changeset 264:04fee26ecce0

add authenticated JRPG conversation platform Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 07:34:12 -0700
parents ee04e4e69fed
children 056790c4fb0d
files .claude/skills/zenbu-bazel-c/SKILL.md .claude/skills/zenbu-personal-site/SKILL.md .hgignore auth/BUILD auth/auth_crypto.c auth/auth_crypto.h auth/auth_store.c auth/auth_store.h auth/hash_password.c auth/test/BUILD auth/test/auth_crypto_test.c auth/test/auth_store_test.c dowa/d_string.c dowa/dowa_test.c mrjunejune/.config.development mrjunejune/BUILD mrjunejune/admin_api.c mrjunejune/admin_api.h mrjunejune/auth_api.c mrjunejune/auth_api.h mrjunejune/conversation_api.c mrjunejune/conversation_api.h mrjunejune/conversation_store.c mrjunejune/conversation_store.h mrjunejune/inference_stack.sh mrjunejune/main.c mrjunejune/src/account/password.html mrjunejune/src/admin/users/index.html mrjunejune/src/jrpg/index.html mrjunejune/src/jrpg/jrpg.css mrjunejune/src/jrpg/jrpg.js mrjunejune/src/login/index.html mrjunejune/src/public/jrpg/background-frame-mobile.webp mrjunejune/src/sw.js mrjunejune/template_renderer.c mrjunejune/template_renderer.h mrjunejune/test/BUILD mrjunejune/test/admin_api_test.c mrjunejune/test/auth_api_test.c mrjunejune/test/config_validation_test.sh mrjunejune/test/conversation_api_test.js mrjunejune/test/conversation_store_test.c mrjunejune/test/production_bundle_exclusion_test.sh mrjunejune/test/snapshots/jrpg.snapshot mrjunejune/test/template_renderer_test.c mrjunejune/test/theme_and_webp_test.js seobeo/s_sse.c seobeo/s_web.c seobeo/seobeo.h seobeo/seobeo_internal.h seobeo/tests/BUILD seobeo/tests/seobeo_request_context_test.c seobeo/tests/seobeo_sse_test.c
diffstat 53 files changed, 22032 insertions(+), 528 deletions(-) [+]
line wrap: on
line diff
--- a/.claude/skills/zenbu-bazel-c/SKILL.md	Thu Aug 06 11:31:30 2026 -0700
+++ b/.claude/skills/zenbu-bazel-c/SKILL.md	Fri Aug 07 07:34:12 2026 -0700
@@ -55,6 +55,19 @@
 
 If a change only touches docs or assistant skill files, Bazel verification is not required.
 
+### Parallel test structure
+
+- Do not let unrelated browser, integration, or end-to-end concerns accumulate
+  in one long serial test process.
+- Split slow suites into independently runnable Bazel test targets by concern.
+  Use a `test_suite` target to preserve a single aggregate command.
+- Run related targets in one `bazel test` invocation so Bazel schedules them in
+  parallel. Do not loop over targets or invoke Bazel separately for each test.
+- Parallel shards must use independent ports, temporary directories, databases,
+  and mutable fixtures. Never share process-global test state between shards.
+- During iteration, run only the narrow shard for the changed behavior; run the
+  aggregate suite before completion.
+
 ## Coding conventions to preserve
 
 - Prefer Dowa's integer and boolean aliases (`uint8`, `uint16`, `uint32`,
--- a/.claude/skills/zenbu-personal-site/SKILL.md	Thu Aug 06 11:31:30 2026 -0700
+++ b/.claude/skills/zenbu-personal-site/SKILL.md	Fri Aug 07 07:34:12 2026 -0700
@@ -72,8 +72,22 @@
 bazel build //mrjunejune:mrjunejune_server
 bazel build //mrjunejune:mrjunejune_server_bundle
 bazel test //mrjunejune/test:integration_test
+bazel test //mrjunejune/test:theme_and_webp_test
 ```
 
+The JRPG browser acceptance is sharded by concern:
+
+- `//mrjunejune/test:jrpg_core_test`
+- `//mrjunejune/test:jrpg_jrpg_test`
+- `//mrjunejune/test:jrpg_routing_test`
+- `//mrjunejune/test:jrpg_hls_test`
+
+`//mrjunejune/test:theme_and_webp_test` is the aggregate `test_suite`; Bazel
+runs its shards in parallel. Keep future slow browser scenarios in the narrowest
+independent shard, or create another shard rather than extending one serial
+test process. Give every shard its own port, temporary database, and fixture
+state.
+
 Run the complete local inference stack with:
 
 ```bash
--- a/.hgignore	Thu Aug 06 11:31:30 2026 -0700
+++ b/.hgignore	Fri Aug 07 07:34:12 2026 -0700
@@ -46,7 +46,10 @@
 
 # Environment var.
 .env
-# Server config
-.config
-# DB
+# Server config (local — never track the real config)
+mrjunejune/.config
+# DB and WAL/SHM sidecars
 mrjunejune.db
+*.db-wal
+*.db-shm
+mrjunejune/data/
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/auth/BUILD	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,44 @@
+load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+  name = "auth_crypto",
+  srcs = ["auth_crypto.c"],
+  hdrs = ["auth_crypto.h"],
+  deps = [
+    "//dowa:dowa",
+    "@openssl//:crypto",
+  ],
+)
+
+cc_library(
+  name = "auth_store",
+  srcs = ["auth_store.c"],
+  hdrs = ["auth_store.h"],
+  deps = [
+    ":auth_crypto",
+    "//deita:deita",
+    "//dowa:dowa",
+    "@openssl//:crypto",
+  ],
+  linkopts = ["-lpthread"],
+)
+
+cc_library(
+  name = "auth",
+  deps = [
+    ":auth_crypto",
+    ":auth_store",
+  ],
+)
+
+cc_binary(
+  name = "hash_password",
+  srcs = ["hash_password.c"],
+  deps = [
+    ":auth_crypto",
+    "@openssl//:crypto",
+  ],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/auth/auth_crypto.c	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,872 @@
+#include "auth/auth_crypto.h"
+
+#include <stdio.h>
+#include <string.h>
+
+#include <openssl/crypto.h>
+#include <openssl/evp.h>
+#include <openssl/hmac.h>
+#include <openssl/rand.h>
+
+#define AUTH_CRYPTO_SCRYPT_N 32768
+#define AUTH_CRYPTO_SCRYPT_R 8
+#define AUTH_CRYPTO_SCRYPT_P 1
+#define AUTH_CRYPTO_SCRYPT_MAXMEM (64ULL * 1024ULL * 1024ULL)
+
+#define AUTH_CRYPTO_PASSWORD_PREFIX "zenbu-scrypt$v=1$N=32768$r=8$p=1$"
+#define AUTH_CRYPTO_COOKIE_VERSION "v1"
+#define AUTH_CRYPTO_COOKIE_HMAC_DOMAIN "zenbu-auth-guest-cookie-v1:"
+#define AUTH_CRYPTO_IP_HMAC_DOMAIN "zenbu-auth-ip-binding-v1:"
+#define AUTH_CRYPTO_HMAC_INPUT_SIZE 384
+
+static boolean auth__bounded_length(
+    const char *text,
+    size_t maximum,
+    size_t *p_length)
+{
+  if (!text || !p_length)
+  {
+    return FALSE;
+  }
+
+  for (size_t i = 0; i <= maximum; ++i)
+  {
+    if (text[i] == '\0')
+    {
+      *p_length = i;
+      return TRUE;
+    }
+  }
+
+  return FALSE;
+}
+
+static void auth__hex_encode(
+    const uint8 *bytes,
+    size_t byte_count,
+    char *hex)
+{
+  static const char alphabet[] = "0123456789abcdef";
+
+  for (size_t i = 0; i < byte_count; ++i)
+  {
+    hex[i * 2] = alphabet[bytes[i] >> 4];
+    hex[i * 2 + 1] = alphabet[bytes[i] & 0x0f];
+  }
+  hex[byte_count * 2] = '\0';
+}
+
+static int auth__hex_value(char value)
+{
+  if (value >= '0' && value <= '9')
+  {
+    return value - '0';
+  }
+  if (value >= 'a' && value <= 'f')
+  {
+    return value - 'a' + 10;
+  }
+  return -1;
+}
+
+static boolean auth__hex_decode(
+    const char *hex,
+    size_t hex_length,
+    uint8 *bytes,
+    size_t byte_count)
+{
+  if (hex_length != byte_count * 2)
+  {
+    return FALSE;
+  }
+
+  for (size_t i = 0; i < byte_count; ++i)
+  {
+    int high = auth__hex_value(hex[i * 2]);
+    int low = auth__hex_value(hex[i * 2 + 1]);
+    if (high < 0 || low < 0)
+    {
+      return FALSE;
+    }
+    bytes[i] = (uint8)((high << 4) | low);
+  }
+
+  return TRUE;
+}
+
+static boolean auth__secret_is_valid(
+    const uint8 *cookie_secret,
+    size_t cookie_secret_length)
+{
+  return cookie_secret &&
+         cookie_secret_length >= AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES &&
+         cookie_secret_length <= AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES;
+}
+
+static boolean auth__uuid_is_valid(const char *uuid, size_t uuid_length)
+{
+  if (uuid_length != AUTH_CRYPTO_GUEST_UUID_SIZE - 1)
+  {
+    return FALSE;
+  }
+
+  for (size_t i = 0; i < uuid_length; ++i)
+  {
+    if (i == 8 || i == 13 || i == 18 || i == 23)
+    {
+      if (uuid[i] != '-')
+      {
+        return FALSE;
+      }
+    }
+    else if (auth__hex_value(uuid[i]) < 0)
+    {
+      return FALSE;
+    }
+  }
+
+  return TRUE;
+}
+
+static boolean auth__parse_uint64(
+    const char *digits,
+    size_t digit_count,
+    uint64 *p_value)
+{
+  if (!digits || !p_value || digit_count == 0 || digit_count > 20 ||
+      (digit_count > 1 && digits[0] == '0'))
+  {
+    return FALSE;
+  }
+
+  uint64 value = 0;
+  uint64 maximum = (uint64)-1;
+  for (size_t i = 0; i < digit_count; ++i)
+  {
+    if (digits[i] < '0' || digits[i] > '9')
+    {
+      return FALSE;
+    }
+
+    uint8 digit = (uint8)(digits[i] - '0');
+    if (value > (maximum - digit) / 10)
+    {
+      return FALSE;
+    }
+    value = value * 10 + digit;
+  }
+
+  *p_value = value;
+  return TRUE;
+}
+
+static Auth_Crypto_Result auth__hmac_sha256(
+    const uint8 *secret,
+    size_t secret_length,
+    const char *domain,
+    const char *value,
+    size_t value_length,
+    uint8 digest[32])
+{
+  size_t domain_length = strlen(domain);
+  if (domain_length + value_length > AUTH_CRYPTO_HMAC_INPUT_SIZE)
+  {
+    return AUTH_CRYPTO_INVALID_ARGUMENT;
+  }
+
+  uint8 input[AUTH_CRYPTO_HMAC_INPUT_SIZE];
+  memcpy(input, domain, domain_length);
+  memcpy(input + domain_length, value, value_length);
+
+  unsigned int digest_length = 0;
+  uint8 *result = HMAC(
+      EVP_sha256(),
+      secret,
+      (int)secret_length,
+      input,
+      domain_length + value_length,
+      digest,
+      &digest_length);
+  OPENSSL_cleanse(input, sizeof(input));
+
+  if (!result || digest_length != 32)
+  {
+    OPENSSL_cleanse(digest, 32);
+    return AUTH_CRYPTO_OPERATION_FAILED;
+  }
+
+  return AUTH_CRYPTO_OK;
+}
+
+static boolean auth__split_cookie(
+    const char *cookie,
+    size_t cookie_length,
+    const char *segments[5],
+    size_t segment_lengths[5])
+{
+  size_t segment_start = 0;
+  size_t segment_count = 0;
+
+  for (size_t i = 0; i <= cookie_length; ++i)
+  {
+    if (i == cookie_length || cookie[i] == '.')
+    {
+      if (segment_count >= 5 || i == segment_start)
+      {
+        return FALSE;
+      }
+      segments[segment_count] = cookie + segment_start;
+      segment_lengths[segment_count] = i - segment_start;
+      ++segment_count;
+      segment_start = i + 1;
+    }
+  }
+
+  return segment_count == 5;
+}
+
+static void auth__base64url_32(
+    const uint8 bytes[AUTH_CRYPTO_TOKEN_BYTES],
+    char token[AUTH_CRYPTO_TOKEN_SIZE])
+{
+  static const char alphabet[] =
+      "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+  size_t input_index = 0;
+  size_t output_index = 0;
+
+  while (input_index + 3 <= AUTH_CRYPTO_TOKEN_BYTES)
+  {
+    uint32 value = ((uint32)bytes[input_index] << 16) |
+                   ((uint32)bytes[input_index + 1] << 8) |
+                   bytes[input_index + 2];
+    token[output_index++] = alphabet[(value >> 18) & 63];
+    token[output_index++] = alphabet[(value >> 12) & 63];
+    token[output_index++] = alphabet[(value >> 6) & 63];
+    token[output_index++] = alphabet[value & 63];
+    input_index += 3;
+  }
+
+  uint32 remainder = ((uint32)bytes[input_index] << 8) |
+                     bytes[input_index + 1];
+  token[output_index++] = alphabet[(remainder >> 10) & 63];
+  token[output_index++] = alphabet[(remainder >> 4) & 63];
+  token[output_index++] = alphabet[(remainder << 2) & 63];
+  token[output_index] = '\0';
+}
+
+Auth_Crypto_Result Auth_Crypto_Password_Hash(
+    const char *password,
+    char *encoded_hash,
+    size_t encoded_hash_capacity)
+{
+  if (!password || !encoded_hash)
+  {
+    return AUTH_CRYPTO_INVALID_ARGUMENT;
+  }
+  if (encoded_hash_capacity < AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE)
+  {
+    return AUTH_CRYPTO_BUFFER_TOO_SMALL;
+  }
+  encoded_hash[0] = '\0';
+
+  size_t password_length = 0;
+  if (!auth__bounded_length(
+          password, AUTH_CRYPTO_PASSWORD_MAX_BYTES, &password_length))
+  {
+    return AUTH_CRYPTO_PASSWORD_TOO_LONG;
+  }
+
+  uint8 salt[AUTH_CRYPTO_PASSWORD_SALT_BYTES];
+  uint8 hash[AUTH_CRYPTO_PASSWORD_HASH_BYTES];
+  if (RAND_bytes(salt, sizeof(salt)) != 1)
+  {
+    OPENSSL_cleanse(salt, sizeof(salt));
+    return AUTH_CRYPTO_RANDOM_FAILED;
+  }
+
+  if (EVP_PBE_scrypt(
+          password,
+          password_length,
+          salt,
+          sizeof(salt),
+          AUTH_CRYPTO_SCRYPT_N,
+          AUTH_CRYPTO_SCRYPT_R,
+          AUTH_CRYPTO_SCRYPT_P,
+          AUTH_CRYPTO_SCRYPT_MAXMEM,
+          hash,
+          sizeof(hash)) != 1)
+  {
+    OPENSSL_cleanse(salt, sizeof(salt));
+    OPENSSL_cleanse(hash, sizeof(hash));
+    return AUTH_CRYPTO_OPERATION_FAILED;
+  }
+
+  char salt_hex[AUTH_CRYPTO_PASSWORD_SALT_BYTES * 2 + 1];
+  char hash_hex[AUTH_CRYPTO_PASSWORD_HASH_BYTES * 2 + 1];
+  auth__hex_encode(salt, sizeof(salt), salt_hex);
+  auth__hex_encode(hash, sizeof(hash), hash_hex);
+
+  int written = snprintf(
+      encoded_hash,
+      encoded_hash_capacity,
+      "%s%s$%s",
+      AUTH_CRYPTO_PASSWORD_PREFIX,
+      salt_hex,
+      hash_hex);
+
+  OPENSSL_cleanse(salt, sizeof(salt));
+  OPENSSL_cleanse(hash, sizeof(hash));
+  OPENSSL_cleanse(hash_hex, sizeof(hash_hex));
+
+  if (written < 0 || (size_t)written >= encoded_hash_capacity)
+  {
+    encoded_hash[0] = '\0';
+    return AUTH_CRYPTO_BUFFER_TOO_SMALL;
+  }
+
+  return AUTH_CRYPTO_OK;
+}
+
+Auth_Crypto_Result Auth_Crypto_Password_Verify(
+    const char *password,
+    const char *encoded_hash)
+{
+  if (!password || !encoded_hash)
+  {
+    return AUTH_CRYPTO_INVALID_ARGUMENT;
+  }
+
+  size_t password_length = 0;
+  if (!auth__bounded_length(
+          password, AUTH_CRYPTO_PASSWORD_MAX_BYTES, &password_length))
+  {
+    return AUTH_CRYPTO_PASSWORD_TOO_LONG;
+  }
+
+  size_t encoded_length = 0;
+  if (!auth__bounded_length(
+          encoded_hash,
+          AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE - 1,
+          &encoded_length))
+  {
+    return AUTH_CRYPTO_MALFORMED;
+  }
+
+  size_t prefix_length = sizeof(AUTH_CRYPTO_PASSWORD_PREFIX) - 1;
+  size_t expected_length = prefix_length +
+                           AUTH_CRYPTO_PASSWORD_SALT_BYTES * 2 + 1 +
+                           AUTH_CRYPTO_PASSWORD_HASH_BYTES * 2;
+  if (encoded_length != expected_length ||
+      memcmp(encoded_hash, AUTH_CRYPTO_PASSWORD_PREFIX, prefix_length) != 0 ||
+      encoded_hash[prefix_length + AUTH_CRYPTO_PASSWORD_SALT_BYTES * 2] != '$')
+  {
+    return AUTH_CRYPTO_MALFORMED;
+  }
+
+  uint8 salt[AUTH_CRYPTO_PASSWORD_SALT_BYTES];
+  uint8 expected_hash[AUTH_CRYPTO_PASSWORD_HASH_BYTES];
+  uint8 calculated_hash[AUTH_CRYPTO_PASSWORD_HASH_BYTES];
+  const char *salt_hex = encoded_hash + prefix_length;
+  const char *hash_hex =
+      salt_hex + AUTH_CRYPTO_PASSWORD_SALT_BYTES * 2 + 1;
+
+  if (!auth__hex_decode(
+          salt_hex,
+          AUTH_CRYPTO_PASSWORD_SALT_BYTES * 2,
+          salt,
+          sizeof(salt)) ||
+      !auth__hex_decode(
+          hash_hex,
+          AUTH_CRYPTO_PASSWORD_HASH_BYTES * 2,
+          expected_hash,
+          sizeof(expected_hash)))
+  {
+    return AUTH_CRYPTO_MALFORMED;
+  }
+
+  if (EVP_PBE_scrypt(
+          password,
+          password_length,
+          salt,
+          sizeof(salt),
+          AUTH_CRYPTO_SCRYPT_N,
+          AUTH_CRYPTO_SCRYPT_R,
+          AUTH_CRYPTO_SCRYPT_P,
+          AUTH_CRYPTO_SCRYPT_MAXMEM,
+          calculated_hash,
+          sizeof(calculated_hash)) != 1)
+  {
+    OPENSSL_cleanse(salt, sizeof(salt));
+    OPENSSL_cleanse(expected_hash, sizeof(expected_hash));
+    OPENSSL_cleanse(calculated_hash, sizeof(calculated_hash));
+    return AUTH_CRYPTO_OPERATION_FAILED;
+  }
+
+  int comparison = CRYPTO_memcmp(
+      calculated_hash, expected_hash, sizeof(calculated_hash));
+  OPENSSL_cleanse(salt, sizeof(salt));
+  OPENSSL_cleanse(expected_hash, sizeof(expected_hash));
+  OPENSSL_cleanse(calculated_hash, sizeof(calculated_hash));
+
+  return comparison == 0
+      ? AUTH_CRYPTO_OK
+      : AUTH_CRYPTO_AUTHENTICATION_FAILED;
+}
+
+Auth_Crypto_Result Auth_Crypto_Password_Hash_Validate(const char *encoded_hash)
+{
+  if (!encoded_hash)
+    return AUTH_CRYPTO_INVALID_ARGUMENT;
+
+  size_t encoded_length = 0;
+  if (!auth__bounded_length(
+          encoded_hash,
+          AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE - 1,
+          &encoded_length))
+    return AUTH_CRYPTO_MALFORMED;
+
+  size_t prefix_length = sizeof(AUTH_CRYPTO_PASSWORD_PREFIX) - 1;
+  size_t expected_length = prefix_length +
+                           AUTH_CRYPTO_PASSWORD_SALT_BYTES * 2 + 1 +
+                           AUTH_CRYPTO_PASSWORD_HASH_BYTES * 2;
+  if (encoded_length != expected_length ||
+      memcmp(encoded_hash, AUTH_CRYPTO_PASSWORD_PREFIX, prefix_length) != 0 ||
+      encoded_hash[prefix_length + AUTH_CRYPTO_PASSWORD_SALT_BYTES * 2] != '$')
+    return AUTH_CRYPTO_MALFORMED;
+
+  /* Validate hex characters in salt and hash fields. */
+  const char *salt_hex = encoded_hash + prefix_length;
+  const char *hash_hex = salt_hex + AUTH_CRYPTO_PASSWORD_SALT_BYTES * 2 + 1;
+  for (size_t i = 0; i < AUTH_CRYPTO_PASSWORD_SALT_BYTES * 2; i++)
+    if (auth__hex_value(salt_hex[i]) < 0)
+      return AUTH_CRYPTO_MALFORMED;
+  for (size_t i = 0; i < AUTH_CRYPTO_PASSWORD_HASH_BYTES * 2; i++)
+    if (auth__hex_value(hash_hex[i]) < 0)
+      return AUTH_CRYPTO_MALFORMED;
+
+  return AUTH_CRYPTO_OK;
+}
+
+Auth_Crypto_Result Auth_Crypto_Token_Generate(
+    char *token,
+    size_t token_capacity)
+{
+  if (!token)
+  {
+    return AUTH_CRYPTO_INVALID_ARGUMENT;
+  }
+  if (token_capacity < AUTH_CRYPTO_TOKEN_SIZE)
+  {
+    return AUTH_CRYPTO_BUFFER_TOO_SMALL;
+  }
+  token[0] = '\0';
+
+  uint8 random_bytes[AUTH_CRYPTO_TOKEN_BYTES];
+  if (RAND_bytes(random_bytes, sizeof(random_bytes)) != 1)
+  {
+    OPENSSL_cleanse(random_bytes, sizeof(random_bytes));
+    return AUTH_CRYPTO_RANDOM_FAILED;
+  }
+
+  auth__base64url_32(random_bytes, token);
+  OPENSSL_cleanse(random_bytes, sizeof(random_bytes));
+  return AUTH_CRYPTO_OK;
+}
+
+Auth_Crypto_Result Auth_Crypto_Token_Digest(
+    const char *token,
+    char *digest_hex,
+    size_t digest_hex_capacity)
+{
+  if (!token || !digest_hex)
+  {
+    return AUTH_CRYPTO_INVALID_ARGUMENT;
+  }
+  if (digest_hex_capacity < AUTH_CRYPTO_TOKEN_DIGEST_SIZE)
+  {
+    return AUTH_CRYPTO_BUFFER_TOO_SMALL;
+  }
+  digest_hex[0] = '\0';
+
+  size_t token_length = 0;
+  if (!auth__bounded_length(token, AUTH_CRYPTO_TOKEN_SIZE - 1, &token_length) ||
+      token_length != AUTH_CRYPTO_TOKEN_SIZE - 1)
+  {
+    return AUTH_CRYPTO_MALFORMED;
+  }
+  for (size_t i = 0; i < token_length; ++i)
+  {
+    char value = token[i];
+    if (!((value >= 'A' && value <= 'Z') ||
+          (value >= 'a' && value <= 'z') ||
+          (value >= '0' && value <= '9') ||
+          value == '-' || value == '_'))
+    {
+      return AUTH_CRYPTO_MALFORMED;
+    }
+  }
+
+  uint8 digest[AUTH_CRYPTO_TOKEN_DIGEST_BYTES];
+  unsigned int digest_length = 0;
+  if (EVP_Digest(
+          token,
+          token_length,
+          digest,
+          &digest_length,
+          EVP_sha256(),
+          NULL) != 1 ||
+      digest_length != AUTH_CRYPTO_TOKEN_DIGEST_BYTES)
+  {
+    OPENSSL_cleanse(digest, sizeof(digest));
+    return AUTH_CRYPTO_OPERATION_FAILED;
+  }
+
+  auth__hex_encode(digest, sizeof(digest), digest_hex);
+  OPENSSL_cleanse(digest, sizeof(digest));
+  return AUTH_CRYPTO_OK;
+}
+
+Auth_Crypto_Result Auth_Crypto_IP_Binding_Digest(
+    const uint8 *cookie_secret,
+    size_t cookie_secret_length,
+    const char *canonical_peer_ip,
+    char *digest_hex,
+    size_t digest_hex_capacity)
+{
+  if (!auth__secret_is_valid(cookie_secret, cookie_secret_length) ||
+      !canonical_peer_ip || !digest_hex)
+  {
+    return AUTH_CRYPTO_INVALID_ARGUMENT;
+  }
+  if (digest_hex_capacity < AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE)
+  {
+    return AUTH_CRYPTO_BUFFER_TOO_SMALL;
+  }
+  digest_hex[0] = '\0';
+
+  size_t ip_length = 0;
+  if (!auth__bounded_length(
+          canonical_peer_ip, AUTH_CRYPTO_IP_MAX_BYTES, &ip_length) ||
+      ip_length == 0)
+  {
+    return AUTH_CRYPTO_INVALID_ARGUMENT;
+  }
+
+  uint8 digest[32];
+  Auth_Crypto_Result result = auth__hmac_sha256(
+      cookie_secret,
+      cookie_secret_length,
+      AUTH_CRYPTO_IP_HMAC_DOMAIN,
+      canonical_peer_ip,
+      ip_length,
+      digest);
+  if (result != AUTH_CRYPTO_OK)
+  {
+    return result;
+  }
+
+  auth__hex_encode(digest, sizeof(digest), digest_hex);
+  OPENSSL_cleanse(digest, sizeof(digest));
+  return AUTH_CRYPTO_OK;
+}
+
+Auth_Crypto_Result Auth_Crypto_Guest_Cookie_Create(
+    const uint8 *cookie_secret,
+    size_t cookie_secret_length,
+    const char *guest_uuid,
+    uint64 expiration_unix,
+    const char *ip_binding_digest,
+    char *cookie,
+    size_t cookie_capacity)
+{
+  if (!auth__secret_is_valid(cookie_secret, cookie_secret_length) ||
+      !guest_uuid || !ip_binding_digest || !cookie ||
+      expiration_unix == 0)
+  {
+    return AUTH_CRYPTO_INVALID_ARGUMENT;
+  }
+  if (cookie_capacity < AUTH_CRYPTO_GUEST_COOKIE_SIZE)
+  {
+    return AUTH_CRYPTO_BUFFER_TOO_SMALL;
+  }
+  cookie[0] = '\0';
+
+  size_t uuid_length = 0;
+  if (!auth__bounded_length(
+          guest_uuid, AUTH_CRYPTO_GUEST_UUID_SIZE - 1, &uuid_length) ||
+      !auth__uuid_is_valid(guest_uuid, uuid_length))
+  {
+    return AUTH_CRYPTO_INVALID_ARGUMENT;
+  }
+
+  size_t ip_digest_length = 0;
+  uint8 ip_digest_bytes[32];
+  if (!auth__bounded_length(
+          ip_binding_digest,
+          AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE - 1,
+          &ip_digest_length) ||
+      !auth__hex_decode(
+          ip_binding_digest,
+          ip_digest_length,
+          ip_digest_bytes,
+          sizeof(ip_digest_bytes)))
+  {
+    return AUTH_CRYPTO_INVALID_ARGUMENT;
+  }
+  OPENSSL_cleanse(ip_digest_bytes, sizeof(ip_digest_bytes));
+
+  char payload[AUTH_CRYPTO_GUEST_COOKIE_SIZE];
+  int payload_written = snprintf(
+      payload,
+      sizeof(payload),
+      "%s.%s.%llu.%s",
+      AUTH_CRYPTO_COOKIE_VERSION,
+      guest_uuid,
+      (unsigned long long)expiration_unix,
+      ip_binding_digest);
+  if (payload_written < 0 || (size_t)payload_written >= sizeof(payload))
+  {
+    return AUTH_CRYPTO_OPERATION_FAILED;
+  }
+
+  uint8 signature[32];
+  Auth_Crypto_Result result = auth__hmac_sha256(
+      cookie_secret,
+      cookie_secret_length,
+      AUTH_CRYPTO_COOKIE_HMAC_DOMAIN,
+      payload,
+      (size_t)payload_written,
+      signature);
+  if (result != AUTH_CRYPTO_OK)
+  {
+    return result;
+  }
+
+  char signature_hex[65];
+  auth__hex_encode(signature, sizeof(signature), signature_hex);
+  OPENSSL_cleanse(signature, sizeof(signature));
+
+  int cookie_written = snprintf(
+      cookie,
+      cookie_capacity,
+      "%s.%s",
+      payload,
+      signature_hex);
+  OPENSSL_cleanse(signature_hex, sizeof(signature_hex));
+  if (cookie_written < 0 || (size_t)cookie_written >= cookie_capacity)
+  {
+    cookie[0] = '\0';
+    return AUTH_CRYPTO_BUFFER_TOO_SMALL;
+  }
+
+  return AUTH_CRYPTO_OK;
+}
+
+Auth_Crypto_Result Auth_Crypto_Guest_Cookie_Verify(
+    const uint8 *cookie_secret,
+    size_t cookie_secret_length,
+    const char *cookie,
+    uint64 current_unix,
+    const char *expected_ip_binding_digest,
+    Auth_Crypto_Guest_Cookie *guest_cookie)
+{
+  if (!auth__secret_is_valid(cookie_secret, cookie_secret_length) ||
+      !cookie || !expected_ip_binding_digest || !guest_cookie)
+  {
+    return AUTH_CRYPTO_INVALID_ARGUMENT;
+  }
+  memset(guest_cookie, 0, sizeof(*guest_cookie));
+
+  size_t expected_ip_length = 0;
+  uint8 expected_ip[32];
+  if (!auth__bounded_length(
+          expected_ip_binding_digest,
+          AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE - 1,
+          &expected_ip_length) ||
+      !auth__hex_decode(
+          expected_ip_binding_digest,
+          expected_ip_length,
+          expected_ip,
+          sizeof(expected_ip)))
+  {
+    return AUTH_CRYPTO_INVALID_ARGUMENT;
+  }
+
+  size_t cookie_length = 0;
+  if (!auth__bounded_length(
+          cookie, AUTH_CRYPTO_GUEST_COOKIE_SIZE - 1, &cookie_length))
+  {
+    OPENSSL_cleanse(expected_ip, sizeof(expected_ip));
+    return AUTH_CRYPTO_MALFORMED;
+  }
+
+  const char *segments[5];
+  size_t segment_lengths[5];
+  if (!auth__split_cookie(
+          cookie, cookie_length, segments, segment_lengths) ||
+      segment_lengths[0] != sizeof(AUTH_CRYPTO_COOKIE_VERSION) - 1 ||
+      memcmp(
+          segments[0],
+          AUTH_CRYPTO_COOKIE_VERSION,
+          sizeof(AUTH_CRYPTO_COOKIE_VERSION) - 1) != 0 ||
+      !auth__uuid_is_valid(segments[1], segment_lengths[1]))
+  {
+    OPENSSL_cleanse(expected_ip, sizeof(expected_ip));
+    return AUTH_CRYPTO_MALFORMED;
+  }
+
+  uint64 expiration_unix = 0;
+  uint8 cookie_ip[32];
+  uint8 provided_signature[32];
+  if (!auth__parse_uint64(
+          segments[2], segment_lengths[2], &expiration_unix) ||
+      expiration_unix == 0 ||
+      !auth__hex_decode(
+          segments[3],
+          segment_lengths[3],
+          cookie_ip,
+          sizeof(cookie_ip)) ||
+      !auth__hex_decode(
+          segments[4],
+          segment_lengths[4],
+          provided_signature,
+          sizeof(provided_signature)))
+  {
+    OPENSSL_cleanse(expected_ip, sizeof(expected_ip));
+    return AUTH_CRYPTO_MALFORMED;
+  }
+
+  size_t payload_length = (size_t)(segments[4] - cookie - 1);
+  uint8 calculated_signature[32];
+  Auth_Crypto_Result result = auth__hmac_sha256(
+      cookie_secret,
+      cookie_secret_length,
+      AUTH_CRYPTO_COOKIE_HMAC_DOMAIN,
+      cookie,
+      payload_length,
+      calculated_signature);
+  if (result != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(expected_ip, sizeof(expected_ip));
+    OPENSSL_cleanse(cookie_ip, sizeof(cookie_ip));
+    OPENSSL_cleanse(provided_signature, sizeof(provided_signature));
+    return result;
+  }
+
+  int signature_comparison = CRYPTO_memcmp(
+      calculated_signature,
+      provided_signature,
+      sizeof(calculated_signature));
+  int ip_comparison = CRYPTO_memcmp(
+      cookie_ip,
+      expected_ip,
+      sizeof(cookie_ip));
+  OPENSSL_cleanse(calculated_signature, sizeof(calculated_signature));
+  OPENSSL_cleanse(provided_signature, sizeof(provided_signature));
+  OPENSSL_cleanse(cookie_ip, sizeof(cookie_ip));
+  OPENSSL_cleanse(expected_ip, sizeof(expected_ip));
+
+  if (signature_comparison != 0)
+  {
+    return AUTH_CRYPTO_AUTHENTICATION_FAILED;
+  }
+  if (ip_comparison != 0)
+  {
+    return AUTH_CRYPTO_IP_MISMATCH;
+  }
+  if (current_unix >= expiration_unix)
+  {
+    return AUTH_CRYPTO_EXPIRED;
+  }
+
+  memcpy(
+      guest_cookie->guest_uuid,
+      segments[1],
+      AUTH_CRYPTO_GUEST_UUID_SIZE - 1);
+  guest_cookie->guest_uuid[AUTH_CRYPTO_GUEST_UUID_SIZE - 1] = '\0';
+  guest_cookie->expiration_unix = expiration_unix;
+  return AUTH_CRYPTO_OK;
+}
+
+const char *Auth_Crypto_Result_String(Auth_Crypto_Result result)
+{
+  switch (result)
+  {
+    case AUTH_CRYPTO_OK:
+      return "ok";
+    case AUTH_CRYPTO_INVALID_ARGUMENT:
+      return "invalid argument";
+    case AUTH_CRYPTO_BUFFER_TOO_SMALL:
+      return "buffer too small";
+    case AUTH_CRYPTO_PASSWORD_TOO_LONG:
+      return "password too long";
+    case AUTH_CRYPTO_RANDOM_FAILED:
+      return "secure random generation failed";
+    case AUTH_CRYPTO_OPERATION_FAILED:
+      return "cryptographic operation failed";
+    case AUTH_CRYPTO_MALFORMED:
+      return "malformed input";
+    case AUTH_CRYPTO_AUTHENTICATION_FAILED:
+      return "authentication failed";
+    case AUTH_CRYPTO_EXPIRED:
+      return "expired";
+    case AUTH_CRYPTO_IP_MISMATCH:
+      return "IP binding mismatch";
+  }
+
+  return "unknown result";
+}
+
+size_t Auth_Crypto_Base64url_Encode(
+    const uint8 *bytes,
+    size_t       byte_count,
+    char        *out,
+    size_t       out_capacity)
+{
+  if (!bytes || !out || out_capacity == 0)
+    return 0;
+
+  /* Unpadded output length: full groups + partial group chars */
+  size_t full_groups   = byte_count / 3;
+  size_t remainder     = byte_count % 3;
+  size_t unpadded_len  = full_groups * 4 +
+                         (remainder == 0 ? 0 : remainder + 1);
+  if (out_capacity < unpadded_len + 1)
+  {
+    out[0] = '\0';
+    return 0;
+  }
+
+  static const char kAlpha[] =
+      "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+
+  size_t in_idx = 0, out_idx = 0;
+
+  /* Full 3-byte groups */
+  while (in_idx + 2 < byte_count)
+  {
+    uint32 v = ((uint32)bytes[in_idx]     << 16) |
+               ((uint32)bytes[in_idx + 1] <<  8) |
+                        bytes[in_idx + 2];
+    out[out_idx++] = kAlpha[(v >> 18) & 0x3f];
+    out[out_idx++] = kAlpha[(v >> 12) & 0x3f];
+    out[out_idx++] = kAlpha[(v >>  6) & 0x3f];
+    out[out_idx++] = kAlpha[(v      ) & 0x3f];
+    in_idx += 3;
+  }
+
+  /* Remaining 1 or 2 bytes (no padding) */
+  if (in_idx < byte_count)
+  {
+    uint32 v = (uint32)bytes[in_idx] << 16;
+    if (in_idx + 1 < byte_count)
+      v |= (uint32)bytes[in_idx + 1] << 8;
+    out[out_idx++] = kAlpha[(v >> 18) & 0x3f];
+    out[out_idx++] = kAlpha[(v >> 12) & 0x3f];
+    if (in_idx + 1 < byte_count)
+      out[out_idx++] = kAlpha[(v >> 6) & 0x3f];
+  }
+
+  out[out_idx] = '\0';
+  return out_idx;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/auth/auth_crypto.h	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,106 @@
+#ifndef ZENBU_AUTH_CRYPTO_H
+#define ZENBU_AUTH_CRYPTO_H
+
+#include "dowa/dowa.h"
+
+#define AUTH_CRYPTO_PASSWORD_MAX_BYTES 1024
+#define AUTH_CRYPTO_PASSWORD_SALT_BYTES 16
+#define AUTH_CRYPTO_PASSWORD_HASH_BYTES 32
+#define AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE 160
+
+#define AUTH_CRYPTO_TOKEN_BYTES 32
+#define AUTH_CRYPTO_TOKEN_SIZE 44
+#define AUTH_CRYPTO_TOKEN_DIGEST_BYTES 32
+#define AUTH_CRYPTO_TOKEN_DIGEST_SIZE 65
+
+#define AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES 32
+#define AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES 1024
+#define AUTH_CRYPTO_IP_MAX_BYTES 128
+#define AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE 65
+#define AUTH_CRYPTO_GUEST_UUID_SIZE 37
+#define AUTH_CRYPTO_GUEST_COOKIE_SIZE 256
+
+typedef enum {
+  AUTH_CRYPTO_OK = 0,
+  AUTH_CRYPTO_INVALID_ARGUMENT = 1,
+  AUTH_CRYPTO_BUFFER_TOO_SMALL = 2,
+  AUTH_CRYPTO_PASSWORD_TOO_LONG = 3,
+  AUTH_CRYPTO_RANDOM_FAILED = 4,
+  AUTH_CRYPTO_OPERATION_FAILED = 5,
+  AUTH_CRYPTO_MALFORMED = 6,
+  AUTH_CRYPTO_AUTHENTICATION_FAILED = 7,
+  AUTH_CRYPTO_EXPIRED = 8,
+  AUTH_CRYPTO_IP_MISMATCH = 9,
+} Auth_Crypto_Result;
+
+typedef struct {
+  char guest_uuid[AUTH_CRYPTO_GUEST_UUID_SIZE];
+  uint64 expiration_unix;
+} Auth_Crypto_Guest_Cookie;
+
+Auth_Crypto_Result Auth_Crypto_Password_Hash(
+    const char *password,
+    char *encoded_hash,
+    size_t encoded_hash_capacity);
+
+Auth_Crypto_Result Auth_Crypto_Password_Verify(
+    const char *password,
+    const char *encoded_hash);
+
+Auth_Crypto_Result Auth_Crypto_Token_Generate(
+    char *token,
+    size_t token_capacity);
+
+Auth_Crypto_Result Auth_Crypto_Token_Digest(
+    const char *token,
+    char *digest_hex,
+    size_t digest_hex_capacity);
+
+Auth_Crypto_Result Auth_Crypto_IP_Binding_Digest(
+    const uint8 *cookie_secret,
+    size_t cookie_secret_length,
+    const char *canonical_peer_ip,
+    char *digest_hex,
+    size_t digest_hex_capacity);
+
+Auth_Crypto_Result Auth_Crypto_Guest_Cookie_Create(
+    const uint8 *cookie_secret,
+    size_t cookie_secret_length,
+    const char *guest_uuid,
+    uint64 expiration_unix,
+    const char *ip_binding_digest,
+    char *cookie,
+    size_t cookie_capacity);
+
+Auth_Crypto_Result Auth_Crypto_Guest_Cookie_Verify(
+    const uint8 *cookie_secret,
+    size_t cookie_secret_length,
+    const char *cookie,
+    uint64 current_unix,
+    const char *expected_ip_binding_digest,
+    Auth_Crypto_Guest_Cookie *guest_cookie);
+
+const char *Auth_Crypto_Result_String(Auth_Crypto_Result result);
+
+/*
+ * Validate an encoded password hash string without running scrypt.
+ * Checks exact format, version string, parameters (N/r/p), hex salt length,
+ * hex hash length, and that all hex characters are valid.
+ * Returns AUTH_CRYPTO_OK if well-formed, AUTH_CRYPTO_MALFORMED otherwise.
+ * Does NOT verify against a password; use Auth_Crypto_Password_Verify for that.
+ */
+Auth_Crypto_Result Auth_Crypto_Password_Hash_Validate(const char *encoded_hash);
+
+/*
+ * Encode byte_count bytes from bytes as unpadded base64url into out.
+ * out_capacity must be at least ((byte_count + 2) / 3 * 4) - padding + 1;
+ * for AUTH_CRYPTO_TOKEN_BYTES (32) bytes that is AUTH_CRYPTO_TOKEN_SIZE (44).
+ * Returns number of characters written (without NUL), or 0 on error.
+ */
+size_t Auth_Crypto_Base64url_Encode(
+    const uint8 *bytes,
+    size_t       byte_count,
+    char        *out,
+    size_t       out_capacity);
+
+#endif
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/auth/auth_store.c	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,2818 @@
+#include "auth/auth_store.h"
+
+#include "deita/deita.h"
+
+#include <openssl/crypto.h>
+
+#include <fcntl.h>
+#include <limits.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+struct Auth_Store {
+  Deita_Connection *p_connection;
+  pthread_mutex_t   mutex;
+};
+
+/* ------------------------------------------------------------------ */
+/* Migration SQL                                                        */
+/* ------------------------------------------------------------------ */
+
+static const char *k_migration_v1 =
+    "CREATE TABLE IF NOT EXISTS users ("
+    "  id TEXT PRIMARY KEY,"
+    "  username TEXT NOT NULL,"
+    "  normalized_username TEXT NOT NULL UNIQUE,"
+    "  password_hash TEXT NOT NULL,"
+    "  role TEXT NOT NULL CHECK(role IN ('admin','member')),"
+    "  status TEXT NOT NULL DEFAULT 'active'"
+    "    CHECK(status IN ('active','disabled')),"
+    "  must_change_password INTEGER NOT NULL DEFAULT 0,"
+    "  password_changed_at INTEGER NOT NULL DEFAULT 0,"
+    "  created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+    "  updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
+    ");"
+    "CREATE TABLE IF NOT EXISTS auth_sessions ("
+    "  token_digest TEXT PRIMARY KEY,"
+    "  csrf_digest TEXT NOT NULL,"
+    "  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,"
+    "  created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+    "  last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+    "  idle_expires_at INTEGER NOT NULL,"
+    "  absolute_expires_at INTEGER NOT NULL,"
+    "  password_changed_at_snapshot INTEGER NOT NULL DEFAULT 0,"
+    "  revoked_at INTEGER"
+    ");"
+    "CREATE TABLE IF NOT EXISTS guest_identities ("
+    "  id TEXT PRIMARY KEY,"
+    "  ip_binding_digest TEXT NOT NULL,"
+    "  created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+    "  last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+    "  expires_at INTEGER NOT NULL"
+    ");"
+    "CREATE TABLE IF NOT EXISTS guest_usage ("
+    "  guest_id TEXT NOT NULL"
+    "    REFERENCES guest_identities(id) ON DELETE CASCADE,"
+    "  window_start INTEGER NOT NULL,"
+    "  count INTEGER NOT NULL DEFAULT 0,"
+    "  PRIMARY KEY (guest_id, window_start)"
+    ");"
+    "CREATE TABLE IF NOT EXISTS guest_usage_reservations ("
+    "  id INTEGER PRIMARY KEY AUTOINCREMENT,"
+    "  guest_id TEXT NOT NULL"
+    "    REFERENCES guest_identities(id) ON DELETE CASCADE,"
+    "  reserved_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+    "  expires_at INTEGER NOT NULL"
+    ");"
+    "CREATE TABLE IF NOT EXISTS admin_audit_log ("
+    "  id INTEGER PRIMARY KEY AUTOINCREMENT,"
+    "  actor_user_id TEXT,"
+    "  action TEXT NOT NULL,"
+    "  target_user_id TEXT,"
+    "  detail TEXT,"
+    "  created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
+    ");"
+    "CREATE INDEX IF NOT EXISTS idx_users_normalized"
+    "  ON users(normalized_username);"
+    "CREATE INDEX IF NOT EXISTS idx_sessions_user"
+    "  ON auth_sessions(user_id);"
+    "CREATE INDEX IF NOT EXISTS idx_sessions_expiry"
+    "  ON auth_sessions(absolute_expires_at) WHERE revoked_at IS NULL;"
+    "CREATE INDEX IF NOT EXISTS idx_guest_expiry"
+    "  ON guest_identities(expires_at);"
+    "CREATE INDEX IF NOT EXISTS idx_audit_created"
+    "  ON admin_audit_log(created_at DESC);";
+
+/* ------------------------------------------------------------------ */
+/* Internal helpers                                                     */
+/* ------------------------------------------------------------------ */
+
+static void auth__copy_text_fixed(char *dest, size_t size, const char *text)
+{
+  const char *src = text ? text : "";
+  size_t      n   = strlen(src);
+  if (n >= size)
+    n = size - 1;
+  memcpy(dest, src, n);
+  dest[n] = '\0';
+}
+
+static boolean auth__generate_uuid(char output[37])
+{
+  uint8   bytes[16];
+  int     fd     = open("/dev/urandom", O_RDONLY);
+  size_t  offset = 0;
+  ssize_t amount;
+
+  if (fd < 0)
+    return FALSE;
+  while (offset < sizeof(bytes))
+  {
+    amount = read(fd, bytes + offset, sizeof(bytes) - offset);
+    if (amount <= 0)
+    {
+      close(fd);
+      return FALSE;
+    }
+    offset += (size_t)amount;
+  }
+  close(fd);
+
+  bytes[6] = (uint8)((bytes[6] & 0x0f) | 0x40);
+  bytes[8] = (uint8)((bytes[8] & 0x3f) | 0x80);
+  snprintf(
+      output, 37,
+      "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
+      bytes[0],  bytes[1],  bytes[2],  bytes[3],
+      bytes[4],  bytes[5],  bytes[6],  bytes[7],
+      bytes[8],  bytes[9],  bytes[10], bytes[11],
+      bytes[12], bytes[13], bytes[14], bytes[15]);
+  return TRUE;
+}
+
+static Auth_Store_Result auth__rollback(Auth_Store *p_store,
+                                        Auth_Store_Result result)
+{
+  Deita_Query_Execute_Update(p_store->p_connection, "ROLLBACK");
+  return result;
+}
+
+/* Forward declaration — implementation is in the guest quota section. */
+static void auth__i64_str(char *buf, size_t size, int64 value);
+
+static boolean auth__insert_audit_log_locked(
+    Auth_Store *p_store,
+    const char *actor_user_id,
+    const char *action,
+    const char *target_user_id,
+    const char *detail)
+{
+  /* actor_user_id and detail may be NULL — represented as empty string */
+  const char *actor  = actor_user_id  ? actor_user_id  : "";
+  const char *tgt    = target_user_id ? target_user_id : "";
+  const char *det    = detail         ? detail         : "";
+  const char *params[] = {actor, action, tgt, det};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "INSERT INTO admin_audit_log"
+          "  (actor_user_id, action, target_user_id, detail)"
+          "  VALUES (?, ?, ?, ?)",
+          4, params) < 0)
+    return FALSE;
+  /* Trim to the most recent 10 000 entries. */
+  Deita_Query_Execute_Update(
+      p_store->p_connection,
+      "DELETE FROM admin_audit_log"
+      "  WHERE id <= (SELECT MAX(id) FROM admin_audit_log) - 10000");
+  return TRUE;
+}
+
+static boolean auth__digest_is_valid(const char *digest)
+{
+  if (!digest)
+    return FALSE;
+  for (size_t i = 0; i < AUTH_CRYPTO_TOKEN_DIGEST_SIZE - 1; i++)
+  {
+    uint8 c = (uint8)digest[i];
+    if (c == '\0' ||
+        !((c >= '0' && c <= '9') ||
+          (c >= 'a' && c <= 'f') ||
+          (c >= 'A' && c <= 'F')))
+      return FALSE;
+  }
+  return digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE - 1] == '\0';
+}
+
+static boolean auth__session_arguments_are_valid(
+    const char *token_digest,
+    const char *csrf_digest,
+    int64       idle_ttl_secs,
+    int64       absolute_ttl_secs,
+    int64       current_unix)
+{
+  if (!auth__digest_is_valid(token_digest) ||
+      !auth__digest_is_valid(csrf_digest) ||
+      current_unix < 0 || idle_ttl_secs <= 0 || absolute_ttl_secs <= 0 ||
+      idle_ttl_secs > absolute_ttl_secs)
+    return FALSE;
+  if (current_unix > (int64)LLONG_MAX - idle_ttl_secs ||
+      current_unix > (int64)LLONG_MAX - absolute_ttl_secs)
+    return FALSE;
+  return TRUE;
+}
+
+static void auth__fill_session_record(
+    Auth_Session_Record *p_record,
+    const char          *user_id,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    int64                password_changed_at)
+{
+  memset(p_record, 0, sizeof(*p_record));
+  auth__copy_text_fixed(p_record->user_id, sizeof(p_record->user_id), user_id);
+  p_record->created_at = current_unix;
+  p_record->last_seen_at = current_unix;
+  p_record->idle_expires_at = current_unix + idle_ttl_secs;
+  p_record->absolute_expires_at = current_unix + absolute_ttl_secs;
+  p_record->password_changed_at_snapshot = password_changed_at;
+}
+
+static Auth_Store_Result auth__insert_session_locked(
+    Auth_Store          *p_store,
+    const char          *user_id,
+    const char          *token_digest,
+    const char          *csrf_digest,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    int64                password_changed_at)
+{
+  char created_str[32], idle_exp_str[32], abs_exp_str[32], snap_str[32];
+  snprintf(created_str, sizeof(created_str), "%lld", (long long)current_unix);
+  snprintf(idle_exp_str, sizeof(idle_exp_str), "%lld",
+           (long long)(current_unix + idle_ttl_secs));
+  snprintf(abs_exp_str, sizeof(abs_exp_str), "%lld",
+           (long long)(current_unix + absolute_ttl_secs));
+  snprintf(snap_str, sizeof(snap_str), "%lld",
+           (long long)password_changed_at);
+
+  const char *params[] = {
+    token_digest, csrf_digest, user_id,
+    created_str, created_str, idle_exp_str, abs_exp_str, snap_str
+  };
+  int32 result = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "INSERT INTO auth_sessions"
+      "  (token_digest, csrf_digest, user_id,"
+      "   created_at, last_seen_at, idle_expires_at,"
+      "   absolute_expires_at, password_changed_at_snapshot)"
+      "  VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+      8, params);
+  return result < 0 ? AUTH_STORE_CONFLICT : AUTH_STORE_OK;
+}
+
+/* ------------------------------------------------------------------ */
+/* Migration system                                                     */
+/* ------------------------------------------------------------------ */
+
+static boolean auth__apply_migration(Auth_Store *p_store,
+                                     int32       version,
+                                     const char *sql)
+{
+  char version_str[32];
+  snprintf(version_str, sizeof(version_str), "%d", (int)version);
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN EXCLUSIVE") < 0)
+    return FALSE;
+
+  /* Check if already applied. */
+  Dowa_Arena       *p_arena = Dowa_Arena_Create(1024);
+  if (!p_arena)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    return FALSE;
+  }
+  const char       *check_params[] = {version_str};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT version FROM auth_schema_migrations WHERE version = ?",
+      1, check_params, p_arena);
+  boolean already = p_result && Deita_Result_Set_Next(p_result);
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  if (already)
+  {
+    if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+    {
+      auth__rollback(p_store, AUTH_STORE_ERROR);
+      return FALSE;
+    }
+    return TRUE;
+  }
+
+  /* Apply the migration. */
+  if (Deita_Query_Execute_Update(p_store->p_connection, sql) < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    return FALSE;
+  }
+
+  const char *ins_params[] = {version_str};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "INSERT INTO auth_schema_migrations (version) VALUES (?)",
+          1, ins_params) < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    return FALSE;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    return FALSE;
+  }
+  return TRUE;
+}
+
+/*
+ * Migration v2: extend guest quota tables.
+ * - Backfills turns_used from the legacy count column before any new schema
+ *   is applied, so existing usage is preserved (req 1).
+ * - Adds turns_used, output_tokens_used, output_tokens_reserved to guest_usage.
+ * - Replaces guest_usage_reservations with a richer schema keyed by request_id.
+ * The old reservations table is ephemeral (in-flight requests only), so
+ * dropping and recreating it is safe; legacy reservations had no token-amount
+ * column so output_tokens_reserved stays 0 after the replace.
+ */
+static const char *k_migration_v2 =
+    "ALTER TABLE guest_usage"
+    "  ADD COLUMN turns_used INTEGER NOT NULL DEFAULT 0;"
+    "ALTER TABLE guest_usage"
+    "  ADD COLUMN output_tokens_used INTEGER NOT NULL DEFAULT 0;"
+    "ALTER TABLE guest_usage"
+    "  ADD COLUMN output_tokens_reserved INTEGER NOT NULL DEFAULT 0;"
+    /* Backfill turns_used from the legacy request-count column. */
+    "UPDATE guest_usage SET turns_used = count WHERE count > 0;"
+    "DROP TABLE IF EXISTS guest_usage_reservations;"
+    "CREATE TABLE IF NOT EXISTS guest_usage_reservations ("
+    "  request_id TEXT PRIMARY KEY,"
+    "  guest_id TEXT NOT NULL"
+    "    REFERENCES guest_identities(id) ON DELETE CASCADE,"
+    "  window_start INTEGER NOT NULL,"
+    "  output_tokens_reserved INTEGER NOT NULL DEFAULT 0,"
+    "  reserved_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+    "  expires_at INTEGER NOT NULL"
+    ");"
+    "CREATE INDEX IF NOT EXISTS idx_reservations_guest"
+    "  ON guest_usage_reservations(guest_id);"
+    "CREATE INDEX IF NOT EXISTS idx_reservations_expiry"
+    "  ON guest_usage_reservations(expires_at);"
+    "CREATE INDEX IF NOT EXISTS idx_guest_usage_guest"
+    "  ON guest_usage(guest_id);";
+
+static boolean auth__run_migrations(Auth_Store *p_store)
+{
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "CREATE TABLE IF NOT EXISTS auth_schema_migrations ("
+          "  version INTEGER PRIMARY KEY,"
+          "  applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
+          ")") < 0)
+    return FALSE;
+
+  if (!auth__apply_migration(p_store, 1, k_migration_v1))
+    return FALSE;
+  return auth__apply_migration(p_store, 2, k_migration_v2);
+}
+
+/* ------------------------------------------------------------------ */
+/* Store lifecycle                                                      */
+/* ------------------------------------------------------------------ */
+
+/*
+ * Reap expired reservations with the mutex already held.
+ * Run atomically in a nested SAVEPOINT to avoid interfering with any outer
+ * transaction (callers sometimes hold BEGIN IMMEDIATE).
+ */
+static void auth__reap_expired_reservations_locked(
+    Auth_Store *p_store,
+    int64       current_unix)
+{
+  char now_str[32];
+  auth__i64_str(now_str, sizeof(now_str), current_unix);
+
+  /* Use a savepoint so we can run inside or outside a transaction. */
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "SAVEPOINT reap_expired") < 0)
+    return;
+
+  const char *upd_params[] = {now_str, now_str};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "UPDATE guest_usage"
+          "  SET output_tokens_reserved = MAX(0, output_tokens_reserved - ("
+          "    SELECT COALESCE(SUM(r.output_tokens_reserved), 0)"
+          "    FROM guest_usage_reservations r"
+          "    WHERE r.guest_id = guest_usage.guest_id"
+          "      AND r.window_start = guest_usage.window_start"
+          "      AND r.expires_at < ?"
+          "  ))"
+          "  WHERE output_tokens_reserved > 0"
+          "    AND EXISTS ("
+          "      SELECT 1 FROM guest_usage_reservations r2"
+          "      WHERE r2.guest_id = guest_usage.guest_id"
+          "        AND r2.expires_at < ?)",
+          2, upd_params) < 0)
+  {
+    Deita_Query_Execute_Update(p_store->p_connection,
+                               "ROLLBACK TO reap_expired");
+    Deita_Query_Execute_Update(p_store->p_connection, "RELEASE reap_expired");
+    return;
+  }
+
+  const char *del_params[] = {now_str};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "DELETE FROM guest_usage_reservations WHERE expires_at < ?",
+          1, del_params) < 0)
+  {
+    Deita_Query_Execute_Update(p_store->p_connection,
+                               "ROLLBACK TO reap_expired");
+    Deita_Query_Execute_Update(p_store->p_connection, "RELEASE reap_expired");
+    return;
+  }
+
+  Deita_Query_Execute_Update(p_store->p_connection, "RELEASE reap_expired");
+}
+
+Auth_Store *Auth_Store_Create(const char *database_path)
+{
+  if (!database_path)
+    return NULL;
+
+  Auth_Store *p_store = calloc(1, sizeof(*p_store));
+  if (!p_store)
+    return NULL;
+
+  p_store->p_connection = Deita_Connection_Create(
+      DEITA_DATABASE_TYPE_SQLITE3, database_path);
+  if (!p_store->p_connection ||
+      !Deita_Connection_Is_Open(p_store->p_connection))
+  {
+    if (p_store->p_connection)
+      Deita_Connection_Close(p_store->p_connection);
+    free(p_store);
+    return NULL;
+  }
+
+  if (pthread_mutex_init(&p_store->mutex, NULL) != 0)
+  {
+    Deita_Connection_Close(p_store->p_connection);
+    free(p_store);
+    return NULL;
+  }
+
+  /* Connection-level settings — must be re-applied on every open. */
+  if (Deita_Query_Execute_Update(p_store->p_connection,
+          "PRAGMA foreign_keys = ON;"
+          "PRAGMA journal_mode = WAL;") < 0)
+  {
+    Auth_Store_Destroy(p_store);
+    return NULL;
+  }
+
+  if (!auth__run_migrations(p_store))
+  {
+    Auth_Store_Destroy(p_store);
+    return NULL;
+  }
+
+  /* Reap any expired reservations left over from a previous run. */
+  pthread_mutex_lock(&p_store->mutex);
+  auth__reap_expired_reservations_locked(p_store, (int64)time(NULL));
+  pthread_mutex_unlock(&p_store->mutex);
+
+  return p_store;
+}
+
+void Auth_Store_Destroy(Auth_Store *p_store)
+{
+  if (!p_store)
+    return;
+  if (p_store->p_connection)
+    Deita_Connection_Close(p_store->p_connection);
+  pthread_mutex_destroy(&p_store->mutex);
+  free(p_store);
+}
+
+/* ------------------------------------------------------------------ */
+/* Username utilities                                                   */
+/* ------------------------------------------------------------------ */
+
+boolean Auth_Store_Normalize_Username(
+    const char *username,
+    char       *normalized,
+    size_t      capacity)
+{
+  if (!username || !normalized || capacity == 0)
+    return FALSE;
+
+  size_t len   = strlen(username);
+  size_t start = 0;
+  size_t end   = len;
+
+  while (start < len && username[start] == ' ')
+    start++;
+  while (end > start && username[end - 1] == ' ')
+    end--;
+
+  size_t norm_len = end - start;
+  if (norm_len < AUTH_STORE_USERNAME_MIN ||
+      norm_len > AUTH_STORE_USERNAME_MAX)
+    return FALSE;
+  if (capacity <= norm_len)
+    return FALSE;
+
+  for (size_t i = 0; i < norm_len; i++)
+  {
+    uint8 c = (uint8)username[start + i];
+    if (c >= 'A' && c <= 'Z')
+      c = (uint8)(c - 'A' + 'a');
+    else if ((c >= 'a' && c <= 'z') ||
+             (c >= '0' && c <= '9') ||
+             c == '_' || c == '-' || c == '.')
+      ; /* valid as-is */
+    else
+      return FALSE;
+    normalized[i] = (char)c;
+  }
+  normalized[norm_len] = '\0';
+  return TRUE;
+}
+
+boolean Auth_Store_Validate_Username(const char *normalized_username)
+{
+  if (!normalized_username)
+    return FALSE;
+  size_t i = 0;
+  while (normalized_username[i] != '\0')
+  {
+    if (i >= AUTH_STORE_USERNAME_MAX)
+      return FALSE;
+    uint8 c = (uint8)normalized_username[i];
+    if (!((c >= 'a' && c <= 'z') ||
+          (c >= '0' && c <= '9') ||
+          c == '_' || c == '-' || c == '.'))
+      return FALSE;
+    i++;
+  }
+  return i >= AUTH_STORE_USERNAME_MIN;
+}
+
+/* ------------------------------------------------------------------ */
+/* Internal: populate Auth_User_Record from an open result set         */
+/* ------------------------------------------------------------------ */
+
+/*
+ * Columns expected (0-based):
+ *   0 id, 1 username, 2 normalized_username, 3 role, 4 status,
+ *   5 must_change_password, 6 password_changed_at, 7 created_at, 8 updated_at
+ */
+static void auth__read_user_record(Auth_User_Record *r,
+                                   Deita_Result_Set *p)
+{
+  auth__copy_text_fixed(r->id,                 sizeof(r->id),
+                        Deita_Result_Set_Get_Text(p, 0));
+  auth__copy_text_fixed(r->username,           sizeof(r->username),
+                        Deita_Result_Set_Get_Text(p, 1));
+  auth__copy_text_fixed(r->normalized_username,sizeof(r->normalized_username),
+                        Deita_Result_Set_Get_Text(p, 2));
+  auth__copy_text_fixed(r->role,               sizeof(r->role),
+                        Deita_Result_Set_Get_Text(p, 3));
+  auth__copy_text_fixed(r->status,             sizeof(r->status),
+                        Deita_Result_Set_Get_Text(p, 4));
+  r->must_change_password = Deita_Result_Set_Get_Integer(p, 5) ? TRUE : FALSE;
+  r->password_changed_at  = Deita_Result_Set_Get_Integer(p, 6);
+  r->created_at           = Deita_Result_Set_Get_Integer(p, 7);
+  r->updated_at           = Deita_Result_Set_Get_Integer(p, 8);
+}
+
+/* ------------------------------------------------------------------ */
+/* User management                                                      */
+/* ------------------------------------------------------------------ */
+
+Auth_Store_Result Auth_Store_Create_User(
+    Auth_Store *p_store,
+    const char *username,
+    const char *encoded_hash,
+    const char *role,
+    boolean     must_change_password,
+    char        output_id[37])
+{
+  if (!p_store || !username || !encoded_hash || !role || !output_id)
+    return AUTH_STORE_INVALID_ARG;
+  if (encoded_hash[0] == '\0')
+    return AUTH_STORE_INVALID_ARG;
+  if (strcmp(role, "admin") != 0 && strcmp(role, "member") != 0)
+    return AUTH_STORE_INVALID_ARG;
+
+  char normalized[AUTH_STORE_USERNAME_MAX + 1];
+  if (!Auth_Store_Normalize_Username(
+          username, normalized, sizeof(normalized)))
+    return AUTH_STORE_INVALID_ARG;
+
+  if (!auth__generate_uuid(output_id))
+    return AUTH_STORE_ERROR;
+
+  const char *mcp_str = must_change_password ? "1" : "0";
+  const char *params[] = {
+    output_id, username, normalized, encoded_hash, role, mcp_str
+  };
+
+  pthread_mutex_lock(&p_store->mutex);
+  int32 result = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "INSERT INTO users"
+      "  (id, username, normalized_username, password_hash, role,"
+      "   must_change_password)"
+      "  VALUES (?, ?, ?, ?, ?, ?)",
+      6, params);
+  pthread_mutex_unlock(&p_store->mutex);
+
+  if (result < 0)
+    return AUTH_STORE_CONFLICT; /* most likely UNIQUE constraint on norm_name */
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Create_User_Audited(
+    Auth_Store *p_store,
+    const char *username,
+    const char *encoded_hash,
+    const char *role,
+    boolean     must_change_password,
+    const char *actor_user_id,
+    char        output_id[37])
+{
+  if (!p_store || !username || !encoded_hash || !role || !output_id)
+    return AUTH_STORE_INVALID_ARG;
+  if (encoded_hash[0] == '\0')
+    return AUTH_STORE_INVALID_ARG;
+  if (strcmp(role, "admin") != 0 && strcmp(role, "member") != 0)
+    return AUTH_STORE_INVALID_ARG;
+
+  char normalized[AUTH_STORE_USERNAME_MAX + 1];
+  if (!Auth_Store_Normalize_Username(
+          username, normalized, sizeof(normalized)))
+    return AUTH_STORE_INVALID_ARG;
+  if (!auth__generate_uuid(output_id))
+    return AUTH_STORE_ERROR;
+
+  const char *mcp_str = must_change_password ? "1" : "0";
+  const char *params[] = {
+    output_id, username, normalized, encoded_hash, role, mcp_str
+  };
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  int32 result = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "INSERT INTO users"
+      "  (id, username, normalized_username, password_hash, role,"
+      "   must_change_password)"
+      "  VALUES (?, ?, ?, ?, ?, ?)",
+      6, params);
+  if (result < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_CONFLICT;
+  }
+
+  if (!auth__insert_audit_log_locked(
+          p_store, actor_user_id, "admin_user_created", output_id, role))
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Bootstrap_Admin(
+    Auth_Store               *p_store,
+    const char               *username,
+    const char               *encoded_hash,
+    Auth_Store_Bootstrap_Result *p_bootstrap_result,
+    char                      output_id[37])
+{
+  if (!p_store || !username || !encoded_hash || !p_bootstrap_result)
+    return AUTH_STORE_INVALID_ARG;
+  if (encoded_hash[0] == '\0')
+    return AUTH_STORE_INVALID_ARG;
+
+  char normalized[AUTH_STORE_USERNAME_MAX + 1];
+  if (!Auth_Store_Normalize_Username(
+          username, normalized, sizeof(normalized)))
+    return AUTH_STORE_INVALID_ARG;
+
+  char id_buf[37];
+  if (!auth__generate_uuid(id_buf))
+    return AUTH_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  /* Check for any existing admin (active or disabled). */
+  Dowa_Arena       *p_arena = Dowa_Arena_Create(2048);
+  if (!p_arena)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  Deita_Result_Set *p_result = Deita_Query_Execute(
+      p_store->p_connection,
+      "SELECT id FROM users WHERE role = 'admin' LIMIT 1",
+      p_arena);
+  boolean admin_exists = p_result && Deita_Result_Set_Next(p_result);
+  char existing_id[37] = {0};
+  if (admin_exists && p_result)
+    auth__copy_text_fixed(existing_id, sizeof(existing_id),
+                          Deita_Result_Set_Get_Text(p_result, 0));
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  if (admin_exists)
+  {
+    if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+    {
+      auth__rollback(p_store, AUTH_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return AUTH_STORE_ERROR;
+    }
+    pthread_mutex_unlock(&p_store->mutex);
+    *p_bootstrap_result = AUTH_STORE_BOOTSTRAP_ALREADY_PRESENT;
+    if (output_id)
+      auth__copy_text_fixed(output_id, 37, existing_id);
+    return AUTH_STORE_OK;
+  }
+
+  const char *params[] = {
+    id_buf, username, normalized, encoded_hash, "admin"
+  };
+  int32 ins = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "INSERT INTO users"
+      "  (id, username, normalized_username, password_hash, role)"
+      "  VALUES (?, ?, ?, ?, ?)",
+      5, params);
+  if (ins < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (!auth__insert_audit_log_locked(
+          p_store, NULL, "bootstrap_admin_created", id_buf, NULL))
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+  *p_bootstrap_result = AUTH_STORE_BOOTSTRAP_CREATED;
+  if (output_id)
+    auth__copy_text_fixed(output_id, 37, id_buf);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Find_User_By_Username(
+    Auth_Store            *p_store,
+    const char            *username,
+    Auth_User_Auth_Record *p_record)
+{
+  if (!p_store || !username || !p_record)
+    return AUTH_STORE_INVALID_ARG;
+
+  char normalized[AUTH_STORE_USERNAME_MAX + 1];
+  if (!Auth_Store_Normalize_Username(
+          username, normalized, sizeof(normalized)))
+    return AUTH_STORE_NOT_FOUND;
+
+  memset(p_record, 0, sizeof(*p_record));
+
+  Dowa_Arena       *p_arena = Dowa_Arena_Create(2048);
+  if (!p_arena)
+    return AUTH_STORE_ERROR;
+
+  const char       *params[] = {normalized};
+  pthread_mutex_lock(&p_store->mutex);
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT id, username, normalized_username, role, status,"
+      "       must_change_password, password_changed_at,"
+      "       created_at, updated_at, password_hash"
+      "  FROM users WHERE normalized_username = ?",
+      1, params, p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_NOT_FOUND;
+  }
+  auth__read_user_record(&p_record->user, p_result);
+  auth__copy_text_fixed(p_record->password_hash,
+                        sizeof(p_record->password_hash),
+                        Deita_Result_Set_Get_Text(p_result, 9));
+  Deita_Result_Set_Free(p_result);
+  pthread_mutex_unlock(&p_store->mutex);
+  Dowa_Arena_Free(p_arena);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Get_User(
+    Auth_Store       *p_store,
+    const char       *user_id,
+    Auth_User_Record *p_record)
+{
+  if (!p_store || !user_id || !p_record)
+    return AUTH_STORE_INVALID_ARG;
+
+  memset(p_record, 0, sizeof(*p_record));
+
+  Dowa_Arena       *p_arena = Dowa_Arena_Create(2048);
+  if (!p_arena)
+    return AUTH_STORE_ERROR;
+
+  const char       *params[] = {user_id};
+  pthread_mutex_lock(&p_store->mutex);
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT id, username, normalized_username, role, status,"
+      "       must_change_password, password_changed_at,"
+      "       created_at, updated_at"
+      "  FROM users WHERE id = ?",
+      1, params, p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_NOT_FOUND;
+  }
+  auth__read_user_record(p_record, p_result);
+  Deita_Result_Set_Free(p_result);
+  pthread_mutex_unlock(&p_store->mutex);
+  Dowa_Arena_Free(p_arena);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_List_Users(
+    Auth_Store        *p_store,
+    Auth_User_Record **pp_records,
+    Dowa_Arena        *p_arena)
+{
+  if (!p_store || !pp_records || !p_arena)
+    return AUTH_STORE_INVALID_ARG;
+
+  *pp_records = NULL;
+
+  Dowa_Arena *p_local = Dowa_Arena_Create(2048);
+  if (!p_local)
+    return AUTH_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+  Deita_Result_Set *p_result = Deita_Query_Execute(
+      p_store->p_connection,
+      "SELECT id, username, normalized_username, role, status,"
+      "       must_change_password, password_changed_at,"
+      "       created_at, updated_at"
+      "  FROM users ORDER BY created_at",
+      p_local);
+  if (!p_result)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_local);
+    return AUTH_STORE_ERROR;
+  }
+
+  Auth_User_Record *records = NULL;
+  while (Deita_Result_Set_Next(p_result))
+  {
+    Auth_User_Record record;
+    memset(&record, 0, sizeof(record));
+    auth__read_user_record(&record, p_result);
+    Dowa_Array_Push_Arena(records, record, p_arena);
+  }
+  boolean err = Deita_Result_Set_Has_Error(p_result);
+  Deita_Result_Set_Free(p_result);
+  pthread_mutex_unlock(&p_store->mutex);
+  Dowa_Arena_Free(p_local);
+
+  if (err)
+    return AUTH_STORE_ERROR;
+
+  *pp_records = records;
+  return AUTH_STORE_OK;
+}
+
+static Auth_Store_Result auth__update_user_status(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_status,
+    const char *actor_user_id,
+    boolean     revoke_sessions)
+{
+  if (!p_store || !user_id || !new_status)
+    return AUTH_STORE_INVALID_ARG;
+  if (strcmp(new_status, "active") != 0 &&
+      strcmp(new_status, "disabled") != 0)
+    return AUTH_STORE_INVALID_ARG;
+
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  /* Fetch current role and status. */
+  Dowa_Arena       *p_arena = Dowa_Arena_Create(2048);
+  if (!p_arena)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+  const char       *sel_params[] = {user_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT role, status FROM users WHERE id = ?",
+      1, sel_params, p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_arena);
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_NOT_FOUND;
+  }
+  char cur_role[8], cur_status[9];
+  auth__copy_text_fixed(cur_role,   sizeof(cur_role),
+                        Deita_Result_Set_Get_Text(p_result, 0));
+  auth__copy_text_fixed(cur_status, sizeof(cur_status),
+                        Deita_Result_Set_Get_Text(p_result, 1));
+  Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  /* Last-admin protection: prevent disabling the final active admin. */
+  if (strcmp(new_status, "disabled") == 0 &&
+      strcmp(cur_role, "admin")      == 0 &&
+      strcmp(cur_status, "active")   == 0)
+  {
+    Dowa_Arena *p_count_arena = Dowa_Arena_Create(1024);
+    if (!p_count_arena)
+    {
+      auth__rollback(p_store, AUTH_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return AUTH_STORE_ERROR;
+    }
+    p_result = Deita_Query_Execute(
+        p_store->p_connection,
+        "SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'",
+        p_count_arena);
+    int64 count = 0;
+    if (p_result && Deita_Result_Set_Next(p_result))
+      count = Deita_Result_Set_Get_Integer(p_result, 0);
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_count_arena);
+
+    if (count <= 1)
+    {
+      auth__rollback(p_store, AUTH_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return AUTH_STORE_LAST_ADMIN;
+    }
+  }
+
+  const char *upd_params[] = {new_status, user_id};
+  int32 upd = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE users SET status = ?, updated_at = strftime('%s','now')"
+      "  WHERE id = ?",
+      2, upd_params);
+  if (upd <= 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return upd < 0 ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND;
+  }
+
+  if (revoke_sessions)
+  {
+    const char *rev_params[] = {user_id};
+    if (Deita_Query_Execute_Update_Prepared(
+            p_store->p_connection,
+            "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
+            "  WHERE user_id = ? AND revoked_at IS NULL",
+            1, rev_params) < 0)
+    {
+      auth__rollback(p_store, AUTH_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return AUTH_STORE_ERROR;
+    }
+  }
+
+  char detail[64];
+  snprintf(detail, sizeof(detail), "status->%s", new_status);
+  if (!auth__insert_audit_log_locked(
+          p_store, actor_user_id, "user_status_updated", user_id, detail))
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Update_User_Status(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_status,
+    const char *actor_user_id)
+{
+  return auth__update_user_status(
+      p_store, user_id, new_status, actor_user_id, FALSE);
+}
+
+Auth_Store_Result Auth_Store_Enable_User(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *actor_user_id)
+{
+  return auth__update_user_status(
+      p_store, user_id, "active", actor_user_id, FALSE);
+}
+
+Auth_Store_Result Auth_Store_Disable_User_And_Revoke_Sessions(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *actor_user_id)
+{
+  return auth__update_user_status(
+      p_store, user_id, "disabled", actor_user_id, TRUE);
+}
+
+static Auth_Store_Result auth__update_user_role(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_role,
+    const char *actor_user_id,
+    boolean     revoke_sessions)
+{
+  if (!p_store || !user_id || !new_role)
+    return AUTH_STORE_INVALID_ARG;
+  if (strcmp(new_role, "admin") != 0 && strcmp(new_role, "member") != 0)
+    return AUTH_STORE_INVALID_ARG;
+
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  Dowa_Arena       *p_arena = Dowa_Arena_Create(2048);
+  if (!p_arena)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+  const char       *sel_params[] = {user_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT role, status FROM users WHERE id = ?",
+      1, sel_params, p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_arena);
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_NOT_FOUND;
+  }
+  char cur_role[8], cur_status[9];
+  auth__copy_text_fixed(cur_role,   sizeof(cur_role),
+                        Deita_Result_Set_Get_Text(p_result, 0));
+  auth__copy_text_fixed(cur_status, sizeof(cur_status),
+                        Deita_Result_Set_Get_Text(p_result, 1));
+  Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  /* Last-admin protection: prevent demoting the final active admin. */
+  if (strcmp(new_role,    "member") == 0 &&
+      strcmp(cur_role,    "admin")  == 0 &&
+      strcmp(cur_status,  "active") == 0)
+  {
+    Dowa_Arena *p_count_arena = Dowa_Arena_Create(1024);
+    if (!p_count_arena)
+    {
+      auth__rollback(p_store, AUTH_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return AUTH_STORE_ERROR;
+    }
+    p_result = Deita_Query_Execute(
+        p_store->p_connection,
+        "SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'",
+        p_count_arena);
+    int64 count = 0;
+    if (p_result && Deita_Result_Set_Next(p_result))
+      count = Deita_Result_Set_Get_Integer(p_result, 0);
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_count_arena);
+
+    if (count <= 1)
+    {
+      auth__rollback(p_store, AUTH_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return AUTH_STORE_LAST_ADMIN;
+    }
+  }
+
+  const char *upd_params[] = {new_role, user_id};
+  int32 upd = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE users SET role = ?, updated_at = strftime('%s','now')"
+      "  WHERE id = ?",
+      2, upd_params);
+  if (upd <= 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return upd < 0 ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND;
+  }
+
+  if (revoke_sessions)
+  {
+    const char *rev_params[] = {user_id};
+    if (Deita_Query_Execute_Update_Prepared(
+            p_store->p_connection,
+            "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
+            "  WHERE user_id = ? AND revoked_at IS NULL",
+            1, rev_params) < 0)
+    {
+      auth__rollback(p_store, AUTH_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return AUTH_STORE_ERROR;
+    }
+  }
+
+  char detail[64];
+  snprintf(detail, sizeof(detail), "role->%s", new_role);
+  if (!auth__insert_audit_log_locked(
+          p_store, actor_user_id, "user_role_updated", user_id, detail))
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Update_User_Role(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_role,
+    const char *actor_user_id)
+{
+  return auth__update_user_role(
+      p_store, user_id, new_role, actor_user_id, FALSE);
+}
+
+Auth_Store_Result Auth_Store_Update_Role_And_Revoke_Sessions(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_role,
+    const char *actor_user_id)
+{
+  return auth__update_user_role(
+      p_store, user_id, new_role, actor_user_id, TRUE);
+}
+
+Auth_Store_Result Auth_Store_Set_Must_Change_Password(
+    Auth_Store *p_store,
+    const char *user_id,
+    boolean     value,
+    const char *actor_user_id)
+{
+  if (!p_store || !user_id)
+    return AUTH_STORE_INVALID_ARG;
+
+  const char *val_str    = value ? "1" : "0";
+  const char *params[]   = {val_str, user_id};
+
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  int32 upd = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE users"
+      "  SET must_change_password = ?, updated_at = strftime('%s','now')"
+      "  WHERE id = ?",
+      2, params);
+  if (upd <= 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return upd < 0 ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND;
+  }
+
+  if (!auth__insert_audit_log_locked(
+          p_store, actor_user_id,
+          value ? "must_change_password_set" : "must_change_password_cleared",
+          user_id, NULL))
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Update_Password(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_encoded_hash,
+    boolean     revoke_other_sessions,
+    const char *keep_token_digest)
+{
+  if (!p_store || !user_id || !new_encoded_hash)
+    return AUTH_STORE_INVALID_ARG;
+  if (new_encoded_hash[0] == '\0')
+    return AUTH_STORE_INVALID_ARG;
+
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  const char *upd_params[] = {new_encoded_hash, user_id};
+  int32 upd = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE users"
+      "  SET password_hash = ?,"
+      "      password_changed_at = strftime('%s','now'),"
+      "      must_change_password = 0,"
+      "      updated_at = strftime('%s','now')"
+      "  WHERE id = ?",
+      2, upd_params);
+  if (upd <= 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return upd < 0 ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND;
+  }
+
+  if (revoke_other_sessions)
+  {
+    int32 rev;
+    if (keep_token_digest && keep_token_digest[0] != '\0')
+    {
+      const char *rev_params[] = {user_id, keep_token_digest};
+      rev = Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "UPDATE auth_sessions"
+          "  SET revoked_at = strftime('%s','now')"
+          "  WHERE user_id = ? AND token_digest != ? AND revoked_at IS NULL",
+          2, rev_params);
+    }
+    else
+    {
+      const char *rev_params[] = {user_id};
+      rev = Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "UPDATE auth_sessions"
+          "  SET revoked_at = strftime('%s','now')"
+          "  WHERE user_id = ? AND revoked_at IS NULL",
+          1, rev_params);
+    }
+    if (rev < 0)
+    {
+      auth__rollback(p_store, AUTH_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return AUTH_STORE_ERROR;
+    }
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+  return AUTH_STORE_OK;
+}
+
+/* ------------------------------------------------------------------ */
+/* Session management                                                   */
+/* ------------------------------------------------------------------ */
+
+Auth_Store_Result Auth_Store_Create_Session(
+    Auth_Store          *p_store,
+    const char          *user_id,
+    const char          *token_digest,
+    const char          *csrf_digest,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    Auth_Session_Record *p_record)
+{
+  if (!p_store || !user_id || !token_digest || !csrf_digest || !p_record)
+    return AUTH_STORE_INVALID_ARG;
+  if (!auth__session_arguments_are_valid(
+          token_digest, csrf_digest, idle_ttl_secs,
+          absolute_ttl_secs, current_unix))
+    return AUTH_STORE_INVALID_ARG;
+
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  /* Verify user is active and read password_changed_at snapshot. */
+  Dowa_Arena       *p_arena = Dowa_Arena_Create(2048);
+  if (!p_arena)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+  const char       *sel_params[] = {user_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT status, password_changed_at FROM users WHERE id = ?",
+      1, sel_params, p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_arena);
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_NOT_FOUND;
+  }
+  char  cur_status[9];
+  int64 pca;
+  auth__copy_text_fixed(cur_status, sizeof(cur_status),
+                        Deita_Result_Set_Get_Text(p_result, 0));
+  pca = Deita_Result_Set_Get_Integer(p_result, 1);
+  Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  if (strcmp(cur_status, "disabled") == 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_USER_DISABLED;
+  }
+
+  Auth_Store_Result insert_result = auth__insert_session_locked(
+      p_store, user_id, token_digest, csrf_digest,
+      idle_ttl_secs, absolute_ttl_secs, current_unix, pca);
+  if (insert_result != AUTH_STORE_OK)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return insert_result;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+
+  auth__fill_session_record(
+      p_record, user_id, idle_ttl_secs, absolute_ttl_secs, current_unix, pca);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Create_Session_CAS(
+    Auth_Store          *p_store,
+    const char          *user_id,
+    const char          *expected_password_hash,
+    const char          *token_digest,
+    const char          *csrf_digest,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    Auth_Session_Record *p_record)
+{
+  if (!p_store || !user_id || !expected_password_hash ||
+      expected_password_hash[0] == '\0' || !p_record)
+    return AUTH_STORE_INVALID_ARG;
+  if (!auth__session_arguments_are_valid(
+          token_digest, csrf_digest, idle_ttl_secs,
+          absolute_ttl_secs, current_unix))
+    return AUTH_STORE_INVALID_ARG;
+
+  char              current_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE] = {0};
+  char              current_status[9] = {0};
+  int64             password_changed_at = 0;
+  Auth_Store_Result result = AUTH_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    OPENSSL_cleanse(current_hash, sizeof(current_hash));
+    return AUTH_STORE_ERROR;
+  }
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
+  if (!p_arena)
+  {
+    result = auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto create_session_cas_done;
+  }
+
+  const char *select_params[] = {user_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT status, password_changed_at, password_hash"
+      "  FROM users WHERE id = ?",
+      1, select_params, p_arena);
+  if (!p_result)
+  {
+    Dowa_Arena_Free(p_arena);
+    result = auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto create_session_cas_done;
+  }
+  if (!Deita_Result_Set_Next(p_result))
+  {
+    boolean query_error = Deita_Result_Set_Has_Error(p_result);
+    Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_arena);
+    result = auth__rollback(
+        p_store, query_error ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND);
+    goto create_session_cas_done;
+  }
+
+  auth__copy_text_fixed(
+      current_status, sizeof(current_status),
+      Deita_Result_Set_Get_Text(p_result, 0));
+  password_changed_at = Deita_Result_Set_Get_Integer(p_result, 1);
+  auth__copy_text_fixed(
+      current_hash, sizeof(current_hash),
+      Deita_Result_Set_Get_Text(p_result, 2));
+  Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  if (strcmp(current_status, "disabled") == 0)
+  {
+    result = auth__rollback(p_store, AUTH_STORE_USER_DISABLED);
+    goto create_session_cas_done;
+  }
+  if (strcmp(current_hash, expected_password_hash) != 0)
+  {
+    result = auth__rollback(p_store, AUTH_STORE_STALE_PASSWORD);
+    goto create_session_cas_done;
+  }
+
+  result = auth__insert_session_locked(
+      p_store, user_id, token_digest, csrf_digest,
+      idle_ttl_secs, absolute_ttl_secs, current_unix, password_changed_at);
+  if (result != AUTH_STORE_OK)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto create_session_cas_done;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    result = auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto create_session_cas_done;
+  }
+
+  auth__fill_session_record(
+      p_record, user_id, idle_ttl_secs, absolute_ttl_secs,
+      current_unix, password_changed_at);
+  result = AUTH_STORE_OK;
+
+create_session_cas_done:
+  OPENSSL_cleanse(current_hash, sizeof(current_hash));
+  pthread_mutex_unlock(&p_store->mutex);
+  return result;
+}
+
+Auth_Store_Result Auth_Store_Find_Session(
+    Auth_Store          *p_store,
+    const char          *token_digest,
+    int64                current_unix,
+    Auth_Session_Record *p_session,
+    Auth_User_Record    *p_user)
+{
+  if (!p_store || !token_digest || !p_session || !p_user)
+    return AUTH_STORE_INVALID_ARG;
+
+  memset(p_session, 0, sizeof(*p_session));
+  memset(p_user,    0, sizeof(*p_user));
+
+  Dowa_Arena       *p_arena = Dowa_Arena_Create(4096);
+  if (!p_arena)
+    return AUTH_STORE_ERROR;
+
+  const char       *params[] = {token_digest};
+  pthread_mutex_lock(&p_store->mutex);
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT"
+      "  s.user_id, s.created_at, s.last_seen_at,"
+      "  s.idle_expires_at, s.absolute_expires_at,"
+      "  s.password_changed_at_snapshot, s.revoked_at,"
+      "  u.id, u.username, u.normalized_username, u.role, u.status,"
+      "  u.must_change_password, u.password_changed_at,"
+      "  u.created_at, u.updated_at"
+      "  FROM auth_sessions s"
+      "  JOIN users u ON s.user_id = u.id"
+      "  WHERE s.token_digest = ?",
+      1, params, p_arena);
+
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_NOT_FOUND;
+  }
+
+  /* Read session fields. */
+  auth__copy_text_fixed(p_session->user_id, sizeof(p_session->user_id),
+                        Deita_Result_Set_Get_Text(p_result, 0));
+  p_session->created_at    = Deita_Result_Set_Get_Integer(p_result, 1);
+  p_session->last_seen_at  = Deita_Result_Set_Get_Integer(p_result, 2);
+  p_session->idle_expires_at       = Deita_Result_Set_Get_Integer(p_result, 3);
+  p_session->absolute_expires_at   = Deita_Result_Set_Get_Integer(p_result, 4);
+  p_session->password_changed_at_snapshot =
+      Deita_Result_Set_Get_Integer(p_result, 5);
+  boolean is_revoked =
+      (Deita_Result_Set_Get_Column_Type(p_result, 6) != DEITA_COLUMN_TYPE_NULL);
+
+  /* Read user fields (columns 7-15). */
+  /* Reuse auth__read_user_record after shifting: pass a pointer with offset */
+  Auth_User_Record tmp_user;
+  memset(&tmp_user, 0, sizeof(tmp_user));
+  auth__copy_text_fixed(tmp_user.id,                 sizeof(tmp_user.id),
+                        Deita_Result_Set_Get_Text(p_result, 7));
+  auth__copy_text_fixed(tmp_user.username,           sizeof(tmp_user.username),
+                        Deita_Result_Set_Get_Text(p_result, 8));
+  auth__copy_text_fixed(tmp_user.normalized_username,
+                        sizeof(tmp_user.normalized_username),
+                        Deita_Result_Set_Get_Text(p_result, 9));
+  auth__copy_text_fixed(tmp_user.role,   sizeof(tmp_user.role),
+                        Deita_Result_Set_Get_Text(p_result, 10));
+  auth__copy_text_fixed(tmp_user.status, sizeof(tmp_user.status),
+                        Deita_Result_Set_Get_Text(p_result, 11));
+  tmp_user.must_change_password  = Deita_Result_Set_Get_Integer(p_result, 12) ?
+                                    TRUE : FALSE;
+  tmp_user.password_changed_at   = Deita_Result_Set_Get_Integer(p_result, 13);
+  tmp_user.created_at            = Deita_Result_Set_Get_Integer(p_result, 14);
+  tmp_user.updated_at            = Deita_Result_Set_Get_Integer(p_result, 15);
+
+  Deita_Result_Set_Free(p_result);
+  pthread_mutex_unlock(&p_store->mutex);
+  Dowa_Arena_Free(p_arena);
+
+  /* Apply validity checks in order of specificity. */
+  if (is_revoked)
+    return AUTH_STORE_REVOKED;
+
+  if (current_unix >= p_session->idle_expires_at ||
+      current_unix >= p_session->absolute_expires_at)
+    return AUTH_STORE_EXPIRED;
+
+  if (strcmp(tmp_user.status, "disabled") == 0)
+    return AUTH_STORE_USER_DISABLED;
+
+  if (tmp_user.password_changed_at != p_session->password_changed_at_snapshot)
+    return AUTH_STORE_STALE_PASSWORD;
+
+  *p_user = tmp_user;
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Touch_Session(
+    Auth_Store *p_store,
+    const char *token_digest,
+    int64       current_unix,
+    int64       idle_ttl_secs)
+{
+  if (!p_store || !token_digest)
+    return AUTH_STORE_INVALID_ARG;
+
+  char last_seen_str[32], idle_exp_str[32];
+  snprintf(last_seen_str, sizeof(last_seen_str), "%lld", (long long)current_unix);
+  snprintf(idle_exp_str,  sizeof(idle_exp_str),  "%lld",
+           (long long)(current_unix + idle_ttl_secs));
+
+  const char *params[] = {last_seen_str, idle_exp_str, token_digest};
+
+  pthread_mutex_lock(&p_store->mutex);
+  int32 result = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE auth_sessions"
+      "  SET last_seen_at = ?, idle_expires_at = ?"
+      "  WHERE token_digest = ? AND revoked_at IS NULL",
+      3, params);
+  pthread_mutex_unlock(&p_store->mutex);
+
+  if (result < 0)
+    return AUTH_STORE_ERROR;
+  return result == 0 ? AUTH_STORE_NOT_FOUND : AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Revoke_Session(
+    Auth_Store *p_store,
+    const char *token_digest)
+{
+  if (!p_store || !token_digest)
+    return AUTH_STORE_INVALID_ARG;
+
+  const char *params[] = {token_digest};
+
+  pthread_mutex_lock(&p_store->mutex);
+  int32 result = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
+      "  WHERE token_digest = ?",
+      1, params);
+  pthread_mutex_unlock(&p_store->mutex);
+
+  if (result < 0)
+    return AUTH_STORE_ERROR;
+  return result == 0 ? AUTH_STORE_NOT_FOUND : AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Revoke_All_Sessions(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *except_token_digest)
+{
+  if (!p_store || !user_id)
+    return AUTH_STORE_INVALID_ARG;
+
+  pthread_mutex_lock(&p_store->mutex);
+  int32 result;
+  if (except_token_digest && except_token_digest[0] != '\0')
+  {
+    const char *params[] = {user_id, except_token_digest};
+    result = Deita_Query_Execute_Update_Prepared(
+        p_store->p_connection,
+        "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
+        "  WHERE user_id = ? AND token_digest != ? AND revoked_at IS NULL",
+        2, params);
+  }
+  else
+  {
+    const char *params[] = {user_id};
+    result = Deita_Query_Execute_Update_Prepared(
+        p_store->p_connection,
+        "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
+        "  WHERE user_id = ? AND revoked_at IS NULL",
+        1, params);
+  }
+  pthread_mutex_unlock(&p_store->mutex);
+
+  return result < 0 ? AUTH_STORE_ERROR : AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Revoke_All_Sessions_Audited(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *except_token_digest,
+    const char *actor_user_id)
+{
+  if (!p_store || !user_id)
+    return AUTH_STORE_INVALID_ARG;
+  if (except_token_digest && except_token_digest[0] != '\0' &&
+      !auth__digest_is_valid(except_token_digest))
+    return AUTH_STORE_INVALID_ARG;
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(1024);
+  if (!p_arena)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  const char *find_params[] = {user_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT 1 FROM users WHERE id = ?",
+      1, find_params, p_arena);
+  if (!p_result)
+  {
+    Dowa_Arena_Free(p_arena);
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+  if (!Deita_Result_Set_Next(p_result))
+  {
+    boolean query_error = Deita_Result_Set_Has_Error(p_result);
+    Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_arena);
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return query_error ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND;
+  }
+  Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  int32 revoke_result;
+  if (except_token_digest && except_token_digest[0] != '\0')
+  {
+    const char *params[] = {user_id, except_token_digest};
+    revoke_result = Deita_Query_Execute_Update_Prepared(
+        p_store->p_connection,
+        "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
+        "  WHERE user_id = ? AND token_digest != ? AND revoked_at IS NULL",
+        2, params);
+  }
+  else
+  {
+    const char *params[] = {user_id};
+    revoke_result = Deita_Query_Execute_Update_Prepared(
+        p_store->p_connection,
+        "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
+        "  WHERE user_id = ? AND revoked_at IS NULL",
+        1, params);
+  }
+  if (revoke_result < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (!auth__insert_audit_log_locked(
+          p_store, actor_user_id, "admin_sessions_revoked", user_id, NULL))
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Rotate_Session(
+    Auth_Store          *p_store,
+    const char          *user_id,
+    const char          *old_token_digest,
+    const char          *new_token_digest,
+    const char          *new_csrf_digest,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    Auth_Session_Record *p_record)
+{
+  if (!p_store || !user_id || !old_token_digest || !new_token_digest ||
+      !new_csrf_digest || !p_record || !auth__digest_is_valid(old_token_digest))
+    return AUTH_STORE_INVALID_ARG;
+  if (!auth__session_arguments_are_valid(
+          new_token_digest, new_csrf_digest, idle_ttl_secs,
+          absolute_ttl_secs, current_unix))
+    return AUTH_STORE_INVALID_ARG;
+
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  /* Verify user is active and read password_changed_at snapshot. */
+  Dowa_Arena       *p_arena = Dowa_Arena_Create(2048);
+  if (!p_arena)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+  const char       *sel_params[] = {user_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT status, password_changed_at FROM users WHERE id = ?",
+      1, sel_params, p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_arena);
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_NOT_FOUND;
+  }
+  char  cur_status[9];
+  int64 pca;
+  auth__copy_text_fixed(cur_status, sizeof(cur_status),
+                        Deita_Result_Set_Get_Text(p_result, 0));
+  pca = Deita_Result_Set_Get_Integer(p_result, 1);
+  Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  if (strcmp(cur_status, "disabled") == 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_USER_DISABLED;
+  }
+
+  /* Insert new session. */
+  Auth_Store_Result insert_result = auth__insert_session_locked(
+      p_store, user_id, new_token_digest, new_csrf_digest,
+      idle_ttl_secs, absolute_ttl_secs, current_unix, pca);
+  if (insert_result != AUTH_STORE_OK)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return insert_result;
+  }
+
+  /* Revoke old session (idempotent: ignore if already revoked). */
+  const char *rev_params[] = {old_token_digest};
+  int32 revoke_result = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE auth_sessions SET revoked_at = strftime('%s','now')"
+      "  WHERE token_digest = ? AND revoked_at IS NULL",
+      1, rev_params);
+  if (revoke_result < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+
+  auth__fill_session_record(
+      p_record, user_id, idle_ttl_secs, absolute_ttl_secs, current_unix, pca);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Self_Change_Password(
+    Auth_Store          *p_store,
+    const char          *user_id,
+    const char          *old_encoded_hash,
+    const char          *new_encoded_hash,
+    const char          *new_token_digest,
+    const char          *new_csrf_digest,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    Auth_Session_Record *p_record)
+{
+  if (!p_store || !user_id || !old_encoded_hash || !new_encoded_hash ||
+      old_encoded_hash[0] == '\0' || new_encoded_hash[0] == '\0' || !p_record)
+    return AUTH_STORE_INVALID_ARG;
+  if (!auth__session_arguments_are_valid(
+          new_token_digest, new_csrf_digest, idle_ttl_secs,
+          absolute_ttl_secs, current_unix))
+    return AUTH_STORE_INVALID_ARG;
+
+  char current_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE] = {0};
+  char reread_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE] = {0};
+  char status[9] = {0};
+  char timestamp[32];
+  snprintf(timestamp, sizeof(timestamp), "%lld", (long long)current_unix);
+  Auth_Store_Result result = AUTH_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    OPENSSL_cleanse(current_hash, sizeof(current_hash));
+    OPENSSL_cleanse(reread_hash, sizeof(reread_hash));
+    return AUTH_STORE_ERROR;
+  }
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
+  if (!p_arena)
+  {
+    result = auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto self_change_done;
+  }
+
+  const char *select_params[] = {user_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT status, password_hash FROM users WHERE id = ?",
+      1, select_params, p_arena);
+  if (!p_result)
+  {
+    Dowa_Arena_Free(p_arena);
+    result = auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto self_change_done;
+  }
+  if (!Deita_Result_Set_Next(p_result))
+  {
+    boolean query_error = Deita_Result_Set_Has_Error(p_result);
+    Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_arena);
+    result = auth__rollback(
+        p_store, query_error ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND);
+    goto self_change_done;
+  }
+  auth__copy_text_fixed(
+      status, sizeof(status), Deita_Result_Set_Get_Text(p_result, 0));
+  auth__copy_text_fixed(
+      current_hash, sizeof(current_hash),
+      Deita_Result_Set_Get_Text(p_result, 1));
+  Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  if (strcmp(status, "disabled") == 0)
+  {
+    result = auth__rollback(p_store, AUTH_STORE_USER_DISABLED);
+    goto self_change_done;
+  }
+  if (strcmp(current_hash, old_encoded_hash) != 0)
+  {
+    result = auth__rollback(p_store, AUTH_STORE_STALE_PASSWORD);
+    goto self_change_done;
+  }
+
+  const char *update_params[] = {
+    new_encoded_hash, timestamp, timestamp, user_id, old_encoded_hash
+  };
+  int32 update_result = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE users"
+      "  SET password_hash = ?, password_changed_at = ?,"
+      "      must_change_password = 0, updated_at = ?"
+      "  WHERE id = ? AND password_hash = ?",
+      5, update_params);
+  if (update_result < 0)
+  {
+    result = auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto self_change_done;
+  }
+  if (update_result == 0)
+  {
+    result = auth__rollback(p_store, AUTH_STORE_STALE_PASSWORD);
+    goto self_change_done;
+  }
+
+  p_arena = Dowa_Arena_Create(2048);
+  if (!p_arena)
+  {
+    result = auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto self_change_done;
+  }
+  p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT status, password_changed_at, password_hash"
+      "  FROM users WHERE id = ?",
+      1, select_params, p_arena);
+  if (!p_result)
+  {
+    Dowa_Arena_Free(p_arena);
+    result = auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto self_change_done;
+  }
+  if (!Deita_Result_Set_Next(p_result))
+  {
+    Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_arena);
+    result = auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto self_change_done;
+  }
+  auth__copy_text_fixed(
+      status, sizeof(status), Deita_Result_Set_Get_Text(p_result, 0));
+  int64 password_changed_at = Deita_Result_Set_Get_Integer(p_result, 1);
+  auth__copy_text_fixed(
+      reread_hash, sizeof(reread_hash),
+      Deita_Result_Set_Get_Text(p_result, 2));
+  Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+  if (strcmp(status, "active") != 0 ||
+      password_changed_at != current_unix ||
+      strcmp(reread_hash, new_encoded_hash) != 0)
+  {
+    result = auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto self_change_done;
+  }
+
+  const char *revoke_params[] = {timestamp, user_id};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "UPDATE auth_sessions SET revoked_at = ?"
+          "  WHERE user_id = ? AND revoked_at IS NULL",
+          2, revoke_params) < 0)
+  {
+    result = auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto self_change_done;
+  }
+
+  result = auth__insert_session_locked(
+      p_store, user_id, new_token_digest, new_csrf_digest,
+      idle_ttl_secs, absolute_ttl_secs, current_unix, password_changed_at);
+  if (result != AUTH_STORE_OK)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto self_change_done;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    result = auth__rollback(p_store, AUTH_STORE_ERROR);
+    goto self_change_done;
+  }
+
+  auth__fill_session_record(
+      p_record, user_id, idle_ttl_secs, absolute_ttl_secs,
+      current_unix, password_changed_at);
+  result = AUTH_STORE_OK;
+
+self_change_done:
+  OPENSSL_cleanse(current_hash, sizeof(current_hash));
+  OPENSSL_cleanse(reread_hash, sizeof(reread_hash));
+  pthread_mutex_unlock(&p_store->mutex);
+  return result;
+}
+
+/* ------------------------------------------------------------------ */
+/* Guest identity                                                       */
+/* ------------------------------------------------------------------ */
+
+Auth_Store_Result Auth_Store_Upsert_Guest_Identity(
+    Auth_Store                *p_store,
+    const char                *guest_id,
+    const char                *ip_binding_digest,
+    int64                      expires_at,
+    Auth_Guest_Identity_Record *p_record)
+{
+  if (!p_store || !guest_id || !ip_binding_digest || !p_record)
+    return AUTH_STORE_INVALID_ARG;
+
+  char exp_str[32];
+  snprintf(exp_str, sizeof(exp_str), "%lld", (long long)expires_at);
+
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  /* INSERT OR IGNORE so usage data is not cascade-deleted on upsert. */
+  const char *ins_params[] = {guest_id, ip_binding_digest, exp_str};
+  Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "INSERT OR IGNORE INTO guest_identities (id, ip_binding_digest, expires_at)"
+      "  VALUES (?, ?, ?)",
+      3, ins_params);
+
+  /* Always refresh last_seen_at and expires_at. */
+  const char *upd_params[] = {exp_str, guest_id};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "UPDATE guest_identities"
+          "  SET last_seen_at = strftime('%s','now'), expires_at = ?"
+          "  WHERE id = ?",
+          2, upd_params) < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  /* Read back the current row. */
+  Dowa_Arena       *p_arena = Dowa_Arena_Create(2048);
+  if (!p_arena)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+  const char       *sel_params[] = {guest_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT id, created_at, last_seen_at, expires_at"
+      "  FROM guest_identities WHERE id = ?",
+      1, sel_params, p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_arena);
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+  auth__copy_text_fixed(p_record->id, sizeof(p_record->id),
+                        Deita_Result_Set_Get_Text(p_result, 0));
+  p_record->created_at   = Deita_Result_Set_Get_Integer(p_result, 1);
+  p_record->last_seen_at = Deita_Result_Set_Get_Integer(p_result, 2);
+  p_record->expires_at   = Deita_Result_Set_Get_Integer(p_result, 3);
+  Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Find_Guest_Identity(
+    Auth_Store                *p_store,
+    const char                *guest_id,
+    int64                      current_unix,
+    Auth_Guest_Identity_Record *p_record)
+{
+  if (!p_store || !guest_id || !p_record)
+    return AUTH_STORE_INVALID_ARG;
+
+  memset(p_record, 0, sizeof(*p_record));
+
+  Dowa_Arena       *p_arena = Dowa_Arena_Create(2048);
+  if (!p_arena)
+    return AUTH_STORE_ERROR;
+
+  const char       *params[] = {guest_id};
+  pthread_mutex_lock(&p_store->mutex);
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT id, created_at, last_seen_at, expires_at"
+      "  FROM guest_identities WHERE id = ?",
+      1, params, p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_NOT_FOUND;
+  }
+  auth__copy_text_fixed(p_record->id, sizeof(p_record->id),
+                        Deita_Result_Set_Get_Text(p_result, 0));
+  p_record->created_at   = Deita_Result_Set_Get_Integer(p_result, 1);
+  p_record->last_seen_at = Deita_Result_Set_Get_Integer(p_result, 2);
+  p_record->expires_at   = Deita_Result_Set_Get_Integer(p_result, 3);
+  Deita_Result_Set_Free(p_result);
+  pthread_mutex_unlock(&p_store->mutex);
+  Dowa_Arena_Free(p_arena);
+
+  if (current_unix >= p_record->expires_at)
+    return AUTH_STORE_EXPIRED;
+
+  return AUTH_STORE_OK;
+}
+
+/* ------------------------------------------------------------------ */
+/* Guest quota                                                          */
+/* ------------------------------------------------------------------ */
+
+static void auth__i64_str(char *buf, size_t size, int64 value)
+{
+  snprintf(buf, size, "%lld", (long long)value);
+}
+
+Auth_Store_Result Auth_Store_Guest_Get_Usage(
+    Auth_Store             *p_store,
+    const char             *guest_id,
+    int64                   window_start,
+    Auth_Store_Guest_Usage *p_usage)
+{
+  if (!p_store || !guest_id || !p_usage)
+    return AUTH_STORE_INVALID_ARG;
+
+  memset(p_usage, 0, sizeof(*p_usage));
+
+  char ws_str[32];
+  auth__i64_str(ws_str, sizeof(ws_str), window_start);
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
+  if (!p_arena)
+    return AUTH_STORE_ERROR;
+
+  const char       *params[] = {guest_id, ws_str};
+  pthread_mutex_lock(&p_store->mutex);
+  auth__reap_expired_reservations_locked(p_store, (int64)time(NULL));
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT turns_used, output_tokens_used, output_tokens_reserved"
+      "  FROM guest_usage WHERE guest_id = ? AND window_start = ?",
+      2, params, p_arena);
+  if (p_result && Deita_Result_Set_Next(p_result))
+  {
+    p_usage->turns_used             = Deita_Result_Set_Get_Integer(p_result, 0);
+    p_usage->output_tokens_used     = Deita_Result_Set_Get_Integer(p_result, 1);
+    p_usage->output_tokens_reserved = Deita_Result_Set_Get_Integer(p_result, 2);
+  }
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  pthread_mutex_unlock(&p_store->mutex);
+  Dowa_Arena_Free(p_arena);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Guest_Quota_Result Auth_Store_Guest_Reserve(
+    Auth_Store *p_store,
+    const char *guest_id,
+    const char *request_id,
+    int64       window_start,
+    int64       max_output_tokens,
+    int64       turns_limit,
+    int64       tokens_limit,
+    int64       reservation_expires)
+{
+  if (!p_store || !guest_id || !request_id ||
+      max_output_tokens <= 0 || turns_limit <= 0 || tokens_limit <= 0)
+    return AUTH_STORE_GUEST_QUOTA_ERROR;
+
+  char ws_str[32], exp_str[32], tok_str[32];
+  auth__i64_str(ws_str,  sizeof(ws_str),  window_start);
+  auth__i64_str(exp_str, sizeof(exp_str), reservation_expires);
+  auth__i64_str(tok_str, sizeof(tok_str), max_output_tokens);
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(4096);
+  if (!p_arena)
+    return AUTH_STORE_GUEST_QUOTA_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_GUEST_QUOTA_ERROR;
+  }
+  auth__reap_expired_reservations_locked(p_store, (int64)time(NULL));
+
+  /* Ensure usage row exists for this window. */
+  const char *ins_params[] = {guest_id, ws_str};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "INSERT OR IGNORE INTO guest_usage"
+          "  (guest_id, window_start, count)"
+          "  VALUES (?, ?, 0)",
+          2, ins_params) < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_GUEST_QUOTA_ERROR;
+  }
+
+  /* Read current usage. */
+  const char       *sel_params[] = {guest_id, ws_str};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT turns_used, output_tokens_used, output_tokens_reserved"
+      "  FROM guest_usage WHERE guest_id = ? AND window_start = ?",
+      2, sel_params, p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result) Deita_Result_Set_Free(p_result);
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_GUEST_QUOTA_ERROR;
+  }
+  int64 turns_used    = Deita_Result_Set_Get_Integer(p_result, 0);
+  int64 tokens_used   = Deita_Result_Set_Get_Integer(p_result, 1);
+  int64 tokens_resvd  = Deita_Result_Set_Get_Integer(p_result, 2);
+  Deita_Result_Set_Free(p_result);
+
+  /* Check turn limit. */
+  if (turns_used >= turns_limit)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED;
+  }
+
+  /* Check token limit: used + reserved + new_reservation <= limit. */
+  if (tokens_used + tokens_resvd + max_output_tokens > tokens_limit)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED;
+  }
+
+  /* Update usage: charge turn, add token reservation. */
+  const char *upd_params[] = {tok_str, guest_id, ws_str};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "UPDATE guest_usage"
+          "  SET turns_used = turns_used + 1,"
+          "      output_tokens_reserved = output_tokens_reserved + ?,"
+          "      count = count + 1"
+          "  WHERE guest_id = ? AND window_start = ?",
+          3, upd_params) <= 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_GUEST_QUOTA_ERROR;
+  }
+
+  /* Insert reservation row. */
+  const char *res_params[] = {request_id, guest_id, ws_str, tok_str, exp_str};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "INSERT INTO guest_usage_reservations"
+          "  (request_id, guest_id, window_start, output_tokens_reserved, expires_at)"
+          "  VALUES (?, ?, ?, ?, ?)",
+          5, res_params) < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_GUEST_QUOTA_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_GUEST_QUOTA_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+  Dowa_Arena_Free(p_arena);
+  return AUTH_STORE_GUEST_QUOTA_OK;
+}
+
+Auth_Store_Result Auth_Store_Guest_Reconcile(
+    Auth_Store *p_store,
+    const char *request_id,
+    int64       actual_output_tokens)
+{
+  if (!p_store || !request_id)
+    return AUTH_STORE_INVALID_ARG;
+  if (actual_output_tokens < 0)
+    actual_output_tokens = 0;
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(4096);
+  if (!p_arena)
+    return AUTH_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_ERROR;
+  }
+
+  /* Fetch reservation. */
+  const char       *sel_params[] = {request_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT guest_id, window_start, output_tokens_reserved"
+      "  FROM guest_usage_reservations WHERE request_id = ?",
+      1, sel_params, p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    /* Idempotent: reservation already gone. */
+    if (p_result) Deita_Result_Set_Free(p_result);
+    Deita_Query_Execute_Update(p_store->p_connection, "COMMIT");
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_OK;
+  }
+
+  char guest_id[37];
+  auth__copy_text_fixed(guest_id, sizeof(guest_id),
+                        Deita_Result_Set_Get_Text(p_result, 0));
+  int64 window_start       = Deita_Result_Set_Get_Integer(p_result, 1);
+  int64 tokens_reserved    = Deita_Result_Set_Get_Integer(p_result, 2);
+  Deita_Result_Set_Free(p_result);
+
+  /* Charge provider-reported usage even when it exceeds the reservation. */
+  int64 to_charge = actual_output_tokens;
+
+  char ws_str[32], charge_str[32], res_str[32];
+  auth__i64_str(ws_str,     sizeof(ws_str),     window_start);
+  auth__i64_str(charge_str, sizeof(charge_str), to_charge);
+  auth__i64_str(res_str,    sizeof(res_str),    tokens_reserved);
+
+  /* Update usage: add actual tokens, subtract reservation. */
+  const char *upd_params[] = {charge_str, res_str, guest_id, ws_str};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "UPDATE guest_usage"
+          "  SET output_tokens_used = output_tokens_used + ?,"
+          "      output_tokens_reserved = MAX(0, output_tokens_reserved - ?)"
+          "  WHERE guest_id = ? AND window_start = ?",
+          4, upd_params) < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_ERROR;
+  }
+
+  /* Delete reservation. */
+  const char *del_params[] = {request_id};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "DELETE FROM guest_usage_reservations WHERE request_id = ?",
+          1, del_params) < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+  Dowa_Arena_Free(p_arena);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Guest_Release(
+    Auth_Store *p_store,
+    const char *request_id)
+{
+  if (!p_store || !request_id)
+    return AUTH_STORE_INVALID_ARG;
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(4096);
+  if (!p_arena)
+    return AUTH_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_ERROR;
+  }
+
+  /* Fetch reservation. */
+  const char       *sel_params[] = {request_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT guest_id, window_start, output_tokens_reserved"
+      "  FROM guest_usage_reservations WHERE request_id = ?",
+      1, sel_params, p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    /* Idempotent: reservation already gone. */
+    if (p_result) Deita_Result_Set_Free(p_result);
+    Deita_Query_Execute_Update(p_store->p_connection, "COMMIT");
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_OK;
+  }
+
+  char guest_id[37];
+  auth__copy_text_fixed(guest_id, sizeof(guest_id),
+                        Deita_Result_Set_Get_Text(p_result, 0));
+  int64 window_start    = Deita_Result_Set_Get_Integer(p_result, 1);
+  int64 tokens_reserved = Deita_Result_Set_Get_Integer(p_result, 2);
+  Deita_Result_Set_Free(p_result);
+
+  char ws_str[32], res_str[32];
+  auth__i64_str(ws_str,  sizeof(ws_str),  window_start);
+  auth__i64_str(res_str, sizeof(res_str), tokens_reserved);
+
+  /* Release token reservation; turns_used is unchanged (turn already charged). */
+  const char *upd_params[] = {res_str, guest_id, ws_str};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "UPDATE guest_usage"
+          "  SET output_tokens_reserved = MAX(0, output_tokens_reserved - ?)"
+          "  WHERE guest_id = ? AND window_start = ?",
+          3, upd_params) < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_ERROR;
+  }
+
+  /* Delete reservation. */
+  const char *del_params[] = {request_id};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "DELETE FROM guest_usage_reservations WHERE request_id = ?",
+          1, del_params) < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    Dowa_Arena_Free(p_arena);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+  Dowa_Arena_Free(p_arena);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Guest_Clear_Reservations(
+    Auth_Store *p_store,
+    const char *guest_id)
+{
+  if (!p_store || !guest_id)
+    return AUTH_STORE_INVALID_ARG;
+
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  /*
+   * Subtract each window's reserved tokens from the usage row.
+   * The correlated subquery aggregates reservations per window.
+   */
+  const char *upd_params[] = {guest_id, guest_id};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "UPDATE guest_usage"
+          "  SET output_tokens_reserved = MAX(0, output_tokens_reserved - ("
+          "    SELECT COALESCE(SUM(r.output_tokens_reserved), 0)"
+          "    FROM guest_usage_reservations r"
+          "    WHERE r.guest_id = ? AND r.window_start = guest_usage.window_start"
+          "  ))"
+          "  WHERE guest_id = ?",
+          2, upd_params) < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  /* Delete all reservations for this guest. */
+  const char *del_params[] = {guest_id};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "DELETE FROM guest_usage_reservations WHERE guest_id = ?",
+          1, del_params) < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+  return AUTH_STORE_OK;
+}
+
+Auth_Store_Result Auth_Store_Guest_Reap_Expired(
+    Auth_Store *p_store,
+    int64       current_unix)
+{
+  if (!p_store)
+    return AUTH_STORE_INVALID_ARG;
+  pthread_mutex_lock(&p_store->mutex);
+  auth__reap_expired_reservations_locked(p_store, current_unix);
+  pthread_mutex_unlock(&p_store->mutex);
+  return AUTH_STORE_OK;
+}
+
+/* ------------------------------------------------------------------ */
+/* Audit log (public)                                                   */
+/* ------------------------------------------------------------------ */
+
+Auth_Store_Result Auth_Store_Insert_Audit_Log(
+    Auth_Store *p_store,
+    const char *actor_user_id,
+    const char *action,
+    const char *target_user_id,
+    const char *detail)
+{
+  if (!p_store || !action || action[0] == '\0')
+    return AUTH_STORE_INVALID_ARG;
+
+  pthread_mutex_lock(&p_store->mutex);
+  boolean inserted = auth__insert_audit_log_locked(
+      p_store, actor_user_id, action, target_user_id, detail);
+  pthread_mutex_unlock(&p_store->mutex);
+  return inserted ? AUTH_STORE_OK : AUTH_STORE_ERROR;
+}
+
+/* ------------------------------------------------------------------ */
+/* Admin password reset                                                 */
+/* ------------------------------------------------------------------ */
+
+Auth_Store_Result Auth_Store_Admin_Reset_Password(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_encoded_hash,
+    const char *actor_user_id)
+{
+  if (!p_store || !user_id || !new_encoded_hash)
+    return AUTH_STORE_INVALID_ARG;
+  if (new_encoded_hash[0] == '\0')
+    return AUTH_STORE_INVALID_ARG;
+
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  const char *upd_params[] = {new_encoded_hash, user_id};
+  int32 upd = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE users"
+      "  SET password_hash = ?,"
+      "      password_changed_at = strftime('%s','now'),"
+      "      must_change_password = 1,"
+      "      updated_at = strftime('%s','now')"
+      "  WHERE id = ?",
+      2, upd_params);
+  if (upd <= 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return upd < 0 ? AUTH_STORE_ERROR : AUTH_STORE_NOT_FOUND;
+  }
+
+  const char *rev_params[] = {user_id};
+  int32 rev = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE auth_sessions"
+      "  SET revoked_at = strftime('%s','now')"
+      "  WHERE user_id = ? AND revoked_at IS NULL",
+      1, rev_params);
+  if (rev < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (!auth__insert_audit_log_locked(
+          p_store, actor_user_id, "admin_temp_password_reset", user_id, NULL))
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    auth__rollback(p_store, AUTH_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return AUTH_STORE_ERROR;
+  }
+
+  pthread_mutex_unlock(&p_store->mutex);
+  return AUTH_STORE_OK;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/auth/auth_store.h	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,462 @@
+#ifndef ZENBU_AUTH_STORE_H
+#define ZENBU_AUTH_STORE_H
+
+#include "dowa/dowa.h"
+#include "auth/auth_crypto.h"
+
+#define AUTH_STORE_USERNAME_MIN 3
+#define AUTH_STORE_USERNAME_MAX 32
+
+typedef struct Auth_Store Auth_Store;
+
+typedef enum {
+  AUTH_STORE_ERROR          = -1,
+  AUTH_STORE_OK             = 0,
+  AUTH_STORE_NOT_FOUND      = 1,
+  AUTH_STORE_CONFLICT       = 2,
+  AUTH_STORE_EXPIRED        = 3,
+  AUTH_STORE_REVOKED        = 4,
+  AUTH_STORE_USER_DISABLED  = 5,
+  AUTH_STORE_STALE_PASSWORD = 6,
+  AUTH_STORE_LAST_ADMIN     = 7,
+  AUTH_STORE_INVALID_ARG    = 8,
+} Auth_Store_Result;
+
+typedef enum {
+  AUTH_STORE_BOOTSTRAP_CREATED       = 0,
+  AUTH_STORE_BOOTSTRAP_ALREADY_PRESENT = 1,
+} Auth_Store_Bootstrap_Result;
+
+/*
+ * Public user record — password_hash is never included here.
+ * All fields are fixed-size; no arena pointer is needed.
+ */
+typedef struct {
+  char    id[37];
+  char    username[AUTH_STORE_USERNAME_MAX + 1];
+  char    normalized_username[AUTH_STORE_USERNAME_MAX + 1];
+  char    role[8];    /* "admin" or "member" */
+  char    status[9];  /* "active" or "disabled" */
+  boolean must_change_password;
+  int64   password_changed_at;
+  int64   created_at;
+  int64   updated_at;
+} Auth_User_Record;
+
+/*
+ * Authentication lookup record — includes the encoded password hash.
+ * Callers must zero this struct after use; never log the hash field.
+ */
+typedef struct {
+  Auth_User_Record user;
+  char             password_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+} Auth_User_Auth_Record;
+
+/*
+ * Session record — token/CSRF digests are not exposed here; the caller
+ * already holds them before calling Create_Session or Find_Session.
+ */
+typedef struct {
+  char  user_id[37];
+  int64 created_at;
+  int64 last_seen_at;
+  int64 idle_expires_at;
+  int64 absolute_expires_at;
+  int64 password_changed_at_snapshot;
+} Auth_Session_Record;
+
+typedef struct {
+  char  id[37];
+  int64 created_at;
+  int64 last_seen_at;
+  int64 expires_at;
+} Auth_Guest_Identity_Record;
+
+/* --- Store lifecycle --- */
+
+Auth_Store *Auth_Store_Create(const char *database_path);
+void        Auth_Store_Destroy(Auth_Store *p_store);
+
+/* --- Username utilities --- */
+
+/*
+ * Trim ASCII spaces, lowercase, validate character set and length.
+ * Writes the normalized form to 'normalized' (capacity must include NUL).
+ * Returns TRUE on success; FALSE if invalid or buffer too small.
+ */
+boolean Auth_Store_Normalize_Username(
+    const char *username,
+    char       *normalized,
+    size_t      capacity);
+
+/*
+ * Validate a pre-normalized username (lowercase, no leading/trailing spaces).
+ * Returns TRUE iff length and character set are within policy.
+ */
+boolean Auth_Store_Validate_Username(const char *normalized_username);
+
+/* --- User management --- */
+
+Auth_Store_Result Auth_Store_Create_User(
+    Auth_Store *p_store,
+    const char *username,
+    const char *encoded_hash,
+    const char *role,
+    boolean     must_change_password,
+    char        output_id[37]);
+
+/* Create a user and its audit record in one transaction. */
+Auth_Store_Result Auth_Store_Create_User_Audited(
+    Auth_Store *p_store,
+    const char *username,
+    const char *encoded_hash,
+    const char *role,
+    boolean     must_change_password,
+    const char *actor_user_id,
+    char        output_id[37]);
+
+/*
+ * Idempotent: creates an admin user only when no admin exists at all.
+ * p_bootstrap_result receives CREATED or ALREADY_PRESENT.
+ * output_id receives the UUID (new or existing) and may be NULL.
+ */
+Auth_Store_Result Auth_Store_Bootstrap_Admin(
+    Auth_Store               *p_store,
+    const char               *username,
+    const char               *encoded_hash,
+    Auth_Store_Bootstrap_Result *p_bootstrap_result,
+    char                      output_id[37]);
+
+/* Includes password_hash for authentication — zero record after use. */
+Auth_Store_Result Auth_Store_Find_User_By_Username(
+    Auth_Store         *p_store,
+    const char         *username,
+    Auth_User_Auth_Record *p_record);
+
+Auth_Store_Result Auth_Store_Get_User(
+    Auth_Store       *p_store,
+    const char       *user_id,
+    Auth_User_Record *p_record);
+
+/*
+ * List all users into a Dowa arena-backed array.
+ * After a successful call, use Dowa_Array_Length(*pp_records) for count.
+ */
+Auth_Store_Result Auth_Store_List_Users(
+    Auth_Store        *p_store,
+    Auth_User_Record **pp_records,
+    Dowa_Arena        *p_arena);
+
+/*
+ * new_status must be "active" or "disabled".
+ * Fails with AUTH_STORE_LAST_ADMIN if disabling the last active admin.
+ */
+Auth_Store_Result Auth_Store_Update_User_Status(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_status,
+    const char *actor_user_id);
+
+/* Atomically enable a user and write audit; revoked sessions stay revoked. */
+Auth_Store_Result Auth_Store_Enable_User(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *actor_user_id);
+
+/* Atomically enforce last-admin policy, disable, revoke, and write audit. */
+Auth_Store_Result Auth_Store_Disable_User_And_Revoke_Sessions(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *actor_user_id);
+
+/*
+ * new_role must be "admin" or "member".
+ * Fails with AUTH_STORE_LAST_ADMIN if demoting the last active admin.
+ */
+Auth_Store_Result Auth_Store_Update_User_Role(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_role,
+    const char *actor_user_id);
+
+/* Atomically enforce last-admin policy, update role, revoke, and audit. */
+Auth_Store_Result Auth_Store_Update_Role_And_Revoke_Sessions(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_role,
+    const char *actor_user_id);
+
+Auth_Store_Result Auth_Store_Set_Must_Change_Password(
+    Auth_Store *p_store,
+    const char *user_id,
+    boolean     value,
+    const char *actor_user_id);
+
+/*
+ * Updates the encoded password hash; clears must_change_password.
+ * If revoke_other_sessions is TRUE, all sessions except keep_token_digest
+ * (which may be NULL) are revoked atomically in the same transaction.
+ */
+Auth_Store_Result Auth_Store_Update_Password(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_encoded_hash,
+    boolean     revoke_other_sessions,
+    const char *keep_token_digest);
+
+/* --- Session management --- */
+
+/*
+ * Creates a new session.  Fails if the user is disabled.
+ * current_unix is the caller-supplied Unix timestamp.
+ */
+Auth_Store_Result Auth_Store_Create_Session(
+    Auth_Store          *p_store,
+    const char          *user_id,
+    const char          *token_digest,
+    const char          *csrf_digest,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    Auth_Session_Record *p_record);
+
+/*
+ * CAS session create: only creates a session if the user's current
+ * password_hash still exactly matches expected_password_hash.
+ * Returns AUTH_STORE_STALE_PASSWORD when an administrator changed it.
+ * The caller must cleanse expected_password_hash after this call.
+ */
+Auth_Store_Result Auth_Store_Create_Session_CAS(
+    Auth_Store          *p_store,
+    const char          *user_id,
+    const char          *expected_password_hash,
+    const char          *token_digest,
+    const char          *csrf_digest,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    Auth_Session_Record *p_record);
+
+/*
+ * Resolves a session by token_digest.
+ * Returns:
+ *   OK             — session is valid; p_session and p_user are populated.
+ *   NOT_FOUND      — no such session.
+ *   REVOKED        — session was explicitly revoked.
+ *   EXPIRED        — idle or absolute expiry exceeded.
+ *   USER_DISABLED  — session owner has been disabled.
+ *   STALE_PASSWORD — password changed after session was created.
+ */
+Auth_Store_Result Auth_Store_Find_Session(
+    Auth_Store          *p_store,
+    const char          *token_digest,
+    int64                current_unix,
+    Auth_Session_Record *p_session,
+    Auth_User_Record    *p_user);
+
+/* Extends the idle expiry; does nothing if the session is revoked. */
+Auth_Store_Result Auth_Store_Touch_Session(
+    Auth_Store *p_store,
+    const char *token_digest,
+    int64       current_unix,
+    int64       idle_ttl_secs);
+
+Auth_Store_Result Auth_Store_Revoke_Session(
+    Auth_Store *p_store,
+    const char *token_digest);
+
+/*
+ * Revokes all sessions for user_id.
+ * If except_token_digest is non-NULL, that session is preserved.
+ */
+Auth_Store_Result Auth_Store_Revoke_All_Sessions(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *except_token_digest);
+
+/* Atomically revoke sessions and write one audit record. */
+Auth_Store_Result Auth_Store_Revoke_All_Sessions_Audited(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *except_token_digest,
+    const char *actor_user_id);
+
+/*
+ * Atomically create a new session and revoke the old one in a single
+ * transaction.  Use this after a password change to rotate the current
+ * session without any window where neither or both sessions are valid.
+ *
+ * old_token_digest: the existing session to revoke (must not be NULL or "").
+ *   If old_token_digest is already revoked the rotation still succeeds and
+ *   the new session is created.
+ * All other parameters are the same as Auth_Store_Create_Session.
+ */
+Auth_Store_Result Auth_Store_Rotate_Session(
+    Auth_Store          *p_store,
+    const char          *user_id,
+    const char          *old_token_digest,
+    const char          *new_token_digest,
+    const char          *new_csrf_digest,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    Auth_Session_Record *p_record);
+
+/*
+ * Atomically compare-and-swap the password hash, clear forced change,
+ * revoke every existing session, and create one replacement session.
+ * On AUTH_STORE_STALE_PASSWORD no state is changed. The caller must cleanse
+ * both password hashes after this call.
+ */
+Auth_Store_Result Auth_Store_Self_Change_Password(
+    Auth_Store          *p_store,
+    const char          *user_id,
+    const char          *old_encoded_hash,
+    const char          *new_encoded_hash,
+    const char          *new_token_digest,
+    const char          *new_csrf_digest,
+    int64                idle_ttl_secs,
+    int64                absolute_ttl_secs,
+    int64                current_unix,
+    Auth_Session_Record *p_record);
+
+/* --- Guest quota --- */
+
+typedef enum {
+  AUTH_STORE_GUEST_QUOTA_OK               =  0,
+  AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED  =  1,
+  AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED =  2,
+  AUTH_STORE_GUEST_QUOTA_ERROR            = -1,
+} Auth_Store_Guest_Quota_Result;
+
+typedef struct {
+  int64 turns_used;
+  int64 output_tokens_used;
+  int64 output_tokens_reserved;
+} Auth_Store_Guest_Usage;
+
+/*
+ * Read current daily usage for a guest window.
+ * window_start: UTC midnight Unix timestamp for the current day.
+ * Returns OK with zeroed usage if no row exists yet, or ERROR.
+ */
+Auth_Store_Result Auth_Store_Guest_Get_Usage(
+    Auth_Store             *p_store,
+    const char             *guest_id,
+    int64                   window_start,
+    Auth_Store_Guest_Usage *p_usage);
+
+/*
+ * Atomically reserve quota for one inference turn.
+ * On success: increments turns_used by 1 and output_tokens_reserved by
+ *   max_output_tokens in the usage row for window_start.
+ * Invariant after every reservation:
+ *   output_tokens_used + output_tokens_reserved <= tokens_limit.
+ * Returns TURNS_EXHAUSTED or TOKENS_EXHAUSTED when the limit would be exceeded.
+ * reservation_expires: Unix timestamp after which the reservation may be
+ *   discarded by cleanup; the turn charge (turns_used) persists regardless.
+ */
+Auth_Store_Guest_Quota_Result Auth_Store_Guest_Reserve(
+    Auth_Store *p_store,
+    const char *guest_id,
+    const char *request_id,
+    int64       window_start,
+    int64       max_output_tokens,
+    int64       turns_limit,
+    int64       tokens_limit,
+    int64       reservation_expires);
+
+/*
+ * Reconcile a completed turn with the actual provider output token count.
+ * actual_output_tokens is capped to the reserved amount (fail closed; if
+ * the provider reports more than reserved, the reserved amount is charged).
+ * Adds capped tokens to output_tokens_used; subtracts reserved from
+ * output_tokens_reserved; deletes the reservation row.
+ * Idempotent by request_id: safe to call when already reconciled.
+ */
+Auth_Store_Result Auth_Store_Guest_Reconcile(
+    Auth_Store *p_store,
+    const char *request_id,
+    int64       actual_output_tokens);
+
+/*
+ * Release a reservation on failure or abort.
+ * Decrements output_tokens_reserved by the reserved amount; does NOT
+ * change turns_used (the turn charge is retained).
+ * Deletes the reservation row.
+ * Idempotent by request_id.
+ */
+Auth_Store_Result Auth_Store_Guest_Release(
+    Auth_Store *p_store,
+    const char *request_id);
+
+/*
+ * Clear all outstanding reservations for a guest.
+ * Used after a successful guest→user login transfer.
+ * Decrements output_tokens_reserved for each affected usage window.
+ * Does NOT change turns_used.
+ */
+Auth_Store_Result Auth_Store_Guest_Clear_Reservations(
+    Auth_Store *p_store,
+    const char *guest_id);
+
+/*
+ * Atomically reap expired reservations: decrement output_tokens_reserved
+ * in the matching guest_usage rows and delete expired reservation rows, all
+ * in one transaction.  Uses MAX(0,...) to avoid underflow.
+ * Idempotent: safe to call repeatedly with the same or decreasing current_unix.
+ * Called at store startup and before quota read/reserve operations.
+ */
+Auth_Store_Result Auth_Store_Guest_Reap_Expired(
+    Auth_Store *p_store,
+    int64       current_unix);
+
+/* --- Guest identity --- */
+
+/*
+ * Insert or refresh a guest identity.
+ * ip_binding_digest must be an HMAC digest — never the raw IP address.
+ * last_seen_at is updated to now; expires_at is refreshed.
+ */
+Auth_Store_Result Auth_Store_Upsert_Guest_Identity(
+    Auth_Store                *p_store,
+    const char                *guest_id,
+    const char                *ip_binding_digest,
+    int64                      expires_at,
+    Auth_Guest_Identity_Record *p_record);
+
+Auth_Store_Result Auth_Store_Find_Guest_Identity(
+    Auth_Store                *p_store,
+    const char                *guest_id,
+    int64                      current_unix,
+    Auth_Guest_Identity_Record *p_record);
+
+/* --- Audit log --- */
+
+/*
+ * Append one bounded audit entry.  actor/target/detail may be NULL.
+ * Never include passwords, hashes, tokens, or session digests.
+ * Returns OK or ERROR.
+ */
+Auth_Store_Result Auth_Store_Insert_Audit_Log(
+    Auth_Store *p_store,
+    const char *actor_user_id,
+    const char *action,
+    const char *target_user_id,
+    const char *detail);
+
+/* --- Admin operations --- */
+
+/*
+ * Atomically: update password hash, set must_change_password=1,
+ * revoke all target sessions, write audit row.
+ * Use for admin-initiated temporary-password reset only.
+ * new_encoded_hash must be a valid encoded zenbu-scrypt hash.
+ */
+Auth_Store_Result Auth_Store_Admin_Reset_Password(
+    Auth_Store *p_store,
+    const char *user_id,
+    const char *new_encoded_hash,
+    const char *actor_user_id);
+
+#endif
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/auth/hash_password.c	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,111 @@
+#include "auth/auth_crypto.h"
+
+#include <openssl/crypto.h>
+
+#include <stdio.h>
+#include <string.h>
+#include <termios.h>
+#include <unistd.h>
+
+#define PW_MAX AUTH_CRYPTO_PASSWORD_MAX_BYTES
+
+static int read_password(int fd, char *buf, size_t capacity)
+{
+  size_t len = 0;
+  int too_long = 0;
+  for (;;)
+  {
+    unsigned char c;
+    int r = (int)read(fd, &c, 1);
+    if (r < 0)
+      return -1;
+    if (r == 0)
+      break;
+    if (c == '\n' || c == '\r')
+      break;
+    if (len + 1 >= capacity)
+    {
+      too_long = 1;
+      continue;
+    }
+    buf[len++] = (char)c;
+  }
+  buf[len] = '\0';
+  return too_long ? -2 : (int)len;
+}
+
+int main(int argc, char **argv)
+{
+  (void)argc;
+  (void)argv;
+
+  int fd = STDIN_FILENO;
+  int is_tty = isatty(fd);
+  struct termios old_term;
+  struct termios new_term;
+  int restored = 0;
+
+  if (is_tty)
+  {
+    if (tcgetattr(fd, &old_term) != 0)
+    {
+      fprintf(stderr, "hash_password: failed to get terminal attributes\n");
+      return 1;
+    }
+    new_term = old_term;
+    new_term.c_lflag &= (tcflag_t)~(ECHO | ECHOE | ECHOK | ECHONL);
+    new_term.c_lflag |= ICANON;
+    if (tcsetattr(fd, TCSAFLUSH, &new_term) != 0)
+    {
+      fprintf(stderr, "hash_password: failed to disable echo\n");
+      return 1;
+    }
+    restored = 1;
+    fprintf(stderr, "Password: ");
+    fflush(stderr);
+  }
+
+  char pw[PW_MAX + 1];
+  memset(pw, 0, sizeof(pw));
+  int n = read_password(fd, pw, sizeof(pw));
+
+  if (restored)
+  {
+    tcsetattr(fd, TCSAFLUSH, &old_term);
+    fprintf(stderr, "\n");
+  }
+
+  if (n == -2)
+  {
+    fprintf(stderr, "hash_password: %s\n",
+            Auth_Crypto_Result_String(AUTH_CRYPTO_PASSWORD_TOO_LONG));
+    OPENSSL_cleanse(pw, sizeof(pw));
+    return 1;
+  }
+  if (n < 0)
+  {
+    fprintf(stderr, "hash_password: read error\n");
+    OPENSSL_cleanse(pw, sizeof(pw));
+    return 1;
+  }
+  if (n == 0)
+  {
+    fprintf(stderr, "hash_password: empty password\n");
+    OPENSSL_cleanse(pw, sizeof(pw));
+    return 1;
+  }
+
+  char encoded_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  Auth_Crypto_Result result =
+      Auth_Crypto_Password_Hash(pw, encoded_hash, sizeof(encoded_hash));
+  OPENSSL_cleanse(pw, sizeof(pw));
+
+  if (result != AUTH_CRYPTO_OK)
+  {
+    fprintf(stderr, "hash_password: %s\n", Auth_Crypto_Result_String(result));
+    return 1;
+  }
+
+  puts(encoded_hash);
+  return 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/auth/test/BUILD	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,20 @@
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+cc_test(
+  name = "auth_crypto_test",
+  srcs = ["auth_crypto_test.c"],
+  deps = ["//auth:auth_crypto"],
+  size = "small",
+)
+
+cc_test(
+  name = "auth_store_test",
+  srcs = ["auth_store_test.c"],
+  deps = [
+    "//auth:auth_crypto",
+    "//auth:auth_store",
+    "//deita:deita",
+  ],
+  size = "medium",
+  timeout = "moderate",
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/auth/test/auth_crypto_test.c	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,259 @@
+#include "auth/auth_crypto.h"
+
+#include <assert.h>
+#include <stdio.h>
+#include <string.h>
+
+static boolean is_lowercase_hex(const char *text, size_t length)
+{
+  for (size_t i = 0; i < length; ++i)
+  {
+    if (!((text[i] >= '0' && text[i] <= '9') ||
+          (text[i] >= 'a' && text[i] <= 'f')))
+    {
+      return FALSE;
+    }
+  }
+  return TRUE;
+}
+
+static boolean is_base64url(const char *text, size_t length)
+{
+  for (size_t i = 0; i < length; ++i)
+  {
+    if (!((text[i] >= 'A' && text[i] <= 'Z') ||
+          (text[i] >= 'a' && text[i] <= 'z') ||
+          (text[i] >= '0' && text[i] <= '9') ||
+          text[i] == '-' || text[i] == '_'))
+    {
+      return FALSE;
+    }
+  }
+  return TRUE;
+}
+
+static void test_password_hashes(void)
+{
+  char first[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  char second[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+
+  assert(Auth_Crypto_Password_Hash(
+      "correct horse battery staple", first, sizeof(first)) == AUTH_CRYPTO_OK);
+  assert(Auth_Crypto_Password_Hash(
+      "correct horse battery staple", second, sizeof(second)) == AUTH_CRYPTO_OK);
+  assert(strcmp(first, second) != 0);
+  const char *prefix = "zenbu-scrypt$v=1$N=32768$r=8$p=1$";
+  assert(strncmp(first, prefix, strlen(prefix)) == 0);
+  assert(memcmp(
+      first + strlen(prefix),
+      second + strlen(prefix),
+      AUTH_CRYPTO_PASSWORD_SALT_BYTES * 2) != 0);
+  assert(Auth_Crypto_Password_Verify(
+      "correct horse battery staple", first) == AUTH_CRYPTO_OK);
+  assert(Auth_Crypto_Password_Verify(
+      "wrong password", first) == AUTH_CRYPTO_AUTHENTICATION_FAILED);
+
+  char malformed[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  strcpy(malformed, first);
+  malformed[0] = 'x';
+  assert(Auth_Crypto_Password_Verify(
+      "correct horse battery staple", malformed) == AUTH_CRYPTO_MALFORMED);
+  assert(Auth_Crypto_Password_Verify(
+      "correct horse battery staple", "not-a-password-hash") ==
+      AUTH_CRYPTO_MALFORMED);
+
+  char oversized[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 2];
+  memset(oversized, 'p', sizeof(oversized));
+  oversized[sizeof(oversized) - 1] = '\0';
+  assert(Auth_Crypto_Password_Hash(
+      oversized, first, sizeof(first)) == AUTH_CRYPTO_PASSWORD_TOO_LONG);
+  assert(Auth_Crypto_Password_Verify(
+      oversized, second) == AUTH_CRYPTO_PASSWORD_TOO_LONG);
+
+  char too_small[8];
+  assert(Auth_Crypto_Password_Hash(
+      "password", too_small, sizeof(too_small)) ==
+      AUTH_CRYPTO_BUFFER_TOO_SMALL);
+}
+
+static void test_tokens(void)
+{
+  char first[AUTH_CRYPTO_TOKEN_SIZE];
+  char second[AUTH_CRYPTO_TOKEN_SIZE];
+  assert(Auth_Crypto_Token_Generate(first, sizeof(first)) == AUTH_CRYPTO_OK);
+  assert(Auth_Crypto_Token_Generate(second, sizeof(second)) == AUTH_CRYPTO_OK);
+  assert(strlen(first) == AUTH_CRYPTO_TOKEN_SIZE - 1);
+  assert(is_base64url(first, strlen(first)));
+  assert(strchr(first, '=') == NULL);
+  assert(strcmp(first, second) != 0);
+
+  char first_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+  char repeated_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+  char second_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+  assert(Auth_Crypto_Token_Digest(
+      first, first_digest, sizeof(first_digest)) == AUTH_CRYPTO_OK);
+  assert(Auth_Crypto_Token_Digest(
+      first, repeated_digest, sizeof(repeated_digest)) == AUTH_CRYPTO_OK);
+  assert(Auth_Crypto_Token_Digest(
+      second, second_digest, sizeof(second_digest)) == AUTH_CRYPTO_OK);
+  assert(strlen(first_digest) == AUTH_CRYPTO_TOKEN_DIGEST_SIZE - 1);
+  assert(is_lowercase_hex(first_digest, strlen(first_digest)));
+  assert(strcmp(first_digest, repeated_digest) == 0);
+  assert(strcmp(first_digest, second_digest) != 0);
+
+  const char *digest_vector_token =
+      "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
+  assert(Auth_Crypto_Token_Digest(
+      digest_vector_token, first_digest, sizeof(first_digest)) ==
+      AUTH_CRYPTO_OK);
+  assert(strcmp(
+      first_digest,
+      "0f007385b6f9d4b7eeb2748605afe1a984a0a3bfa3f014d09e2a784ce9e5cd1a") ==
+      0);
+}
+
+static void test_ip_binding_and_guest_cookies(void)
+{
+  uint8 secret[AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES] = {
+    0x1f, 0x8c, 0x32, 0x99, 0x50, 0xe7, 0xa6, 0x21,
+    0x83, 0x44, 0xab, 0xcd, 0x72, 0x91, 0x05, 0xfe,
+    0x66, 0x3a, 0x10, 0x28, 0xdd, 0xc0, 0x4b, 0x7e,
+    0x59, 0x92, 0xb1, 0x13, 0xef, 0x70, 0x46, 0x8a,
+  };
+  const char *peer_ip = "2001:db8::1234";
+  const char *other_ip = "2001:db8::5678";
+
+  char ip_digest[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE];
+  char repeated_ip_digest[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE];
+  char other_ip_digest[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE];
+  assert(Auth_Crypto_IP_Binding_Digest(
+      secret, sizeof(secret), peer_ip, ip_digest, sizeof(ip_digest)) ==
+      AUTH_CRYPTO_OK);
+  assert(Auth_Crypto_IP_Binding_Digest(
+      secret,
+      sizeof(secret),
+      peer_ip,
+      repeated_ip_digest,
+      sizeof(repeated_ip_digest)) == AUTH_CRYPTO_OK);
+  assert(Auth_Crypto_IP_Binding_Digest(
+      secret,
+      sizeof(secret),
+      other_ip,
+      other_ip_digest,
+      sizeof(other_ip_digest)) == AUTH_CRYPTO_OK);
+  assert(strlen(ip_digest) == AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE - 1);
+  assert(is_lowercase_hex(ip_digest, strlen(ip_digest)));
+  assert(strcmp(ip_digest, repeated_ip_digest) == 0);
+  assert(strcmp(ip_digest, other_ip_digest) != 0);
+
+  const char *guest_uuid = "123e4567-e89b-12d3-a456-426614174000";
+  uint64 expiration = 2000000000;
+  char cookie[AUTH_CRYPTO_GUEST_COOKIE_SIZE];
+  assert(Auth_Crypto_Guest_Cookie_Create(
+      secret,
+      sizeof(secret),
+      guest_uuid,
+      expiration,
+      ip_digest,
+      cookie,
+      sizeof(cookie)) == AUTH_CRYPTO_OK);
+  assert(strstr(cookie, peer_ip) == NULL);
+
+  Auth_Crypto_Guest_Cookie verified;
+  assert(Auth_Crypto_Guest_Cookie_Verify(
+      secret,
+      sizeof(secret),
+      cookie,
+      expiration - 1,
+      ip_digest,
+      &verified) == AUTH_CRYPTO_OK);
+  assert(strcmp(verified.guest_uuid, guest_uuid) == 0);
+  assert(verified.expiration_unix == expiration);
+
+  char tampered[AUTH_CRYPTO_GUEST_COOKIE_SIZE];
+  strcpy(tampered, cookie);
+  size_t cookie_length = strlen(tampered);
+  tampered[cookie_length - 1] =
+      tampered[cookie_length - 1] == '0' ? '1' : '0';
+  assert(Auth_Crypto_Guest_Cookie_Verify(
+      secret,
+      sizeof(secret),
+      tampered,
+      expiration - 1,
+      ip_digest,
+      &verified) == AUTH_CRYPTO_AUTHENTICATION_FAILED);
+
+  assert(Auth_Crypto_Guest_Cookie_Verify(
+      secret,
+      sizeof(secret),
+      cookie,
+      expiration,
+      ip_digest,
+      &verified) == AUTH_CRYPTO_EXPIRED);
+  assert(Auth_Crypto_Guest_Cookie_Verify(
+      secret,
+      sizeof(secret),
+      cookie,
+      expiration - 1,
+      other_ip_digest,
+      &verified) == AUTH_CRYPTO_IP_MISMATCH);
+  assert(Auth_Crypto_Guest_Cookie_Verify(
+      secret,
+      sizeof(secret),
+      "v1.malformed",
+      expiration - 1,
+      ip_digest,
+      &verified) == AUTH_CRYPTO_MALFORMED);
+}
+
+int main(void)
+{
+  test_password_hashes();
+  test_tokens();
+  test_ip_binding_and_guest_cookies();
+
+  /* Auth_Crypto_Base64url_Encode */
+  {
+    /* 32 bytes → 43 unpadded base64url chars */
+    uint8 bytes32[32] = {0};
+    char  out[AUTH_CRYPTO_TOKEN_SIZE];
+    size_t n = Auth_Crypto_Base64url_Encode(bytes32, 32, out, sizeof(out));
+    assert(n == AUTH_CRYPTO_TOKEN_SIZE - 1);
+    assert(strlen(out) == AUTH_CRYPTO_TOKEN_SIZE - 1);
+
+    /* 3 bytes → 4 chars */
+    uint8 b3[3] = {0xfb, 0xff, 0xfe};
+    char  o3[8];
+    assert(Auth_Crypto_Base64url_Encode(b3, 3, o3, sizeof(o3)) == 4);
+
+    /* 1 byte → 2 chars */
+    uint8 b1[1] = {0x00};
+    char  o1[4];
+    assert(Auth_Crypto_Base64url_Encode(b1, 1, o1, sizeof(o1)) == 2);
+
+    /* 2 bytes → 3 chars */
+    uint8 b2[2] = {0x00, 0x00};
+    char  o2[4];
+    assert(Auth_Crypto_Base64url_Encode(b2, 2, o2, sizeof(o2)) == 3);
+
+    /* Buffer too small → 0 */
+    char  oSmall[3];
+    assert(Auth_Crypto_Base64url_Encode(bytes32, 32, oSmall, sizeof(oSmall)) == 0);
+
+    /* Output uses URL-safe alphabet only */
+    uint8 bRnd[32];
+    for (int i = 0; i < 32; i++) bRnd[i] = (uint8)i;
+    char oRnd[AUTH_CRYPTO_TOKEN_SIZE];
+    n = Auth_Crypto_Base64url_Encode(bRnd, 32, oRnd, sizeof(oRnd));
+    assert(n == 43);
+    for (size_t i = 0; i < n; i++) {
+      char c = oRnd[i];
+      assert((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
+             (c >= '0' && c <= '9') || c == '-' || c == '_');
+    }
+  }
+  puts("Auth_Crypto_Base64url_Encode: PASS");
+
+  puts("auth_crypto_test: PASS");
+  return 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/auth/test/auth_store_test.c	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,1587 @@
+#include "auth/auth_store.h"
+#include "auth/auth_crypto.h"
+#include "deita/deita.h"
+
+#include <assert.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+/* ------------------------------------------------------------------ */
+/* Helpers                                                              */
+/* ------------------------------------------------------------------ */
+
+static char g_hash_buf[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+
+static const char *get_test_hash(void)
+{
+  if (g_hash_buf[0] == '\0')
+  {
+    Auth_Crypto_Result r = Auth_Crypto_Password_Hash(
+        "hunter2", g_hash_buf, sizeof(g_hash_buf));
+    assert(r == AUTH_CRYPTO_OK);
+  }
+  return g_hash_buf;
+}
+
+static void make_test_hash(
+    const char *password,
+    char         output[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE])
+{
+  assert(Auth_Crypto_Password_Hash(
+      password, output, AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE) ==
+      AUTH_CRYPTO_OK);
+}
+
+/* Simple UUID generator for the test, matching the pattern in
+   conversation_store.c (no dependency on auth_store internals). */
+static boolean test__make_uuid(char output[37])
+{
+  uint8   bytes[16];
+  int     fd     = open("/dev/urandom", O_RDONLY);
+  size_t  offset = 0;
+  if (fd < 0)
+    return FALSE;
+  while (offset < sizeof(bytes))
+  {
+    ssize_t n = read(fd, bytes + offset, sizeof(bytes) - offset);
+    if (n <= 0) { close(fd); return FALSE; }
+    offset += (size_t)n;
+  }
+  close(fd);
+  bytes[6] = (uint8)((bytes[6] & 0x0f) | 0x40);
+  bytes[8] = (uint8)((bytes[8] & 0x3f) | 0x80);
+  snprintf(output, 37,
+      "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
+      bytes[0],bytes[1],bytes[2],bytes[3],bytes[4],bytes[5],
+      bytes[6],bytes[7],bytes[8],bytes[9],bytes[10],bytes[11],
+      bytes[12],bytes[13],bytes[14],bytes[15]);
+  return TRUE;
+}
+
+/* ------------------------------------------------------------------ */
+/* 1. Username normalization and rejection                              */
+/* ------------------------------------------------------------------ */
+
+static void test_username_normalization(void)
+{
+  char out[AUTH_STORE_USERNAME_MAX + 1];
+
+  assert(Auth_Store_Normalize_Username("  JohnDoe  ", out, sizeof(out)));
+  assert(strcmp(out, "johndoe") == 0);
+
+  assert(Auth_Store_Normalize_Username("Alice", out, sizeof(out)));
+  assert(strcmp(out, "alice") == 0);
+
+  assert(Auth_Store_Normalize_Username("june_bot-2.0", out, sizeof(out)));
+  assert(strcmp(out, "june_bot-2.0") == 0);
+
+  assert(Auth_Store_Normalize_Username("abc", out, sizeof(out)));
+
+  /* Exactly 32 chars */
+  assert(Auth_Store_Normalize_Username(
+      "abcdefghijklmnopqrstuvwxyz123456", out, sizeof(out)));
+
+  /* Too short after trimming */
+  assert(!Auth_Store_Normalize_Username("ab", out, sizeof(out)));
+  assert(!Auth_Store_Normalize_Username("  z  ", out, sizeof(out)));
+
+  /* Too long (33 chars) */
+  assert(!Auth_Store_Normalize_Username(
+      "abcdefghijklmnopqrstuvwxyz1234567", out, sizeof(out)));
+
+  /* Invalid characters */
+  assert(!Auth_Store_Normalize_Username("hello world", out, sizeof(out)));
+  assert(!Auth_Store_Normalize_Username("invalid!", out, sizeof(out)));
+  assert(!Auth_Store_Normalize_Username("utf8\xc3\xa9", out, sizeof(out)));
+
+  /* Buffer too small */
+  char tiny[3];
+  assert(!Auth_Store_Normalize_Username("abc", tiny, sizeof(tiny)));
+
+  /* Validate pre-normalized */
+  assert( Auth_Store_Validate_Username("johndoe"));
+  assert( Auth_Store_Validate_Username("abc"));
+  assert( Auth_Store_Validate_Username("june_bot-2.0"));
+  assert(!Auth_Store_Validate_Username("ab"));
+  assert(!Auth_Store_Validate_Username("Hello"));   /* uppercase */
+  assert(!Auth_Store_Validate_Username("bad char!"));
+  assert(!Auth_Store_Validate_Username(NULL));
+
+  puts("test_username_normalization: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 2. Migration idempotency / reopen                                    */
+/* ------------------------------------------------------------------ */
+
+static void test_migrations_idempotent(const char *db_path)
+{
+  Auth_Store *p = Auth_Store_Create(db_path);
+  assert(p);
+  Auth_Store_Destroy(p);
+
+  /* Reopen: migrations must be no-ops. */
+  p = Auth_Store_Create(db_path);
+  assert(p);
+  Auth_Store_Destroy(p);
+
+  /* Verify all expected tables exist via a second connection. */
+  Dowa_Arena       *p_arena = Dowa_Arena_Create(4096);
+  assert(p_arena);
+  Deita_Connection *p_conn = Deita_Connection_Create(
+      DEITA_DATABASE_TYPE_SQLITE3, db_path);
+  assert(p_conn);
+
+  static const char *expected[] = {
+    "admin_audit_log", "auth_schema_migrations", "auth_sessions",
+    "guest_identities", "guest_usage", "guest_usage_reservations", "users",
+  };
+  size_t found = 0;
+
+  Deita_Result_Set *p_result = Deita_Query_Execute(
+      p_conn,
+      "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
+      p_arena);
+  assert(p_result);
+  while (Deita_Result_Set_Next(p_result))
+  {
+    const char *name = Deita_Result_Set_Get_Text(p_result, 0);
+    for (size_t i = 0; i < sizeof(expected)/sizeof(expected[0]); i++)
+      if (name && strcmp(name, expected[i]) == 0) { found++; break; }
+  }
+  Deita_Result_Set_Free(p_result);
+  Deita_Connection_Close(p_conn);
+  Dowa_Arena_Free(p_arena);
+
+  assert(found == sizeof(expected)/sizeof(expected[0]));
+  puts("test_migrations_idempotent: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 3. Bootstrap admin idempotency                                       */
+/* ------------------------------------------------------------------ */
+
+static void test_bootstrap_admin(Auth_Store *p_store)
+{
+  char id1[37], id2[37];
+  Auth_Store_Bootstrap_Result br;
+
+  assert(Auth_Store_Bootstrap_Admin(
+      p_store, "Admin", get_test_hash(), &br, id1) == AUTH_STORE_OK);
+  assert(br == AUTH_STORE_BOOTSTRAP_CREATED);
+  assert(strlen(id1) == 36);
+
+  /* Second call with a different username: must not overwrite existing admin. */
+  assert(Auth_Store_Bootstrap_Admin(
+      p_store, "OtherAdmin", get_test_hash(), &br, id2) == AUTH_STORE_OK);
+  assert(br == AUTH_STORE_BOOTSTRAP_ALREADY_PRESENT);
+  assert(strcmp(id1, id2) == 0);
+
+  /* Exactly one admin must exist. */
+  Auth_User_Record *records = NULL;
+  Dowa_Arena *p_arena = Dowa_Arena_Create(64 * 1024);
+  assert(p_arena);
+  assert(Auth_Store_List_Users(p_store, &records, p_arena) == AUTH_STORE_OK);
+  size_t admin_count = 0;
+  for (size_t i = 0; i < Dowa_Array_Length(records); i++)
+    if (strcmp(records[i].role, "admin") == 0)
+      admin_count++;
+  assert(admin_count == 1);
+  Dowa_Arena_Free(p_arena);
+
+  puts("test_bootstrap_admin: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 4. Last-admin protection (run while only one admin exists)          */
+/* ------------------------------------------------------------------ */
+
+static void test_last_admin_protection(Auth_Store *p_store)
+{
+  /* At this point only the bootstrap admin ("Admin"/"admin") exists. */
+  Auth_User_Auth_Record ar;
+  assert(Auth_Store_Find_User_By_Username(
+      p_store, "admin", &ar) == AUTH_STORE_OK);
+  const char *admin_id = ar.user.id;
+
+  /* Cannot disable the last active admin. */
+  assert(Auth_Store_Update_User_Status(
+      p_store, admin_id, "disabled", NULL) == AUTH_STORE_LAST_ADMIN);
+
+  /* Cannot demote the last active admin. */
+  assert(Auth_Store_Update_User_Role(
+      p_store, admin_id, "member", NULL) == AUTH_STORE_LAST_ADMIN);
+  assert(Auth_Store_Disable_User_And_Revoke_Sessions(
+      p_store, admin_id, NULL) == AUTH_STORE_LAST_ADMIN);
+  assert(Auth_Store_Update_Role_And_Revoke_Sessions(
+      p_store, admin_id, "member", NULL) == AUTH_STORE_LAST_ADMIN);
+
+  /* Add a second active admin — operations on the first should now succeed. */
+  char id2[37];
+  assert(Auth_Store_Create_User(
+      p_store, "SecondAdmin", get_test_hash(), "admin", FALSE, id2) ==
+      AUTH_STORE_OK);
+
+  /* Now demotion of the first admin is allowed (two active admins). */
+  assert(Auth_Store_Update_User_Role(
+      p_store, admin_id, "member", id2) == AUTH_STORE_OK);
+
+  /* Re-promote so remaining tests can rely on admin being an admin. */
+  assert(Auth_Store_Update_User_Role(
+      p_store, admin_id, "admin", id2) == AUTH_STORE_OK);
+
+  puts("test_last_admin_protection: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 5. Username uniqueness                                               */
+/* ------------------------------------------------------------------ */
+
+static void test_username_uniqueness(Auth_Store *p_store)
+{
+  char id[37];
+  assert(Auth_Store_Create_User(
+      p_store, "UniqueUser", get_test_hash(), "member", FALSE, id) ==
+      AUTH_STORE_OK);
+
+  char id2[37];
+  assert(Auth_Store_Create_User(
+      p_store, "uniqueuser", get_test_hash(), "member", FALSE, id2) ==
+      AUTH_STORE_CONFLICT);
+
+  puts("test_username_uniqueness: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 6. User lookup                                                       */
+/* ------------------------------------------------------------------ */
+
+static void test_user_lookup(Auth_Store *p_store)
+{
+  char id[37];
+  assert(Auth_Store_Create_User(
+      p_store, "LookupUser", get_test_hash(), "member", FALSE, id) ==
+      AUTH_STORE_OK);
+
+  Auth_User_Record r;
+  assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK);
+  assert(strcmp(r.id, id) == 0);
+  assert(strcmp(r.username, "LookupUser") == 0);
+  assert(strcmp(r.normalized_username, "lookupuser") == 0);
+  assert(strcmp(r.role, "member") == 0);
+  assert(strcmp(r.status, "active") == 0);
+  assert(r.must_change_password == FALSE);
+
+  /* Not found */
+  assert(Auth_Store_Get_User(
+      p_store, "00000000-0000-0000-0000-000000000000", &r) ==
+      AUTH_STORE_NOT_FOUND);
+
+  /* Case-insensitive find by username */
+  Auth_User_Auth_Record auth_r;
+  assert(Auth_Store_Find_User_By_Username(
+      p_store, "  LOOKUPUSER  ", &auth_r) == AUTH_STORE_OK);
+  assert(strcmp(auth_r.user.id, id) == 0);
+  assert(strncmp(auth_r.password_hash, "zenbu-scrypt$", 13) == 0);
+
+  assert(Auth_Store_Find_User_By_Username(
+      p_store, "nobody", &auth_r) == AUTH_STORE_NOT_FOUND);
+
+  puts("test_user_lookup: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 7. Forced password flag                                              */
+/* ------------------------------------------------------------------ */
+
+static void test_forced_password_flag(Auth_Store *p_store)
+{
+  char id[37];
+  assert(Auth_Store_Create_User(
+      p_store, "ForcedPwUser", get_test_hash(), "member", TRUE, id) ==
+      AUTH_STORE_OK);
+
+  Auth_User_Record r;
+  assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK);
+  assert(r.must_change_password == TRUE);
+
+  assert(Auth_Store_Set_Must_Change_Password(
+      p_store, id, FALSE, NULL) == AUTH_STORE_OK);
+  assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK);
+  assert(r.must_change_password == FALSE);
+
+  assert(Auth_Store_Set_Must_Change_Password(
+      p_store, id, TRUE, NULL) == AUTH_STORE_OK);
+  assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK);
+  assert(r.must_change_password == TRUE);
+
+  /* Updating password clears the flag. */
+  assert(Auth_Store_Update_Password(
+      p_store, id, get_test_hash(), FALSE, NULL) == AUTH_STORE_OK);
+  assert(Auth_Store_Get_User(p_store, id, &r) == AUTH_STORE_OK);
+  assert(r.must_change_password == FALSE);
+
+  puts("test_forced_password_flag: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 8. Session create / resolve / touch / revoke                        */
+/* ------------------------------------------------------------------ */
+
+static void test_session_lifecycle(Auth_Store *p_store)
+{
+  char user_id[37];
+  assert(Auth_Store_Create_User(
+      p_store, "SessUser", get_test_hash(), "member", FALSE, user_id) ==
+      AUTH_STORE_OK);
+
+  const char *tok  = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+  const char *csrf = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+  int64 now      = 1700000000LL;
+  int64 idle_ttl = 3600;
+  int64 abs_ttl  = 86400;
+
+  Auth_Session_Record sess;
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, tok, csrf, idle_ttl, abs_ttl, now, &sess) ==
+      AUTH_STORE_OK);
+  assert(strcmp(sess.user_id, user_id) == 0);
+  assert(sess.created_at          == now);
+  assert(sess.idle_expires_at     == now + idle_ttl);
+  assert(sess.absolute_expires_at == now + abs_ttl);
+  assert(sess.password_changed_at_snapshot == 0);
+
+  Auth_Session_Record fs;
+  Auth_User_Record    fu;
+  int64 check = now + 60;
+
+  assert(Auth_Store_Find_Session(
+      p_store, tok, check, &fs, &fu) == AUTH_STORE_OK);
+  assert(strcmp(fu.id, user_id) == 0);
+
+  /* Touch extends idle expiry. */
+  assert(Auth_Store_Touch_Session(
+      p_store, tok, check, idle_ttl) == AUTH_STORE_OK);
+  assert(Auth_Store_Find_Session(
+      p_store, tok, check, &fs, &fu) == AUTH_STORE_OK);
+  assert(fs.idle_expires_at == check + idle_ttl);
+
+  /* Revoke. */
+  assert(Auth_Store_Revoke_Session(p_store, tok) == AUTH_STORE_OK);
+  assert(Auth_Store_Find_Session(
+      p_store, tok, check, &fs, &fu) == AUTH_STORE_REVOKED);
+
+  /* Unknown digest. */
+  const char *unk = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
+  assert(Auth_Store_Find_Session(
+      p_store, unk, check, &fs, &fu) == AUTH_STORE_NOT_FOUND);
+
+  puts("test_session_lifecycle: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 9. Stale password snapshot                                           */
+/* ------------------------------------------------------------------ */
+
+static void test_stale_password_snapshot(Auth_Store *p_store)
+{
+  char user_id[37];
+  assert(Auth_Store_Create_User(
+      p_store, "StaleUser", get_test_hash(), "member", FALSE, user_id) ==
+      AUTH_STORE_OK);
+
+  const char *tok  = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
+  const char *csrf = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
+  int64 now = 1700100000LL;
+
+  Auth_Session_Record sess;
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, tok, csrf, 3600, 86400, now, &sess) == AUTH_STORE_OK);
+  assert(sess.password_changed_at_snapshot == 0);
+
+  Auth_Session_Record fs;
+  Auth_User_Record    fu;
+  assert(Auth_Store_Find_Session(
+      p_store, tok, now + 10, &fs, &fu) == AUTH_STORE_OK);
+
+  assert(Auth_Store_Update_Password(
+      p_store, user_id, get_test_hash(), FALSE, NULL) == AUTH_STORE_OK);
+
+  assert(Auth_Store_Find_Session(
+      p_store, tok, now + 20, &fs, &fu) == AUTH_STORE_STALE_PASSWORD);
+
+  puts("test_stale_password_snapshot: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 10. Disabled user blocks session resolution and creation             */
+/* ------------------------------------------------------------------ */
+
+static void test_disabled_user(Auth_Store *p_store)
+{
+  char user_id[37];
+  assert(Auth_Store_Create_User(
+      p_store, "DisabledUser", get_test_hash(), "member", FALSE, user_id) ==
+      AUTH_STORE_OK);
+
+  const char *tok  = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
+  const char *csrf = "1111111111111111111111111111111111111111111111111111111111111111";
+  int64 now = 1700200000LL;
+
+  Auth_Session_Record sess;
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, tok, csrf, 3600, 86400, now, &sess) == AUTH_STORE_OK);
+
+  Auth_Session_Record fs;
+  Auth_User_Record    fu;
+  assert(Auth_Store_Find_Session(
+      p_store, tok, now + 10, &fs, &fu) == AUTH_STORE_OK);
+
+  /* Disable the member user (no last-admin protection applies). */
+  assert(Auth_Store_Update_User_Status(
+      p_store, user_id, "disabled", NULL) == AUTH_STORE_OK);
+
+  assert(Auth_Store_Find_Session(
+      p_store, tok, now + 20, &fs, &fu) == AUTH_STORE_USER_DISABLED);
+
+  /* New session for a disabled user must fail. */
+  const char *tok2  = "2222222222222222222222222222222222222222222222222222222222222222";
+  const char *csrf2 = "3333333333333333333333333333333333333333333333333333333333333333";
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, tok2, csrf2, 3600, 86400, now + 30, &sess) ==
+      AUTH_STORE_USER_DISABLED);
+
+  /* Re-enable. */
+  assert(Auth_Store_Update_User_Status(
+      p_store, user_id, "active", NULL) == AUTH_STORE_OK);
+  assert(Auth_Store_Find_Session(
+      p_store, tok, now + 30, &fs, &fu) == AUTH_STORE_OK);
+
+  puts("test_disabled_user: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 11. Password update revokes other sessions atomically               */
+/* ------------------------------------------------------------------ */
+
+static void test_password_update_revokes_others(Auth_Store *p_store)
+{
+  char user_id[37];
+  assert(Auth_Store_Create_User(
+      p_store, "RevokeOthers", get_test_hash(), "member", FALSE, user_id) ==
+      AUTH_STORE_OK);
+
+  int64 now = 1700300000LL;
+  const char *tok_keep = "4444444444444444444444444444444444444444444444444444444444444444";
+  const char *tok_rev1 = "5555555555555555555555555555555555555555555555555555555555555555";
+  const char *tok_rev2 = "6666666666666666666666666666666666666666666666666666666666666666";
+  const char *csrf_k   = "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111";
+  const char *csrf_1   = "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222";
+  const char *csrf_2   = "cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333cccc3333";
+
+  Auth_Session_Record sess;
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, tok_keep, csrf_k, 3600, 86400, now, &sess) ==
+      AUTH_STORE_OK);
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, tok_rev1, csrf_1, 3600, 86400, now, &sess) ==
+      AUTH_STORE_OK);
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, tok_rev2, csrf_2, 3600, 86400, now, &sess) ==
+      AUTH_STORE_OK);
+
+  /* Update password: keep tok_keep, revoke all others. */
+  assert(Auth_Store_Update_Password(
+      p_store, user_id, get_test_hash(), TRUE, tok_keep) == AUTH_STORE_OK);
+
+  int64 check = now + 60;
+  Auth_Session_Record fs;
+  Auth_User_Record    fu;
+
+  /* tok_keep is not revoked but snapshot is stale because password changed. */
+  assert(Auth_Store_Find_Session(
+      p_store, tok_keep, check, &fs, &fu) == AUTH_STORE_STALE_PASSWORD);
+
+  assert(Auth_Store_Find_Session(
+      p_store, tok_rev1, check, &fs, &fu) == AUTH_STORE_REVOKED);
+  assert(Auth_Store_Find_Session(
+      p_store, tok_rev2, check, &fs, &fu) == AUTH_STORE_REVOKED);
+
+  puts("test_password_update_revokes_others: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 12. Revoke_All_Sessions                                              */
+/* ------------------------------------------------------------------ */
+
+static void test_revoke_all_sessions(Auth_Store *p_store)
+{
+  char user_id[37];
+  assert(Auth_Store_Create_User(
+      p_store, "RevokeAll", get_test_hash(), "member", FALSE, user_id) ==
+      AUTH_STORE_OK);
+
+  int64 now = 1700400000LL;
+  const char *t1 = "7777777777777777777777777777777777777777777777777777777777777777";
+  const char *t2 = "8888888888888888888888888888888888888888888888888888888888888888";
+  const char *t3 = "9999999999999999999999999999999999999999999999999999999999999999";
+  const char *c1 = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
+  const char *c2 = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3";
+  const char *c3 = "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4";
+
+  Auth_Session_Record sess;
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, t1, c1, 3600, 86400, now, &sess) == AUTH_STORE_OK);
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, t2, c2, 3600, 86400, now, &sess) == AUTH_STORE_OK);
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, t3, c3, 3600, 86400, now, &sess) == AUTH_STORE_OK);
+
+  /* Revoke all except t2. */
+  assert(Auth_Store_Revoke_All_Sessions(p_store, user_id, t2) == AUTH_STORE_OK);
+
+  int64 check = now + 60;
+  Auth_Session_Record fs;
+  Auth_User_Record    fu;
+  assert(Auth_Store_Find_Session(p_store, t1, check, &fs, &fu) == AUTH_STORE_REVOKED);
+  assert(Auth_Store_Find_Session(p_store, t2, check, &fs, &fu) == AUTH_STORE_OK);
+  assert(Auth_Store_Find_Session(p_store, t3, check, &fs, &fu) == AUTH_STORE_REVOKED);
+
+  puts("test_revoke_all_sessions: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 13. Expired session                                                  */
+/* ------------------------------------------------------------------ */
+
+static void test_expired_session(Auth_Store *p_store)
+{
+  char user_id[37];
+  assert(Auth_Store_Create_User(
+      p_store, "ExpiredUser", get_test_hash(), "member", FALSE, user_id) ==
+      AUTH_STORE_OK);
+
+  const char *tok  = "abababababababababababababababababababababababababababababababab";
+  const char *csrf = "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd";
+  int64 now = 1700500000LL;
+
+  Auth_Session_Record sess;
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, tok, csrf, 100, 200, now, &sess) == AUTH_STORE_OK);
+
+  Auth_Session_Record fs;
+  Auth_User_Record    fu;
+
+  assert(Auth_Store_Find_Session(
+      p_store, tok, now + 50, &fs, &fu) == AUTH_STORE_OK);
+
+  assert(Auth_Store_Find_Session(
+      p_store, tok, now + 110, &fs, &fu) == AUTH_STORE_EXPIRED);
+
+  assert(Auth_Store_Find_Session(
+      p_store, tok, now + 201, &fs, &fu) == AUTH_STORE_EXPIRED);
+
+  puts("test_expired_session: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 14. Guest identity persistence and no raw IP storage                */
+/* ------------------------------------------------------------------ */
+
+static void test_guest_identity(Auth_Store *p_store, const char *db_path)
+{
+  uint8 secret[AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES];
+  memset(secret, 0xab, sizeof(secret));
+  const char *raw_ip = "203.0.113.42"; /* TEST-NET-3, never stored */
+
+  char ip_digest[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE];
+  assert(Auth_Crypto_IP_Binding_Digest(
+      secret, sizeof(secret), raw_ip,
+      ip_digest, sizeof(ip_digest)) == AUTH_CRYPTO_OK);
+
+  char guest_id[37];
+  assert(test__make_uuid(guest_id));
+  int64 now = 1700600000LL;
+  int64 exp = now + 86400;
+
+  Auth_Guest_Identity_Record g;
+  assert(Auth_Store_Upsert_Guest_Identity(
+      p_store, guest_id, ip_digest, exp, &g) == AUTH_STORE_OK);
+  assert(strcmp(g.id, guest_id) == 0);
+  assert(g.expires_at == exp);
+
+  /* Find within expiry. */
+  Auth_Guest_Identity_Record g2;
+  assert(Auth_Store_Find_Guest_Identity(
+      p_store, guest_id, now + 60, &g2) == AUTH_STORE_OK);
+  assert(strcmp(g2.id, guest_id) == 0);
+
+  /* Find after expiry. */
+  assert(Auth_Store_Find_Guest_Identity(
+      p_store, guest_id, exp + 1, &g2) == AUTH_STORE_EXPIRED);
+
+  /* Upsert refreshes expiry without losing the identity row. */
+  int64 new_exp = exp + 86400;
+  assert(Auth_Store_Upsert_Guest_Identity(
+      p_store, guest_id, ip_digest, new_exp, &g) == AUTH_STORE_OK);
+  assert(g.expires_at == new_exp);
+  assert(Auth_Store_Find_Guest_Identity(
+      p_store, guest_id, exp + 1, &g2) == AUTH_STORE_OK);
+
+  /* Unknown guest. */
+  assert(Auth_Store_Find_Guest_Identity(
+      p_store, "00000000-0000-4000-8000-000000000000",
+      now, &g2) == AUTH_STORE_NOT_FOUND);
+
+  /*
+   * Verify raw IP is NOT stored in the database: open a second connection
+   * and inspect the ip_binding_digest column directly.
+   */
+  Dowa_Arena       *p_arena = Dowa_Arena_Create(4096);
+  assert(p_arena);
+  Deita_Connection *p_conn = Deita_Connection_Create(
+      DEITA_DATABASE_TYPE_SQLITE3, db_path);
+  assert(p_conn);
+
+  const char       *sel_params[] = {guest_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_conn,
+      "SELECT ip_binding_digest FROM guest_identities WHERE id = ?",
+      1, sel_params, p_arena);
+  assert(p_result);
+  assert(Deita_Result_Set_Next(p_result));
+  const char *stored = Deita_Result_Set_Get_Text(p_result, 0);
+  assert(stored != NULL);
+  assert(strcmp(stored, ip_digest) == 0);   /* HMAC digest is stored */
+  assert(strstr(stored, raw_ip) == NULL);   /* raw IP is NOT stored  */
+  Deita_Result_Set_Free(p_result);
+  Deita_Connection_Close(p_conn);
+  Dowa_Arena_Free(p_arena);
+
+  puts("test_guest_identity: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 16. Rotate_Session                                                   */
+/* ------------------------------------------------------------------ */
+
+static void test_rotate_session(Auth_Store *p_store)
+{
+  char user_id[37];
+  assert(Auth_Store_Create_User(
+      p_store, "RotateUser", get_test_hash(), "member", FALSE, user_id) ==
+      AUTH_STORE_OK);
+
+  int64 now = 1700400000LL;
+  const char *old_tok = "e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1";
+  const char *old_csrf = "f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2";
+  const char *new_tok = "a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3";
+  const char *new_csrf = "b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4";
+
+  Auth_Session_Record sess;
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, old_tok, old_csrf, 3600, 86400, now, &sess) ==
+      AUTH_STORE_OK);
+
+  /* Rotate: new session created, old revoked atomically */
+  Auth_Session_Record new_sess;
+  assert(Auth_Store_Rotate_Session(
+      p_store, user_id, old_tok, new_tok, new_csrf,
+      3600, 86400, now + 10, &new_sess) == AUTH_STORE_OK);
+
+  int64 check = now + 60;
+  Auth_Session_Record fs;
+  Auth_User_Record    fu;
+
+  /* Old session must be revoked */
+  assert(Auth_Store_Find_Session(p_store, old_tok, check, &fs, &fu) ==
+         AUTH_STORE_REVOKED);
+
+  /* New session must be valid */
+  assert(Auth_Store_Find_Session(p_store, new_tok, check, &fs, &fu) ==
+         AUTH_STORE_OK);
+  assert(strcmp(fs.user_id, user_id) == 0);
+
+  /* Rotate again with already-revoked old token must still succeed
+   * (idempotent revocation) */
+  const char *new_tok2 = "c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5";
+  const char *new_csrf2 = "d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6";
+  Auth_Session_Record sess2;
+  assert(Auth_Store_Rotate_Session(
+      p_store, user_id, old_tok, new_tok2, new_csrf2,
+      3600, 86400, now + 20, &sess2) == AUTH_STORE_OK);
+  assert(Auth_Store_Find_Session(p_store, new_tok2, check, &fs, &fu) ==
+         AUTH_STORE_OK);
+
+  puts("test_rotate_session: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 17. Compare-and-swap session creation                                */
+/* ------------------------------------------------------------------ */
+
+static void test_create_session_cas(Auth_Store *p_store)
+{
+  char first_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  char second_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  char reset_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  make_test_hash("cas-first-password", first_hash);
+  make_test_hash("cas-second-password", second_hash);
+  make_test_hash("cas-reset-password", reset_hash);
+
+  char user_id[37];
+  assert(Auth_Store_Create_User(
+      p_store, "CasSessionUser", first_hash, "member", FALSE, user_id) ==
+      AUTH_STORE_OK);
+
+  const char *token1 =
+      "1010101010101010101010101010101010101010101010101010101010101010";
+  const char *csrf1 =
+      "2020202020202020202020202020202020202020202020202020202020202020";
+  const char *token2 =
+      "3030303030303030303030303030303030303030303030303030303030303030";
+  const char *csrf2 =
+      "4040404040404040404040404040404040404040404040404040404040404040";
+  const char *token3 =
+      "5050505050505050505050505050505050505050505050505050505050505050";
+  const char *csrf3 =
+      "6060606060606060606060606060606060606060606060606060606060606060";
+  const char *token4 =
+      "7070707070707070707070707070707070707070707070707070707070707070";
+  const char *csrf4 =
+      "8080808080808080808080808080808080808080808080808080808080808080";
+  const char *disabled_token =
+      "9090909090909090909090909090909090909090909090909090909090909090";
+  const char *disabled_csrf =
+      "a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0";
+  int64 now = 1800000000LL;
+  Auth_Session_Record session;
+
+  assert(Auth_Store_Create_Session_CAS(
+      p_store, user_id, first_hash, token1, csrf1,
+      3600, 86400, now, &session) == AUTH_STORE_OK);
+
+  assert(Auth_Store_Update_User_Status(
+      p_store, user_id, "disabled", NULL) == AUTH_STORE_OK);
+  assert(Auth_Store_Create_Session_CAS(
+      p_store, user_id, first_hash, disabled_token, disabled_csrf,
+      3600, 86400, now + 1, &session) == AUTH_STORE_USER_DISABLED);
+  assert(Auth_Store_Update_User_Status(
+      p_store, user_id, "active", NULL) == AUTH_STORE_OK);
+
+  Auth_Session_Record found_session;
+  Auth_User_Record found_user;
+  assert(Auth_Store_Find_Session(
+      p_store, disabled_token, now + 2, &found_session, &found_user) ==
+      AUTH_STORE_NOT_FOUND);
+
+  assert(Auth_Store_Update_Password(
+      p_store, user_id, second_hash, FALSE, NULL) == AUTH_STORE_OK);
+  assert(Auth_Store_Create_Session_CAS(
+      p_store, user_id, first_hash, token2, csrf2,
+      3600, 86400, now + 10, &session) == AUTH_STORE_STALE_PASSWORD);
+
+  assert(Auth_Store_Find_Session(
+      p_store, token2, now + 20, &found_session, &found_user) ==
+      AUTH_STORE_NOT_FOUND);
+
+  assert(Auth_Store_Admin_Reset_Password(
+      p_store, user_id, reset_hash, NULL) == AUTH_STORE_OK);
+  assert(Auth_Store_Create_Session_CAS(
+      p_store, user_id, second_hash, token3, csrf3,
+      3600, 86400, now + 20, &session) == AUTH_STORE_STALE_PASSWORD);
+  assert(Auth_Store_Find_Session(
+      p_store, token3, now + 30, &found_session, &found_user) ==
+      AUTH_STORE_NOT_FOUND);
+
+  assert(Auth_Store_Create_Session_CAS(
+      p_store, user_id, reset_hash, token4, csrf4,
+      3600, 86400, now + 30, &session) == AUTH_STORE_OK);
+  assert(Auth_Store_Find_Session(
+      p_store, token4, now + 40, &found_session, &found_user) ==
+      AUTH_STORE_OK);
+
+  memset(first_hash, 0, sizeof(first_hash));
+  memset(second_hash, 0, sizeof(second_hash));
+  memset(reset_hash, 0, sizeof(reset_hash));
+  puts("test_create_session_cas: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 18. Atomic self-service password change                              */
+/* ------------------------------------------------------------------ */
+
+static void test_self_change_password(Auth_Store *p_store)
+{
+  char old_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  char new_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  char unused_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  make_test_hash("self-change-old", old_hash);
+  make_test_hash("self-change-new", new_hash);
+  make_test_hash("self-change-unused", unused_hash);
+
+  char user_id[37];
+  assert(Auth_Store_Create_User(
+      p_store, "SelfChangeUser", old_hash, "member", TRUE, user_id) ==
+      AUTH_STORE_OK);
+
+  const char *old_token1 =
+      "1111222211112222111122221111222211112222111122221111222211112222";
+  const char *old_csrf1 =
+      "2222333322223333222233332222333322223333222233332222333322223333";
+  const char *old_token2 =
+      "3333444433334444333344443333444433334444333344443333444433334444";
+  const char *old_csrf2 =
+      "4444555544445555444455554444555544445555444455554444555544445555";
+  const char *new_token =
+      "5555666655556666555566665555666655556666555566665555666655556666";
+  const char *new_csrf =
+      "6666777766667777666677776666777766667777666677776666777766667777";
+  const char *failed_token =
+      "7777888877778888777788887777888877778888777788887777888877778888";
+  const char *failed_csrf =
+      "8888999988889999888899998888999988889999888899998888999988889999";
+  int64 now = 1810000000LL;
+  Auth_Session_Record session;
+
+  assert(Auth_Store_Create_Session_CAS(
+      p_store, user_id, old_hash, old_token1, old_csrf1,
+      3600, 86400, now, &session) == AUTH_STORE_OK);
+  assert(Auth_Store_Create_Session_CAS(
+      p_store, user_id, old_hash, old_token2, old_csrf2,
+      3600, 86400, now, &session) == AUTH_STORE_OK);
+
+  assert(Auth_Store_Self_Change_Password(
+      p_store, user_id, old_hash, new_hash, new_token, new_csrf,
+      3600, 86400, now + 100, &session) == AUTH_STORE_OK);
+  assert(session.password_changed_at_snapshot == now + 100);
+
+  Auth_Session_Record found_session;
+  Auth_User_Record found_user;
+  assert(Auth_Store_Find_Session(
+      p_store, old_token1, now + 101, &found_session, &found_user) ==
+      AUTH_STORE_REVOKED);
+  assert(Auth_Store_Find_Session(
+      p_store, old_token2, now + 101, &found_session, &found_user) ==
+      AUTH_STORE_REVOKED);
+  assert(Auth_Store_Find_Session(
+      p_store, new_token, now + 101, &found_session, &found_user) ==
+      AUTH_STORE_OK);
+  assert(found_user.must_change_password == FALSE);
+  assert(found_user.password_changed_at == now + 100);
+
+  Auth_User_Auth_Record auth_record;
+  assert(Auth_Store_Find_User_By_Username(
+      p_store, "SelfChangeUser", &auth_record) == AUTH_STORE_OK);
+  assert(strcmp(auth_record.password_hash, new_hash) == 0);
+  memset(&auth_record, 0, sizeof(auth_record));
+
+  assert(Auth_Store_Self_Change_Password(
+      p_store, user_id, old_hash, unused_hash, failed_token, failed_csrf,
+      3600, 86400, now + 200, &session) == AUTH_STORE_STALE_PASSWORD);
+  assert(Auth_Store_Find_Session(
+      p_store, failed_token, now + 201, &found_session, &found_user) ==
+      AUTH_STORE_NOT_FOUND);
+  assert(Auth_Store_Find_Session(
+      p_store, new_token, now + 201, &found_session, &found_user) ==
+      AUTH_STORE_OK);
+  assert(Auth_Store_Find_User_By_Username(
+      p_store, "SelfChangeUser", &auth_record) == AUTH_STORE_OK);
+  assert(strcmp(auth_record.password_hash, new_hash) == 0);
+
+  memset(&auth_record, 0, sizeof(auth_record));
+  memset(old_hash, 0, sizeof(old_hash));
+  memset(new_hash, 0, sizeof(new_hash));
+  memset(unused_hash, 0, sizeof(unused_hash));
+  puts("test_self_change_password: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 19. Audited and session-revoking admin operations                    */
+/* ------------------------------------------------------------------ */
+
+static void test_audited_admin_operations(
+    Auth_Store *p_store,
+    const char *db_path)
+{
+  char user_id[37];
+  const char *actor_id = "00000000-0000-4000-8000-000000000019";
+  assert(Auth_Store_Create_User_Audited(
+      p_store, "AuditedUser", get_test_hash(), "member", FALSE,
+      actor_id, user_id) == AUTH_STORE_OK);
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
+  assert(p_arena);
+  Deita_Connection *p_conn = Deita_Connection_Create(
+      DEITA_DATABASE_TYPE_SQLITE3, db_path);
+  assert(p_conn);
+  const char *audit_params[] = {user_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_conn,
+      "SELECT actor_user_id, action, detail FROM admin_audit_log"
+      "  WHERE target_user_id = ? ORDER BY id DESC LIMIT 1",
+      1, audit_params, p_arena);
+  assert(p_result);
+  assert(Deita_Result_Set_Next(p_result));
+  assert(strcmp(Deita_Result_Set_Get_Text(p_result, 0), actor_id) == 0);
+  assert(strcmp(Deita_Result_Set_Get_Text(p_result, 1),
+                "admin_user_created") == 0);
+  assert(strcmp(Deita_Result_Set_Get_Text(p_result, 2), "member") == 0);
+  assert(strstr(Deita_Result_Set_Get_Text(p_result, 2), "zenbu-scrypt") == NULL);
+  Deita_Result_Set_Free(p_result);
+  Deita_Connection_Close(p_conn);
+  Dowa_Arena_Free(p_arena);
+
+  const char *token1 =
+      "9191919191919191919191919191919191919191919191919191919191919191";
+  const char *csrf1 =
+      "9292929292929292929292929292929292929292929292929292929292929292";
+  const char *token2 =
+      "9393939393939393939393939393939393939393939393939393939393939393";
+  const char *csrf2 =
+      "9494949494949494949494949494949494949494949494949494949494949494";
+  const char *token3 =
+      "9595959595959595959595959595959595959595959595959595959595959595";
+  const char *csrf3 =
+      "9696969696969696969696969696969696969696969696969696969696969696";
+  int64 now = 1820000000LL;
+  Auth_Session_Record session;
+  Auth_Session_Record found_session;
+  Auth_User_Record found_user;
+
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, token1, csrf1, 3600, 86400, now, &session) ==
+      AUTH_STORE_OK);
+  assert(Auth_Store_Enable_User(p_store, user_id, actor_id) == AUTH_STORE_OK);
+  assert(Auth_Store_Find_Session(
+      p_store, token1, now + 1, &found_session, &found_user) == AUTH_STORE_OK);
+
+  assert(Auth_Store_Disable_User_And_Revoke_Sessions(
+      p_store, user_id, actor_id) == AUTH_STORE_OK);
+  assert(Auth_Store_Find_Session(
+      p_store, token1, now + 2, &found_session, &found_user) ==
+      AUTH_STORE_REVOKED);
+  assert(Auth_Store_Enable_User(p_store, user_id, actor_id) == AUTH_STORE_OK);
+  assert(Auth_Store_Find_Session(
+      p_store, token1, now + 3, &found_session, &found_user) ==
+      AUTH_STORE_REVOKED);
+
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, token2, csrf2, 3600, 86400, now + 4, &session) ==
+      AUTH_STORE_OK);
+  assert(Auth_Store_Update_Role_And_Revoke_Sessions(
+      p_store, user_id, "admin", actor_id) == AUTH_STORE_OK);
+  assert(Auth_Store_Find_Session(
+      p_store, token2, now + 5, &found_session, &found_user) ==
+      AUTH_STORE_REVOKED);
+  assert(Auth_Store_Get_User(p_store, user_id, &found_user) == AUTH_STORE_OK);
+  assert(strcmp(found_user.role, "admin") == 0);
+
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, token3, csrf3, 3600, 86400, now + 6, &session) ==
+      AUTH_STORE_OK);
+  assert(Auth_Store_Revoke_All_Sessions_Audited(
+      p_store, user_id, NULL, actor_id) == AUTH_STORE_OK);
+  assert(Auth_Store_Find_Session(
+      p_store, token3, now + 7, &found_session, &found_user) ==
+      AUTH_STORE_REVOKED);
+  assert(Auth_Store_Enable_User(
+      p_store, "00000000-0000-4000-8000-000000000000", actor_id) ==
+      AUTH_STORE_NOT_FOUND);
+
+  puts("test_audited_admin_operations: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* 20. Audit failures roll back transactional mutations                 */
+/* ------------------------------------------------------------------ */
+
+static void test_audit_failure_rollback(
+    Auth_Store *p_store,
+    const char *db_path)
+{
+  char reset_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  make_test_hash("audit-reset-password", reset_hash);
+
+  char user_id[37];
+  assert(Auth_Store_Create_User(
+      p_store, "AuditRollbackUser", get_test_hash(), "member", FALSE,
+      user_id) == AUTH_STORE_OK);
+
+  const char *token =
+      "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1";
+  const char *csrf =
+      "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2";
+  int64 now = 1830000000LL;
+  Auth_Session_Record session;
+  assert(Auth_Store_Create_Session(
+      p_store, user_id, token, csrf, 3600, 86400, now, &session) ==
+      AUTH_STORE_OK);
+
+  Deita_Connection *p_conn = Deita_Connection_Create(
+      DEITA_DATABASE_TYPE_SQLITE3, db_path);
+  assert(p_conn);
+  assert(Deita_Query_Execute_Update(
+      p_conn, "UPDATE users SET role = 'member' WHERE role = 'admin'") >= 0);
+  assert(Deita_Query_Execute_Update(
+      p_conn,
+      "CREATE TRIGGER fail_auth_audit BEFORE INSERT ON admin_audit_log"
+      " BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END") >= 0);
+  Deita_Connection_Close(p_conn);
+
+  assert(Auth_Store_Set_Must_Change_Password(
+      p_store, user_id, TRUE, NULL) == AUTH_STORE_ERROR);
+  Auth_User_Record user;
+  assert(Auth_Store_Get_User(p_store, user_id, &user) == AUTH_STORE_OK);
+  assert(user.must_change_password == FALSE);
+
+  Auth_Session_Record found_session;
+  Auth_User_Record found_user;
+  assert(Auth_Store_Update_User_Status(
+      p_store, user_id, "disabled", NULL) == AUTH_STORE_ERROR);
+  assert(Auth_Store_Get_User(p_store, user_id, &user) == AUTH_STORE_OK);
+  assert(strcmp(user.status, "active") == 0);
+  assert(Auth_Store_Find_Session(
+      p_store, token, now + 1, &found_session, &found_user) == AUTH_STORE_OK);
+
+  assert(Auth_Store_Update_User_Role(
+      p_store, user_id, "admin", NULL) == AUTH_STORE_ERROR);
+  assert(Auth_Store_Get_User(p_store, user_id, &user) == AUTH_STORE_OK);
+  assert(strcmp(user.role, "member") == 0);
+
+  assert(Auth_Store_Disable_User_And_Revoke_Sessions(
+      p_store, user_id, NULL) == AUTH_STORE_ERROR);
+  assert(Auth_Store_Update_Role_And_Revoke_Sessions(
+      p_store, user_id, "admin", NULL) == AUTH_STORE_ERROR);
+  assert(Auth_Store_Revoke_All_Sessions_Audited(
+      p_store, user_id, NULL, NULL) == AUTH_STORE_ERROR);
+  assert(Auth_Store_Find_Session(
+      p_store, token, now + 2, &found_session, &found_user) == AUTH_STORE_OK);
+
+  assert(Auth_Store_Admin_Reset_Password(
+      p_store, user_id, reset_hash, NULL) == AUTH_STORE_ERROR);
+  Auth_User_Auth_Record auth_record;
+  assert(Auth_Store_Find_User_By_Username(
+      p_store, "AuditRollbackUser", &auth_record) == AUTH_STORE_OK);
+  assert(strcmp(auth_record.password_hash, get_test_hash()) == 0);
+  assert(auth_record.user.must_change_password == FALSE);
+  memset(&auth_record, 0, sizeof(auth_record));
+  assert(Auth_Store_Find_Session(
+      p_store, token, now + 3, &found_session, &found_user) == AUTH_STORE_OK);
+
+  Auth_Store_Bootstrap_Result bootstrap_result;
+  char bootstrap_id[37];
+  assert(Auth_Store_Bootstrap_Admin(
+      p_store, "AuditBootstrapRollback", get_test_hash(),
+      &bootstrap_result, bootstrap_id) == AUTH_STORE_ERROR);
+  assert(Auth_Store_Find_User_By_Username(
+      p_store, "AuditBootstrapRollback", &auth_record) == AUTH_STORE_NOT_FOUND);
+
+  assert(Auth_Store_Insert_Audit_Log(
+      p_store, NULL, "forced_failure", user_id, NULL) == AUTH_STORE_ERROR);
+
+  char failed_id[37];
+  assert(Auth_Store_Create_User_Audited(
+      p_store, "AuditCreateRollback", get_test_hash(), "member", FALSE,
+      NULL, failed_id) == AUTH_STORE_ERROR);
+  assert(Auth_Store_Find_User_By_Username(
+      p_store, "AuditCreateRollback", &auth_record) == AUTH_STORE_NOT_FOUND);
+
+  p_conn = Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, db_path);
+  assert(p_conn);
+  assert(Deita_Query_Execute_Update(
+      p_conn, "DROP TRIGGER fail_auth_audit") >= 0);
+  Deita_Connection_Close(p_conn);
+
+  memset(reset_hash, 0, sizeof(reset_hash));
+  puts("test_audit_failure_rollback: PASS");
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Guest quota tests                                                     */
+/* ------------------------------------------------------------------ */
+
+/* Create a guest identity row (required as FK parent). */
+static void make_guest(Auth_Store *p_store, const char *guest_id)
+{
+  uint8 secret[AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES];
+  memset(secret, 0xab, sizeof(secret));
+  char ip_digest[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE];
+  assert(Auth_Crypto_IP_Binding_Digest(
+      secret, sizeof(secret), "10.0.0.1",
+      ip_digest, sizeof(ip_digest)) == AUTH_CRYPTO_OK);
+  Auth_Guest_Identity_Record g;
+  assert(Auth_Store_Upsert_Guest_Identity(
+      p_store, guest_id, ip_digest,
+      2000000000LL, &g) == AUTH_STORE_OK);
+}
+
+static void test_guest_quota(Auth_Store *p_store)
+{
+  char guest_id[37];
+  assert(test__make_uuid(guest_id));
+  make_guest(p_store, guest_id);
+
+  int64 window_start = 1700524800LL; /* UTC midnight 2023-11-21, used as window ID */
+  int64 expires      = (int64)time(NULL) + 7200LL; /* 2 h from now — always future */
+  const int64 turns_limit  = 3;
+  const int64 tokens_limit = 1000;
+  const int64 req_tokens   = 200;
+
+  /* 1. Initial usage is zero. */
+  {
+    Auth_Store_Guest_Usage u;
+    assert(Auth_Store_Guest_Get_Usage(
+        p_store, guest_id, window_start, &u) == AUTH_STORE_OK);
+    assert(u.turns_used == 0);
+    assert(u.output_tokens_used == 0);
+    assert(u.output_tokens_reserved == 0);
+  }
+  puts("  guest_quota/initial_usage: PASS");
+
+  /* 2. Reserve 3 turns; fourth must be TURNS_EXHAUSTED. */
+  {
+    char req1[37], req2[37], req3[37];
+    assert(test__make_uuid(req1));
+    assert(test__make_uuid(req2));
+    assert(test__make_uuid(req3));
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest_id, req1, window_start,
+        req_tokens, turns_limit, tokens_limit,
+        expires) == AUTH_STORE_GUEST_QUOTA_OK);
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest_id, req2, window_start,
+        req_tokens, turns_limit, tokens_limit,
+        expires) == AUTH_STORE_GUEST_QUOTA_OK);
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest_id, req3, window_start,
+        req_tokens, turns_limit, tokens_limit,
+        expires) == AUTH_STORE_GUEST_QUOTA_OK);
+
+    /* Check state: 3 turns used, 600 tokens reserved. */
+    Auth_Store_Guest_Usage u;
+    assert(Auth_Store_Guest_Get_Usage(
+        p_store, guest_id, window_start, &u) == AUTH_STORE_OK);
+    assert(u.turns_used == 3);
+    assert(u.output_tokens_used == 0);
+    assert(u.output_tokens_reserved == req_tokens * 3);
+
+    /* Fourth reservation: turns exhausted. */
+    char req4[37];
+    assert(test__make_uuid(req4));
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest_id, req4, window_start,
+        req_tokens, turns_limit, tokens_limit,
+        expires) == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED);
+    puts("  guest_quota/turn_exhaustion: PASS");
+
+    /* Release all 3 reservations (keeps turn charge). */
+    assert(Auth_Store_Guest_Release(p_store, req1) == AUTH_STORE_OK);
+    assert(Auth_Store_Guest_Release(p_store, req2) == AUTH_STORE_OK);
+    assert(Auth_Store_Guest_Release(p_store, req3) == AUTH_STORE_OK);
+
+    /* After release: turns still charged, tokens freed. */
+    assert(Auth_Store_Guest_Get_Usage(
+        p_store, guest_id, window_start, &u) == AUTH_STORE_OK);
+    assert(u.turns_used == 3);
+    assert(u.output_tokens_reserved == 0);
+    puts("  guest_quota/release_retains_turns: PASS");
+  }
+
+  /* 3. Token exhaustion on a fresh window. */
+  {
+    char guest2[37];
+    assert(test__make_uuid(guest2));
+    make_guest(p_store, guest2);
+    int64 win2 = window_start + 86400LL;
+    const int64 small_limit = 250;
+
+    char r1[37], r2[37];
+    assert(test__make_uuid(r1));
+    assert(test__make_uuid(r2));
+
+    /* Reserve 200; only 250 total → second 200 would overflow. */
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest2, r1, win2,
+        200, 10, small_limit, expires) == AUTH_STORE_GUEST_QUOTA_OK);
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest2, r2, win2,
+        200, 10, small_limit, expires) ==
+        AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED);
+    puts("  guest_quota/token_exhaustion: PASS");
+
+    /* Reconcile r1 with 150 actual (< 200 reserved). */
+    assert(Auth_Store_Guest_Reconcile(p_store, r1, 150) == AUTH_STORE_OK);
+    Auth_Store_Guest_Usage u;
+    assert(Auth_Store_Guest_Get_Usage(
+        p_store, guest2, win2, &u) == AUTH_STORE_OK);
+    assert(u.output_tokens_used == 150);
+    assert(u.output_tokens_reserved == 0);
+    puts("  guest_quota/reconcile_actual_lt_reserved: PASS");
+
+    /* Reconcile with actual > reserved and charge the provider's actual use. */
+    char r3[37];
+    assert(test__make_uuid(r3));
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest2, r3, win2,
+        50, 10, small_limit, expires) == AUTH_STORE_GUEST_QUOTA_OK);
+    assert(Auth_Store_Guest_Reconcile(p_store, r3, 9999) == AUTH_STORE_OK);
+    assert(Auth_Store_Guest_Get_Usage(
+        p_store, guest2, win2, &u) == AUTH_STORE_OK);
+    assert(u.output_tokens_used == 150 + 9999);
+    puts("  guest_quota/reconcile_oversize_actual: PASS");
+  }
+
+  /* 4. Idempotent reconcile/release. */
+  {
+    char guest3[37];
+    assert(test__make_uuid(guest3));
+    make_guest(p_store, guest3);
+    int64 win3 = window_start + 2 * 86400LL;
+    char rid[37];
+    assert(test__make_uuid(rid));
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest3, rid, win3,
+        100, 5, 500, expires) == AUTH_STORE_GUEST_QUOTA_OK);
+    assert(Auth_Store_Guest_Reconcile(p_store, rid, 80) == AUTH_STORE_OK);
+    /* Second reconcile on same request_id: idempotent OK. */
+    assert(Auth_Store_Guest_Reconcile(p_store, rid, 80) == AUTH_STORE_OK);
+    /* Release after reconcile: idempotent OK. */
+    assert(Auth_Store_Guest_Release(p_store, rid) == AUTH_STORE_OK);
+    puts("  guest_quota/idempotent_reconcile_release: PASS");
+  }
+
+  /* 5. Concurrent reservation invariant: two reservations, check totals. */
+  {
+    char guest4[37];
+    assert(test__make_uuid(guest4));
+    make_guest(p_store, guest4);
+    int64 win4 = window_start + 3 * 86400LL;
+    char ra[37], rb[37];
+    assert(test__make_uuid(ra));
+    assert(test__make_uuid(rb));
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest4, ra, win4, 300, 5, 500, expires) ==
+        AUTH_STORE_GUEST_QUOTA_OK);
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest4, rb, win4, 300, 5, 500, expires) ==
+        AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED);
+    /* 300 used, 300 reserved → 600 total, limit 500 → second fails. */
+    Auth_Store_Guest_Usage u;
+    assert(Auth_Store_Guest_Get_Usage(
+        p_store, guest4, win4, &u) == AUTH_STORE_OK);
+    /* invariant: used + reserved <= limit */
+    assert(u.output_tokens_used + u.output_tokens_reserved <= 500);
+    puts("  guest_quota/concurrent_invariant: PASS");
+    assert(Auth_Store_Guest_Release(p_store, ra) == AUTH_STORE_OK);
+  }
+
+  /* 6. Clear all reservations (login transfer). */
+  {
+    char guest5[37];
+    assert(test__make_uuid(guest5));
+    make_guest(p_store, guest5);
+    int64 win5 = window_start + 4 * 86400LL;
+    char rc[37], rd[37];
+    assert(test__make_uuid(rc));
+    assert(test__make_uuid(rd));
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest5, rc, win5, 100, 5, 500, expires) ==
+        AUTH_STORE_GUEST_QUOTA_OK);
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest5, rd, win5, 100, 5, 500, expires) ==
+        AUTH_STORE_GUEST_QUOTA_OK);
+    assert(Auth_Store_Guest_Clear_Reservations(
+        p_store, guest5) == AUTH_STORE_OK);
+    Auth_Store_Guest_Usage u;
+    assert(Auth_Store_Guest_Get_Usage(
+        p_store, guest5, win5, &u) == AUTH_STORE_OK);
+    assert(u.output_tokens_reserved == 0);
+    assert(u.turns_used == 2); /* turns kept */
+    puts("  guest_quota/clear_reservations: PASS");
+  }
+
+  /* 7. UTC rollover: windows are independent. */
+  {
+    char guest6[37];
+    assert(test__make_uuid(guest6));
+    make_guest(p_store, guest6);
+    int64 winA = window_start + 5 * 86400LL;
+    int64 winB = winA + 86400LL;
+    char re[37], rf[37];
+    assert(test__make_uuid(re));
+    assert(test__make_uuid(rf));
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest6, re, winA, 100, 2, 200, expires) ==
+        AUTH_STORE_GUEST_QUOTA_OK);
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest6, rf, winA, 100, 2, 200, expires) ==
+        AUTH_STORE_GUEST_QUOTA_OK);
+    /* winA full; winB is a fresh window. */
+    char rg[37];
+    assert(test__make_uuid(rg));
+    assert(Auth_Store_Guest_Reserve(
+        p_store, guest6, rg, winB, 100, 2, 200, expires) ==
+        AUTH_STORE_GUEST_QUOTA_OK);
+    Auth_Store_Guest_Usage uB;
+    assert(Auth_Store_Guest_Get_Usage(
+        p_store, guest6, winB, &uB) == AUTH_STORE_OK);
+    assert(uB.turns_used == 1);
+    puts("  guest_quota/utc_rollover: PASS");
+    assert(Auth_Store_Guest_Release(p_store, re) == AUTH_STORE_OK);
+    assert(Auth_Store_Guest_Release(p_store, rf) == AUTH_STORE_OK);
+    assert(Auth_Store_Guest_Release(p_store, rg) == AUTH_STORE_OK);
+  }
+
+  puts("test_guest_quota: PASS");
+}/* ------------------------------------------------------------------ */
+/* 19. Migration v2 preserves legacy guest_usage.count in turns_used    */
+/* ------------------------------------------------------------------ */
+
+/*
+ * Build a genuine v1-only database using a raw Deita connection (no
+ * Auth_Store_Create) so that the v2 migration has not yet run.  Insert
+ * a legacy guest_usage row with count=7 and no turns_used column, then
+ * open the database through Auth_Store_Create — which applies v2 —
+ * and verify that turns_used is backfilled from count.
+ */
+static void test_migration_v2_legacy_backfill(const char *db_path)
+{
+  (void)db_path; /* we use our own temp file */
+
+  char legacy_db[] = "/tmp/zenbu-auth-legacy-XXXXXX";
+  int  fd          = mkstemp(legacy_db);
+  assert(fd >= 0);
+  close(fd);
+
+  /* Build the v1-only schema directly. */
+  Deita_Connection *p_conn = Deita_Connection_Create(
+      DEITA_DATABASE_TYPE_SQLITE3, legacy_db);
+  assert(p_conn);
+  Deita_Query_Execute_Update(p_conn,
+      "PRAGMA foreign_keys = OFF;"
+      "PRAGMA journal_mode = WAL;");
+
+  /* auth_schema_migrations ledger */
+  Deita_Query_Execute_Update(p_conn,
+      "CREATE TABLE IF NOT EXISTS auth_schema_migrations ("
+      "  version INTEGER PRIMARY KEY,"
+      "  applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
+      ");");
+
+  /* v1 tables (condensed: only what the migration test needs) */
+  Deita_Query_Execute_Update(p_conn,
+      "CREATE TABLE IF NOT EXISTS guest_identities ("
+      "  id TEXT PRIMARY KEY,"
+      "  ip_binding_digest TEXT NOT NULL,"
+      "  created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+      "  last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+      "  expires_at INTEGER NOT NULL"
+      ");");
+  Deita_Query_Execute_Update(p_conn,
+      /* v1 guest_usage: count only, no turns_used/token columns */
+      "CREATE TABLE IF NOT EXISTS guest_usage ("
+      "  guest_id TEXT NOT NULL"
+      "    REFERENCES guest_identities(id) ON DELETE CASCADE,"
+      "  window_start INTEGER NOT NULL,"
+      "  count INTEGER NOT NULL DEFAULT 0,"
+      "  PRIMARY KEY (guest_id, window_start)"
+      ");");
+  Deita_Query_Execute_Update(p_conn,
+      /* v1 reservations: no request_id, no token count */
+      "CREATE TABLE IF NOT EXISTS guest_usage_reservations ("
+      "  id INTEGER PRIMARY KEY AUTOINCREMENT,"
+      "  guest_id TEXT NOT NULL"
+      "    REFERENCES guest_identities(id) ON DELETE CASCADE,"
+      "  reserved_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+      "  expires_at INTEGER NOT NULL"
+      ");");
+  /* Stub other tables so FK checks don't break */
+  Deita_Query_Execute_Update(p_conn,
+      "CREATE TABLE IF NOT EXISTS users ("
+      "  id TEXT PRIMARY KEY,"
+      "  username TEXT NOT NULL,"
+      "  normalized_username TEXT NOT NULL UNIQUE,"
+      "  password_hash TEXT NOT NULL,"
+      "  role TEXT NOT NULL,"
+      "  status TEXT NOT NULL DEFAULT 'active',"
+      "  must_change_password INTEGER NOT NULL DEFAULT 0,"
+      "  password_changed_at INTEGER NOT NULL DEFAULT 0,"
+      "  created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+      "  updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
+      ");");
+  Deita_Query_Execute_Update(p_conn,
+      "CREATE TABLE IF NOT EXISTS auth_sessions ("
+      "  token_digest TEXT PRIMARY KEY,"
+      "  csrf_digest TEXT NOT NULL,"
+      "  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,"
+      "  created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+      "  last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+      "  idle_expires_at INTEGER NOT NULL,"
+      "  absolute_expires_at INTEGER NOT NULL,"
+      "  password_changed_at_snapshot INTEGER NOT NULL DEFAULT 0,"
+      "  revoked_at INTEGER"
+      ");");
+  Deita_Query_Execute_Update(p_conn,
+      "CREATE TABLE IF NOT EXISTS admin_audit_log ("
+      "  id INTEGER PRIMARY KEY AUTOINCREMENT,"
+      "  actor_user_id TEXT,"
+      "  action TEXT NOT NULL,"
+      "  target_user_id TEXT,"
+      "  detail TEXT,"
+      "  created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
+      ");");
+
+  /* Mark v1 as applied; v2 is intentionally absent. */
+  Deita_Query_Execute_Update(p_conn,
+      "INSERT OR IGNORE INTO auth_schema_migrations (version) VALUES (1);");
+
+  /* Insert a guest identity and a legacy usage row with count=7. */
+  char g_id[37];
+  assert(test__make_uuid(g_id));
+  const char *gid_p[] = {g_id};
+  Deita_Query_Execute_Update_Prepared(p_conn,
+      "INSERT INTO guest_identities (id, ip_binding_digest, expires_at)"
+      "  VALUES (?, 'test-digest', 9999999999)",
+      1, gid_p);
+  int64 win = 1700524800LL;
+  char win_str[32];
+  snprintf(win_str, sizeof(win_str), "%lld", (long long)win);
+  const char *gu_p[] = {g_id, win_str};
+  /* count = 7 in the legacy column */
+  Deita_Query_Execute_Update_Prepared(p_conn,
+      "INSERT INTO guest_usage (guest_id, window_start, count) VALUES (?, ?, 7)",
+      2, gu_p);
+  Deita_Connection_Close(p_conn);
+
+  /* Open through Auth_Store_Create: should apply v2 (backfill turns_used). */
+  Auth_Store *p_store = Auth_Store_Create(legacy_db);
+  assert(p_store);
+
+  Auth_Store_Guest_Usage u;
+  assert(Auth_Store_Guest_Get_Usage(
+      p_store, g_id, win, &u) == AUTH_STORE_OK);
+  assert(u.turns_used == 7); /* backfilled from count */
+  puts("test_migration_v2_legacy_backfill: PASS");
+
+  Auth_Store_Destroy(p_store);
+  unlink(legacy_db);
+}
+
+/* ------------------------------------------------------------------ */
+/* 20. Expired reservation reaping                                       */
+/* ------------------------------------------------------------------ */
+
+static void test_reap_expired_reservations(Auth_Store *p_store)
+{
+  char gid[37];
+  assert(test__make_uuid(gid));
+  make_guest(p_store, gid);
+
+  int64 now     = (int64)time(NULL);
+  /* Use a window far in the future so it can never collide with clock-based
+   * reaping inside Auth_Store_Guest_Reserve. */
+  int64 win     = now + 86400LL;       /* tomorrow's window */
+  /* "past" and "future" are relative to now_sim (= now + 3600), but both
+   * must be > now so the internal Reserve reap does not touch them. */
+  int64 past    = now + 1800LL;        /* expires in 30 min: past from now_sim */
+  int64 future  = now + 7200LL;        /* expires in 2 h: future from now_sim */
+  int64 now_sim = now + 3600LL;        /* simulated "now": 1 h from now */
+
+  char ra[37], rb[37];
+  assert(test__make_uuid(ra));
+  assert(test__make_uuid(rb));
+
+  /* Reserve two turns: ra expires before now_sim, rb expires after. */
+  assert(Auth_Store_Guest_Reserve(
+      p_store, gid, ra, win, 100, 10, 1000, past) ==
+      AUTH_STORE_GUEST_QUOTA_OK);
+  assert(Auth_Store_Guest_Reserve(
+      p_store, gid, rb, win, 200, 10, 1000, future) ==
+      AUTH_STORE_GUEST_QUOTA_OK);
+
+  Auth_Store_Guest_Usage u;
+  assert(Auth_Store_Guest_Get_Usage(p_store, gid, win, &u) == AUTH_STORE_OK);
+  assert(u.turns_used == 2);
+  assert(u.output_tokens_reserved == 300); /* 100 + 200 */
+
+  /* Reap with now_sim > past but < future: only ra should be reaped. */
+  assert(Auth_Store_Guest_Reap_Expired(p_store, now_sim) == AUTH_STORE_OK);
+
+  assert(Auth_Store_Guest_Get_Usage(p_store, gid, win, &u) == AUTH_STORE_OK);
+  assert(u.turns_used == 2);        /* turns retained */
+  assert(u.output_tokens_reserved == 200); /* only ra's 100 removed */
+  puts("  reap/partial: PASS");
+
+  /* Idempotent: reaping again changes nothing. */
+  assert(Auth_Store_Guest_Reap_Expired(p_store, now_sim) == AUTH_STORE_OK);
+  assert(Auth_Store_Guest_Get_Usage(p_store, gid, win, &u) == AUTH_STORE_OK);
+  assert(u.output_tokens_reserved == 200);
+  puts("  reap/idempotent: PASS");
+
+  /* Reap with future time: rb is now expired too. */
+  assert(Auth_Store_Guest_Reap_Expired(p_store, future + 1) == AUTH_STORE_OK);
+  assert(Auth_Store_Guest_Get_Usage(p_store, gid, win, &u) == AUTH_STORE_OK);
+  assert(u.output_tokens_reserved == 0);
+  assert(u.turns_used == 2);
+  puts("  reap/all_expired: PASS");
+
+  /* Release on an already-reaped reservation: idempotent OK. */
+  assert(Auth_Store_Guest_Release(p_store, ra) == AUTH_STORE_OK);
+  assert(Auth_Store_Guest_Release(p_store, rb) == AUTH_STORE_OK);
+  puts("  reap/release_after_reap: PASS");
+
+  puts("test_reap_expired_reservations: PASS");
+}
+
+/* ------------------------------------------------------------------ */
+/* Main                                                                 */
+/* ------------------------------------------------------------------ */
+
+int main(void)
+{
+  char db_path[] = "/tmp/zenbu-auth-XXXXXX";
+  int  fd        = mkstemp(db_path);
+  assert(fd >= 0);
+  close(fd);
+
+  test_migrations_idempotent(db_path);
+  test_migration_v2_legacy_backfill(db_path);
+
+  Auth_Store *p_store = Auth_Store_Create(db_path);
+  assert(p_store);
+
+  test_username_normalization();
+
+  /* Bootstrap and last-admin tests must run first (only one admin). */
+  test_bootstrap_admin(p_store);
+  test_last_admin_protection(p_store);
+
+  test_username_uniqueness(p_store);
+  test_user_lookup(p_store);
+  test_forced_password_flag(p_store);
+  test_session_lifecycle(p_store);
+  test_stale_password_snapshot(p_store);
+  test_disabled_user(p_store);
+  test_password_update_revokes_others(p_store);
+  test_revoke_all_sessions(p_store);
+  test_expired_session(p_store);
+  test_guest_identity(p_store, db_path);
+  test_rotate_session(p_store);
+  test_create_session_cas(p_store);
+  test_self_change_password(p_store);
+  test_audited_admin_operations(p_store, db_path);
+  test_audit_failure_rollback(p_store, db_path);
+  test_guest_quota(p_store);
+  test_reap_expired_reservations(p_store);
+
+  Auth_Store_Destroy(p_store);
+  unlink(db_path);
+
+  puts("auth_store_test: ALL PASS");
+  return 0;
+}
--- a/dowa/d_string.c	Thu Aug 06 11:31:30 2026 -0700
+++ b/dowa/d_string.c	Fri Aug 07 07:34:12 2026 -0700
@@ -367,6 +367,8 @@
     return val;
   }
 
+  /* Unrecognised token — advance one byte to guarantee progress. */
+  (*pos)++;
   val.type = DOWA_JSON_NULL;
   return val;
 }
--- a/dowa/dowa_test.c	Thu Aug 06 11:31:30 2026 -0700
+++ b/dowa/dowa_test.c	Fri Aug 07 07:34:12 2026 -0700
@@ -347,6 +347,47 @@
     printf("randon_number 2: %i\n", random_number2);
   }
 
+  /* --- Malformed JSON regression tests --- */
+  {
+    Dowa_Arena *a = Dowa_Arena_Create(1024);
+    const char *s = "{\"username\":[x]}";
+    Dowa_JSON_Value v = Dowa_JSON_Parse(s, (int32)strlen(s), a);
+    assert(v.type == DOWA_JSON_OBJECT);
+    Dowa_JSON_Value *uv = Dowa_JSON_Get(v.object_val, "username");
+    assert(uv && uv->type == DOWA_JSON_ARRAY);
+    Dowa_Arena_Free(a);
+  }
+  {
+    Dowa_Arena *a = Dowa_Arena_Create(1024);
+    const char *s = "xyz";
+    Dowa_JSON_Value v = Dowa_JSON_Parse(s, (int32)strlen(s), a);
+    assert(v.type == DOWA_JSON_NULL);
+    Dowa_Arena_Free(a);
+  }
+  {
+    Dowa_Arena *a = Dowa_Arena_Create(1024);
+    const char *s = "[x, y, z]";
+    Dowa_JSON_Value v = Dowa_JSON_Parse(s, (int32)strlen(s), a);
+    assert(v.type == DOWA_JSON_ARRAY);
+    Dowa_Arena_Free(a);
+  }
+  {
+    Dowa_Arena *a = Dowa_Arena_Create(1024);
+    const char *s = "{\"a\":x,\"b\":\"ok\"}";
+    Dowa_JSON_Value v = Dowa_JSON_Parse(s, (int32)strlen(s), a);
+    assert(v.type == DOWA_JSON_OBJECT);
+    char *b = Dowa_JSON_Get_String(v.object_val, "b");
+    assert(b && strcmp(b, "ok") == 0);
+    Dowa_Arena_Free(a);
+  }
+  {
+    Dowa_Arena *a = Dowa_Arena_Create(1024);
+    const char *s = "{\"a\":\"unclosed";
+    Dowa_JSON_Value v = Dowa_JSON_Parse(s, (int32)strlen(s), a);
+    assert(v.type == DOWA_JSON_OBJECT);
+    Dowa_Arena_Free(a);
+  }
+
   printf("=== All tests passed! ===\n");
   return 0;
 }
--- a/mrjunejune/.config.development	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/.config.development	Fri Aug 07 07:34:12 2026 -0700
@@ -1,12 +1,106 @@
-# MrJuneJune Server Configuration
-
+# MrJuneJune Server Configuration — DEVELOPMENT TEMPLATE
+#
+# This file is a copyable filler only.  It contains no real secrets, hashes,
+# or credentials.  Copy it to .config (which is VCS-ignored) and fill in your
+# local values before starting the server.
+#
+#   cp mrjunejune/.config.development mrjunejune/.config
+#
+# ─────────────────────────────────────────────────────────────────────────────
 # Auth token for S3 upload API
-# Client must send this in Authorization header: Bearer <token>
-UPLOAD_AUTH_TOKEN=THIS
+# Client must send: Authorization: Bearer <token>
+UPLOAD_AUTH_TOKEN=REPLACE_WITH_LOCAL_TOKEN
 
 # S3 Configuration
-S3_REGION=THIS
-S3_BUCKET=THIS
+S3_REGION=REPLACE_WITH_REGION
+S3_BUCKET=REPLACE_WITH_BUCKET
 
 # Presigned URL expiration (seconds)
-S3_URL_EXPIRES=SECONDS
+S3_URL_EXPIRES=3600
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Auth — all AUTH_* values may also be set as environment variables; env takes
+# precedence over this file.  Never commit real values here.
+#
+# AUTH_COOKIE_SECRET
+#   A cryptographically random 32-byte secret encoded as a lowercase hex string
+#   (64 hex chars).  Used to sign session and guest cookies via HMAC-SHA-256.
+#   Generate with:
+#     openssl rand -hex 32
+#   Minimum: 64 hex chars (32 bytes).  Maximum: 2048 hex chars (1024 bytes).
+AUTH_COOKIE_SECRET=REPLACE_WITH_OUTPUT_OF_openssl_rand_hex_32
+
+# AUTH_BOOTSTRAP_USERNAME / AUTH_BOOTSTRAP_PASSWORD_HASH
+#   Creates the initial admin account on first startup if no admin exists yet.
+#   Both must be set together, or both must be absent.
+#   Generate the hash with:
+#     bazel run //auth:hash_password
+#   Then enter the desired bootstrap password at the prompt.
+#   Example format (do not use this value — it is a public test fixture):
+#     zenbu-scrypt$v=1$N=32768$r=8$p=1$<salt>$<hash>
+AUTH_BOOTSTRAP_USERNAME=admin
+AUTH_BOOTSTRAP_PASSWORD_HASH=REPLACE_WITH_OUTPUT_OF_bazel_run_//auth:hash_password
+
+# AUTH_TRUSTED_PROXY
+#   Exact direct-peer IP address of your reverse proxy (e.g. nginx/Caddy).
+#   When set, the server trusts X-Real-IP from this peer for client-IP binding.
+#   Leave commented out if the server receives direct connections.
+#   Must be a valid IPv4 or IPv6 address.
+# AUTH_TRUSTED_PROXY=10.0.0.1
+
+# AUTH_SESSION_IDLE_TTL / AUTH_SESSION_ABS_TTL
+#   Idle TTL: seconds since last request before a session expires (default 7 d).
+#   Abs TTL: absolute session lifetime regardless of activity (default 30 d).
+#   Idle must not exceed Abs.  Both must be 60–31536000.
+# AUTH_SESSION_IDLE_TTL=604800
+# AUTH_SESSION_ABS_TTL=2592000
+
+# AUTH_GUEST_TTL
+#   Lifetime of a guest identity record in seconds (default 30 d).
+# AUTH_GUEST_TTL=2592000
+
+# AUTH_DEV_INSECURE_COOKIE
+#   Set to true to issue cookies without the Secure attribute.
+#   ONLY valid when SERVER_HOST is a loopback address (127.0.0.1, ::1, or localhost).
+#   Must NOT be set in production or on a non-loopback bind.
+AUTH_DEV_INSECURE_COOKIE=true
+
+# ─────────────────────────────────────────────────────────────────────────────
+# SERVER_HOST
+#   IP address the server listens on.  Defaults to 0.0.0.0 (all interfaces).
+#   For local development, set to 127.0.0.1 — required when
+#   AUTH_DEV_INSECURE_COOKIE=true.
+#   In production, either leave unset (0.0.0.0) or set to a specific interface.
+SERVER_HOST=127.0.0.1
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Guest inference quota — enable guest inference at runtime via the environment:
+#   MRJUNEJUNE_ALLOW_GUEST_INFERENCE=1
+# These integer values control per-guest rate limits.
+# Malformed or out-of-range values cause startup failure.
+# AUTH_GUEST_DAILY_TURNS=10
+# AUTH_GUEST_DAILY_OUTPUT_TOKENS=20000
+# AUTH_GUEST_REQUEST_OUTPUT_TOKENS=2048
+
+# ─────────────────────────────────────────────────────────────────────────────
+# OPERATIONAL NOTES
+#
+# Bootstrap password rotation:
+#   After the bootstrap admin logs in for the first time, change the password
+#   via the /account/password page.  To rotate the bootstrap credential itself:
+#   1. Generate a new hash:  bazel run //auth:hash_password
+#   2. Update AUTH_BOOTSTRAP_PASSWORD_HASH in .config.
+#   3. Use Admin_API or SQL to reset the stored hash for the admin user.
+#      (Bootstrap only creates the user on first startup when no admin exists.)
+#
+# Guest data retention:
+#   Guest identity rows accumulate in the auth SQLite database.  Rows expire
+#   after AUTH_GUEST_TTL seconds from last activity.  Auth_Store automatically
+#   skips expired rows on reads; periodic cleanup of very old rows can be done
+#   with: DELETE FROM guest_identities WHERE expires_at < strftime('%s','now');
+#   Run this against the auth database (DB_PATH, default mrjunejune/data/).
+#
+# Secure cookie policy:
+#   Production deployments must NOT set AUTH_DEV_INSECURE_COOKIE=true.
+#   Ensure your reverse proxy terminates TLS and forwards via SERVER_HOST.
+#   The Secure cookie attribute is enforced automatically on non-loopback hosts.
--- a/mrjunejune/BUILD	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/BUILD	Fri Aug 07 07:34:12 2026 -0700
@@ -172,6 +172,12 @@
 )
 
 filegroup(
+  name = "html_src_files",
+  srcs = glob(["src/**/*.html"]),
+  visibility = ["//mrjunejune/test:__pkg__"],
+)
+
+filegroup(
   name = "src_files",
   srcs = glob(
     ["src/**"],
@@ -193,6 +199,20 @@
 )
 
 cc_library(
+  name = "template_renderer",
+  srcs = ["template_renderer.c"],
+  hdrs = ["template_renderer.h"],
+  deps = [
+    "//dowa:dowa",
+    "//seobeo:seobeo",
+  ],
+  visibility = [
+    "//mrjunejune:__pkg__",
+    "//mrjunejune/test:__pkg__",
+  ],
+)
+
+cc_library(
   name = "latex_renderer",
   srcs = ["latex_renderer.c"],
   hdrs = ["latex_renderer.h"],
@@ -222,11 +242,85 @@
   srcs = ["conversation_api.c"],
   hdrs = ["conversation_api.h"],
   deps = [
+    ":auth_api",
     ":conversation_store",
     ":inference_bridge",
+    "//auth:auth_store",
     "//dowa:dowa",
     "//seobeo:seobeo",
   ],
+  linkopts = ["-lpthread"],
+)
+
+cc_library(
+  name = "admin_api",
+  srcs = ["admin_api.c"],
+  hdrs = ["admin_api.h"],
+  deps = [
+    ":auth_api",
+    ":template_renderer",
+    "//auth:auth_crypto",
+    "//auth:auth_store",
+    "//dowa:dowa",
+    "//seobeo:seobeo",
+    "@openssl//:crypto",
+  ],
+  visibility = ["//mrjunejune/test:__pkg__"],
+)
+
+# Same sources compiled with test-hook symbols exposed.
+cc_library(
+  name = "admin_api_with_test_hooks",
+  srcs = ["admin_api.c"],
+  hdrs = ["admin_api.h"],
+  copts = ["-DADMIN_API_TEST_HOOKS"],
+  deps = [
+    ":auth_api_with_test_hooks",
+    ":template_renderer",
+    "//auth:auth_crypto",
+    "//auth:auth_store",
+    "//dowa:dowa",
+    "//seobeo:seobeo",
+    "@openssl//:crypto",
+  ],
+  linkopts = ["-lpthread"],
+  testonly = True,
+  visibility = ["//mrjunejune/test:__pkg__"],
+)
+
+cc_library(
+  name = "auth_api",
+  srcs = ["auth_api.c"],
+  hdrs = ["auth_api.h"],
+  deps = [
+    ":template_renderer",
+    "//auth:auth_crypto",
+    "//auth:auth_store",
+    "//dowa:dowa",
+    "//seobeo:seobeo",
+    "@openssl//:crypto",
+  ],
+  linkopts = ["-lpthread"],
+  visibility = ["//mrjunejune/test:__pkg__"],
+)
+
+# Same sources, compiled with test-hook symbols exposed.
+cc_library(
+  name = "auth_api_with_test_hooks",
+  srcs = ["auth_api.c"],
+  hdrs = ["auth_api.h"],
+  copts = ["-DAUTH_API_TEST_HOOKS"],
+  deps = [
+    ":template_renderer",
+    "//auth:auth_crypto",
+    "//auth:auth_store",
+    "//dowa:dowa",
+    "//seobeo:seobeo",
+    "@openssl//:crypto",
+  ],
+  linkopts = ["-lpthread"],
+  testonly = True,
+  visibility = ["//mrjunejune/test:__pkg__"],
 )
 
 cc_library(
@@ -265,21 +359,23 @@
   name = "mrjunejune_server",
   srcs = ["main.c"],
   deps = [
+    ":admin_api",
+    ":auth_api",
     ":conversation_api",
+    ":template_renderer",
     "//seobeo:seobeo",
     "//markdown_converter:markdown_to_html_c",
     "//s3:s3",
     "//deita:deita",
     ":latex_renderer",
+    "//auth:auth_crypto",
   ],
   copts = ["-D_GNU_SOURCE"],
   linkopts = ["-lpthread"],
   data = [
     ":src_files",
-    ":config_file",
     ":inference_runtime_data",
     ":tectonic_runtime_data",
-    "//:env_file",
   ],
   visibility = ["//mrjunejune/test:__pkg__"],
 )
@@ -289,15 +385,20 @@
   name = "mrjunejune_server_debug",
   srcs = ["main.c"],
   deps = [
+    ":admin_api",
+    ":auth_api",
     ":conversation_api",
+    ":template_renderer",
     "//seobeo:seobeo_debug",
     "//markdown_converter:markdown_to_html_c",
     "//s3:s3",
     "//deita:deita",
     ":latex_renderer",
+    "//auth:auth_crypto",
   ],
   copts = [
     "-D_GNU_SOURCE",
+    "-DMRJUNEJUNE_DEVELOPMENT_CONFIG",
   ],
   linkopts = ["-lpthread"],
   data = [
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/admin_api.c	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,704 @@
+#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 */
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/admin_api.h	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,39 @@
+#ifndef MRJUNEJUNE_ADMIN_API_H
+#define MRJUNEJUNE_ADMIN_API_H
+
+#include "dowa/dowa.h"
+#include "seobeo/seobeo.h"
+
+/* Route paths */
+#define ADMIN_API_PAGE_PATH          "/admin/users"
+#define ADMIN_API_LIST_PATH          "/api/admin/users"
+#define ADMIN_API_PATCH_PATH         "/api/admin/users/:id"
+#define ADMIN_API_REVOKE_SESS_PATH   "/api/admin/users/:id/sessions"
+
+/* Pagination defaults */
+#define ADMIN_API_DEFAULT_LIMIT  50
+#define ADMIN_API_MAX_LIMIT     100
+
+/*
+ * Register all admin routes.  Call after Auth_API_Init.
+ */
+void Admin_API_Register_Routes(void);
+
+#ifdef ADMIN_API_TEST_HOOKS
+/*
+ * Direct handler entry-points for in-process testing.
+ * Only available when ADMIN_API_TEST_HOOKS is defined.
+ */
+Seobeo_Request_Entry *Admin_API_Test_Page_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena);
+Seobeo_Request_Entry *Admin_API_Test_List_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena);
+Seobeo_Request_Entry *Admin_API_Test_Create_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena);
+Seobeo_Request_Entry *Admin_API_Test_Update_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena);
+Seobeo_Request_Entry *Admin_API_Test_Revoke_Sessions_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena);
+#endif /* ADMIN_API_TEST_HOOKS */
+
+#endif /* MRJUNEJUNE_ADMIN_API_H */
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/auth_api.c	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,2019 @@
+#include "mrjunejune/auth_api.h"
+#include "mrjunejune/template_renderer.h"
+
+#include "auth/auth_crypto.h"
+#include "auth/auth_store.h"
+#include "seobeo/seobeo.h"
+#include "dowa/dowa.h"
+
+#include <openssl/hmac.h>
+#include <openssl/evp.h>
+#include <openssl/rand.h>
+#include <openssl/crypto.h>
+
+#include <arpa/inet.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <time.h>
+
+/* ------------------------------------------------------------------ */
+/* Constants                                                            */
+/* ------------------------------------------------------------------ */
+
+#define BODY_MAX_BYTES          4096
+#define COOKIE_VALUE_MAX        512
+#define CSRF_SUFFIX             ":csrf:v1"
+#define RATE_TABLE_SIZE         512   /* must be power of 2 */
+#define RATE_PROBE_LIMIT        16
+#define RATE_LIMIT_MAX_FAILURES 5
+#define RATE_LIMIT_WINDOW_SECS  (15 * 60)
+#define SESSION_COOKIE_MAX      512
+#define GUEST_COOKIE_MAX        (AUTH_CRYPTO_GUEST_COOKIE_SIZE + 256)
+#define RATE_KEY_MAX            65
+#define BINDING_INPUT_MAX       (AUTH_CRYPTO_TOKEN_DIGEST_SIZE + 16)
+
+/*
+ * Fixed precomputed scrypt hash of the constant string "dummy-zenbu-timing".
+ * Used only for timing-attack mitigation on nonexistent-username lookups.
+ * Never used as an account credential; salt+hash are intentionally public.
+ */
+#define AUTH_DUMMY_PASSWORD_HASH \
+    "zenbu-scrypt$v=1$N=32768$r=8$p=1$" \
+    "c4ff27cc756429b17991b80e4c2c5f77$" \
+    "34e208b3a51796ebd3b39da1039611b1c7ffeb04090b07ef2bf4968ce8131ce3"
+
+/* ------------------------------------------------------------------ */
+/* Module state                                                         */
+/* ------------------------------------------------------------------ */
+
+static Auth_Store              *g_auth_store         = NULL;
+static uint8                    g_cookie_secret[AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES];
+static size_t                   g_cookie_secret_length = 0;
+static char                     g_trusted_proxy_ip[AUTH_CRYPTO_IP_MAX_BYTES];
+static boolean                  g_has_trusted_proxy  = FALSE;
+static int64                    g_session_idle_ttl   = AUTH_API_SESSION_IDLE_TTL_DEFAULT;
+static int64                    g_session_abs_ttl    = AUTH_API_SESSION_ABS_TTL_DEFAULT;
+static int64                    g_guest_ttl          = AUTH_API_GUEST_TTL_DEFAULT;
+static boolean                  g_dev_insecure_cookie = FALSE;
+static Auth_Guest_Transfer_Hook g_transfer_hook      = NULL;
+static void                    *g_transfer_hook_ctx  = NULL;
+static Auth_API_Guest_Quota_Cb  g_guest_quota_cb     = NULL;
+#ifdef AUTH_API_TEST_HOOKS
+static Auth_API_Test_Login_Pre_Create_Hook g_login_pre_create_hook = NULL;
+static void *g_login_pre_create_context = NULL;
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Rate limiter (collision-safe open-addressing with LRU eviction)      */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+  char     key[RATE_KEY_MAX]; /* HMAC hex binding; empty if slot unused */
+  uint32   count;
+  int64    window_start;
+} Auth_Rate_Entry;
+
+static Auth_Rate_Entry  g_rate_table[RATE_TABLE_SIZE];
+static pthread_mutex_t  g_rate_mutex = PTHREAD_MUTEX_INITIALIZER;
+
+/* ------------------------------------------------------------------ */
+/* Internal helpers                                                     */
+/* ------------------------------------------------------------------ */
+
+static int64 auth_now(void)
+{
+  return (int64)time(NULL);
+}
+
+/*
+ * Generate a UUID v4 using RAND_bytes.
+ * buf must be at least 37 bytes.
+ */
+static boolean auth_uuid4(char *buf, size_t capacity)
+{
+  if (capacity < 37) return FALSE;
+  uint8 rnd[16];
+  if (RAND_bytes(rnd, sizeof(rnd)) != 1) return FALSE;
+  /* RFC 4122 version 4 */
+  rnd[6] = (rnd[6] & 0x0f) | 0x40;
+  rnd[8] = (rnd[8] & 0x3f) | 0x80;
+  snprintf(buf, capacity,
+           "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-"
+           "%02x%02x%02x%02x%02x%02x",
+           rnd[0],  rnd[1],  rnd[2],  rnd[3],
+           rnd[4],  rnd[5],
+           rnd[6],  rnd[7],
+           rnd[8],  rnd[9],
+           rnd[10], rnd[11], rnd[12], rnd[13], rnd[14], rnd[15]);
+  OPENSSL_cleanse(rnd, sizeof(rnd));
+  return TRUE;
+}
+
+/*
+ * base64url encode src_len bytes from src into dst.
+ * dst must have capacity ceil(src_len * 4 / 3) + 1.
+ * Returns length of encoded string (without NUL).
+ *
+ * Delegates to Auth_Crypto_Base64url_Encode which uses the correct loop
+ * boundary (processes remaining bytes after full 3-byte groups).
+ */
+static size_t auth_base64url_encode(
+    const uint8 *src,
+    size_t        src_len,
+    char         *dst,
+    size_t        dst_capacity)
+{
+  return Auth_Crypto_Base64url_Encode(src, src_len, dst, dst_capacity);
+}
+
+static void auth_hex_encode(
+    const uint8 *src,
+    size_t        src_len,
+    char         *dst,
+    size_t        dst_capacity)
+{
+  static const char kHex[] = "0123456789abcdef";
+  size_t i = 0, o = 0;
+  while (i < src_len && o + 2 < dst_capacity)
+  {
+    dst[o++] = kHex[(src[i] >> 4) & 0xf];
+    dst[o++] = kHex[src[i] & 0xf];
+    i++;
+  }
+  if (o < dst_capacity) dst[o] = '\0';
+}
+
+/*
+ * Derive a CSRF token deterministically from a session binding.
+ * binding: session token_digest (user) or guest_id (guest).
+ * Output: base64url-encoded HMAC-SHA256, AUTH_CRYPTO_TOKEN_SIZE bytes.
+ */
+static boolean auth_derive_csrf(
+    const char *binding,
+    char       *csrf_out,
+    size_t      csrf_capacity)
+{
+  if (!binding || !csrf_out || csrf_capacity < AUTH_CRYPTO_TOKEN_SIZE)
+    return FALSE;
+
+  size_t binding_len = strlen(binding);
+  size_t suffix_len  = strlen(CSRF_SUFFIX);
+  size_t input_len   = binding_len + suffix_len;
+
+  if (input_len >= BINDING_INPUT_MAX)
+    return FALSE;
+
+  char input[BINDING_INPUT_MAX];
+  memcpy(input, binding, binding_len);
+  memcpy(input + binding_len, CSRF_SUFFIX, suffix_len);
+
+  uint8 digest[32];
+  uint32 digest_len = 32;
+  if (!HMAC(EVP_sha256(),
+            g_cookie_secret, (int)g_cookie_secret_length,
+            (const uint8 *)input, input_len,
+            digest, &digest_len))
+  {
+    OPENSSL_cleanse(input, sizeof(input));
+    OPENSSL_cleanse(digest, sizeof(digest));
+    return FALSE;
+  }
+  OPENSSL_cleanse(input, sizeof(input));
+
+  size_t encoded_length =
+      auth_base64url_encode(digest, 32, csrf_out, csrf_capacity);
+  OPENSSL_cleanse(digest, sizeof(digest));
+  return encoded_length == AUTH_CRYPTO_TOKEN_SIZE - 1;
+}
+
+/*
+ * Build the rate-limit key: HMAC(secret, peer_digest + ":" + norm_user).
+ * Output: 64-char hex string.
+ */
+static boolean auth_rate_key(
+    const char *peer_binding_digest,
+    const char *normalized_username,
+    char       *key_out)
+{
+  char input[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE + 1 + AUTH_STORE_USERNAME_MAX + 1];
+  int n = snprintf(input, sizeof(input), "%s:%s",
+                   peer_binding_digest, normalized_username);
+  if (n < 0 || (size_t)n >= sizeof(input))
+    return FALSE;
+
+  uint8 digest[32];
+  uint32 digest_len = 32;
+  if (!HMAC(EVP_sha256(),
+            g_cookie_secret, (int)g_cookie_secret_length,
+            (const uint8 *)input, (size_t)n,
+            digest, &digest_len))
+  {
+    OPENSSL_cleanse(input, sizeof(input));
+    OPENSSL_cleanse(digest, sizeof(digest));
+    return FALSE;
+  }
+  OPENSSL_cleanse(input, sizeof(input));
+
+  auth_hex_encode(digest, 32, key_out, RATE_KEY_MAX);
+  OPENSSL_cleanse(digest, sizeof(digest));
+  return TRUE;
+}
+
+static uint32 auth_rate_index(const char *key)
+{
+  /* Use the first 8 hex chars of the HMAC key as a uint32 hash seed. */
+  uint32 h = 0;
+  for (int i = 0; i < 8 && key[i] != '\0'; i++)
+  {
+    char c = key[i];
+    uint32 nibble = (c >= '0' && c <= '9') ? (uint32)(c - '0') :
+                    (c >= 'a' && c <= 'f') ? (uint32)(c - 'a' + 10) : 0;
+    h = (h << 4) | nibble;
+  }
+  return h & (RATE_TABLE_SIZE - 1);
+}
+
+/*
+ * Find or insert an entry for key in the open-addressing rate table.
+ * Probes up to RATE_PROBE_LIMIT slots from the hash index.
+ * On a full probe window, evicts the oldest entry by window_start.
+ * Must be called with g_rate_mutex held.
+ * Returns a pointer to the entry on success, NULL if internal error.
+ */
+static Auth_Rate_Entry *auth_rate_find_or_insert(const char *key, int64 now)
+{
+  uint32 start = auth_rate_index(key);
+  Auth_Rate_Entry *evict_candidate = NULL;
+  int64 evict_time = INT64_MAX;
+
+  for (uint32 i = 0; i < (uint32)RATE_PROBE_LIMIT; i++)
+  {
+    uint32 idx = (start + i) & (RATE_TABLE_SIZE - 1);
+    Auth_Rate_Entry *e = &g_rate_table[idx];
+
+    /* Exact match */
+    if (e->key[0] != '\0' &&
+        memcmp(e->key, key, RATE_KEY_MAX) == 0)
+      return e;
+
+    /* Empty slot — claim it */
+    if (e->key[0] == '\0')
+    {
+      memcpy(e->key, key, RATE_KEY_MAX);
+      e->count        = 0;
+      e->window_start = now;
+      return e;
+    }
+
+    /* Expired entry — reuse it immediately */
+    if (now - e->window_start >= RATE_LIMIT_WINDOW_SECS)
+    {
+      memcpy(e->key, key, RATE_KEY_MAX);
+      e->count        = 0;
+      e->window_start = now;
+      return e;
+    }
+
+    /* Track the oldest live entry for eviction */
+    if (e->window_start < evict_time)
+    {
+      evict_time      = e->window_start;
+      evict_candidate = e;
+    }
+  }
+
+  /* All probe slots occupied by live, non-matching entries — evict oldest */
+  if (evict_candidate)
+  {
+    memcpy(evict_candidate->key, key, RATE_KEY_MAX);
+    evict_candidate->count        = 0;
+    evict_candidate->window_start = now;
+    return evict_candidate;
+  }
+
+  return NULL;
+}
+
+/*
+ * Returns TRUE if the caller should be rate-limited (too many failures).
+ * Caller must still call auth_rate_record_failure on a failed attempt.
+ */
+static boolean auth_rate_check(const char *key)
+{
+  pthread_mutex_lock(&g_rate_mutex);
+  int64 now = auth_now();
+  Auth_Rate_Entry *entry = auth_rate_find_or_insert(key, now);
+  boolean limited = FALSE;
+  if (entry && now - entry->window_start < RATE_LIMIT_WINDOW_SECS)
+    limited = (entry->count >= RATE_LIMIT_MAX_FAILURES);
+  pthread_mutex_unlock(&g_rate_mutex);
+  return limited;
+}
+
+static void auth_rate_record_failure(const char *key)
+{
+  pthread_mutex_lock(&g_rate_mutex);
+  int64 now = auth_now();
+  Auth_Rate_Entry *entry = auth_rate_find_or_insert(key, now);
+  if (entry)
+  {
+    if (now - entry->window_start >= RATE_LIMIT_WINDOW_SECS)
+    {
+      /* Window expired; start fresh */
+      entry->count        = 1;
+      entry->window_start = now;
+    }
+    else
+    {
+      entry->count++;
+    }
+  }
+  pthread_mutex_unlock(&g_rate_mutex);
+}
+
+static void auth_rate_reset(const char *key)
+{
+  pthread_mutex_lock(&g_rate_mutex);
+  uint32 start = auth_rate_index(key);
+  for (uint32 i = 0; i < (uint32)RATE_PROBE_LIMIT; i++)
+  {
+    uint32 idx = (start + i) & (RATE_TABLE_SIZE - 1);
+    Auth_Rate_Entry *e = &g_rate_table[idx];
+    if (e->key[0] != '\0' && memcmp(e->key, key, RATE_KEY_MAX) == 0)
+    {
+      memset(e, 0, sizeof(*e));
+      break;
+    }
+  }
+  pthread_mutex_unlock(&g_rate_mutex);
+}
+
+/* ------------------------------------------------------------------ */
+/* Request helpers                                                      */
+/* ------------------------------------------------------------------ */
+
+static const char *auth_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 auth_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;
+}
+
+/*
+ * Parse a named cookie from the Cookie header.
+ * Returns TRUE and fills value_out on success.
+ */
+static boolean auth_parse_cookie(
+    const char *cookie_header,
+    const char *name,
+    char       *value_out,
+    size_t      capacity)
+{
+  if (!cookie_header || !name || !value_out || capacity == 0)
+    return FALSE;
+
+  size_t name_len = strlen(name);
+  const char *p = cookie_header;
+
+  while (*p)
+  {
+    /* skip whitespace */
+    while (*p == ' ' || *p == '\t') p++;
+
+    /* check for name= */
+    if (strncmp(p, name, name_len) == 0 && p[name_len] == '=')
+    {
+      p += name_len + 1;
+      const char *start = p;
+      while (*p && *p != ';') p++;
+      size_t vlen = (size_t)(p - start);
+      if (vlen >= capacity) return FALSE;
+      memcpy(value_out, start, vlen);
+      value_out[vlen] = '\0';
+      return TRUE;
+    }
+
+    /* skip to next ; */
+    while (*p && *p != ';') p++;
+    if (*p == ';') p++;
+  }
+  return FALSE;
+}
+
+/*
+ * Resolve effective peer IP.
+ * If Remote-Addr matches configured trusted proxy, accept X-Real-IP.
+ * Never logs raw IPs.
+ */
+static boolean auth_peer_ip(
+    Seobeo_Request_Entry *p_req,
+    char                 *ip_out,
+    size_t                capacity)
+{
+  const char *direct = auth_req_value(p_req, "Remote-Addr");
+  if (!direct || direct[0] == '\0')
+    return FALSE;
+
+  if (g_has_trusted_proxy &&
+      strcmp(direct, g_trusted_proxy_ip) == 0)
+  {
+    const char *forwarded = auth_req_value(p_req, "X-Real-IP");
+    if (forwarded && forwarded[0] != '\0' && strlen(forwarded) < capacity)
+    {
+      strncpy(ip_out, forwarded, capacity - 1);
+      ip_out[capacity - 1] = '\0';
+      return TRUE;
+    }
+  }
+
+  if (strlen(direct) >= capacity)
+    return FALSE;
+  strncpy(ip_out, direct, capacity - 1);
+  ip_out[capacity - 1] = '\0';
+  return TRUE;
+}
+
+static boolean auth_same_origin(Seobeo_Request_Entry *p_req)
+{
+  const char *host   = auth_req_value(p_req, "Host");
+  const char *origin = auth_req_value(p_req, "Origin");
+  if (!host || !origin) return FALSE;
+
+  const char *host_in_origin = strstr(origin, "://");
+  if (!host_in_origin) return FALSE;
+  host_in_origin += 3;
+
+  const char *end = strchr(host_in_origin, '/');
+  size_t len = end ? (size_t)(end - host_in_origin) : strlen(host_in_origin);
+  return strlen(host) == len && strncmp(host, host_in_origin, len) == 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Response builders                                                    */
+/* ------------------------------------------------------------------ */
+
+/*
+ * Build a Set-Cookie directive string.
+ * expires_max_age = 0 means no Max-Age (persistent); < 0 means Max-Age=0
+ * (clear the cookie).
+ */
+static boolean auth_build_cookie_directive(
+    const char *name,
+    const char *value,
+    int32       max_age,
+    boolean     http_only,
+    char       *out,
+    size_t      capacity)
+{
+  int n;
+  if (max_age < 0)
+  {
+    n = snprintf(out, capacity,
+                 "%s=; Path=/; %sSameSite=Lax; Max-Age=0%s",
+                 name,
+                 http_only ? "HttpOnly; " : "",
+                 g_dev_insecure_cookie ? "" : "; Secure");
+  }
+  else if (max_age == 0)
+  {
+    n = snprintf(out, capacity,
+                 "%s=%s; Path=/; %sSameSite=Lax%s",
+                 name, value,
+                 http_only ? "HttpOnly; " : "",
+                 g_dev_insecure_cookie ? "" : "; Secure");
+  }
+  else
+  {
+    n = snprintf(out, capacity,
+                 "%s=%s; Path=/; %sSameSite=Lax; Max-Age=%d%s",
+                 name, value,
+                 http_only ? "HttpOnly; " : "",
+                 max_age,
+                 g_dev_insecure_cookie ? "" : "; Secure");
+  }
+  return n > 0 && (size_t)n < capacity;
+}
+
+static Seobeo_Request_Entry *auth_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 *auth_json_response_with_cookies(
+    Dowa_Arena *p_arena,
+    const char *status,
+    const char *body,
+    const char *cookie1,  /* value for "Set-Cookie"; NULL to skip */
+    const char *cookie2)  /* value for "set-cookie"; NULL to skip */
+{
+  Seobeo_Request_Entry *resp = auth_json_response(p_arena, status, body);
+  if (cookie1)
+    Dowa_HashMap_Push_Arena(resp, "Set-Cookie", (char *)cookie1, p_arena);
+  if (cookie2)
+    Dowa_HashMap_Push_Arena(resp, "set-cookie", (char *)cookie2, p_arena);
+  return resp;
+}
+
+static Seobeo_Request_Entry *auth_error(
+    Dowa_Arena *p_arena,
+    const char *status,
+    const char *code,
+    const char *message)
+{
+  char body[512];
+  snprintf(body, sizeof(body),
+           "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
+           code, message);
+  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body) + 1);
+  if (body_copy) strcpy(body_copy, body);
+  return auth_json_response(p_arena, status, body_copy ? body_copy : "{}");
+}
+
+/* ------------------------------------------------------------------ */
+/* Principal resolution                                                 */
+/* ------------------------------------------------------------------ */
+
+boolean Auth_API_Resolve_Principal(
+    Seobeo_Request_Entry *p_request,
+    Auth_Principal       *p_principal,
+    Dowa_Arena           *p_arena,
+    char                 *new_guest_cookie_out,
+    size_t                new_guest_cookie_capacity)
+{
+  if (!g_auth_store || !p_principal) return FALSE;
+
+  memset(p_principal, 0, sizeof(*p_principal));
+  if (new_guest_cookie_out && new_guest_cookie_capacity > 0)
+    new_guest_cookie_out[0] = '\0';
+
+  const char *cookie_header = auth_req_value(p_request, "Cookie");
+  int64 now = auth_now();
+
+  /* --- Try authenticated session first --- */
+  char session_token[COOKIE_VALUE_MAX] = {0};
+  if (cookie_header &&
+      auth_parse_cookie(cookie_header, AUTH_API_SESSION_COOKIE_NAME,
+                        session_token, sizeof(session_token)) &&
+      session_token[0] != '\0')
+  {
+    char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+    if (Auth_Crypto_Token_Digest(session_token, token_digest,
+                                 sizeof(token_digest)) == AUTH_CRYPTO_OK)
+    {
+      Auth_Session_Record session;
+      Auth_User_Record    user;
+      Auth_Store_Result result = Auth_Store_Find_Session(
+          g_auth_store, token_digest, now, &session, &user);
+
+      if (result == AUTH_STORE_OK)
+      {
+        Auth_Store_Touch_Session(
+            g_auth_store, token_digest, now, g_session_idle_ttl);
+
+        p_principal->kind = AUTH_PRINCIPAL_USER;
+        strncpy(p_principal->user_id, user.id, sizeof(p_principal->user_id) - 1);
+        strncpy(p_principal->username, user.username, sizeof(p_principal->username) - 1);
+        strncpy(p_principal->role, user.role, sizeof(p_principal->role) - 1);
+        p_principal->must_change_password = user.must_change_password;
+        strncpy(p_principal->_binding, token_digest, sizeof(p_principal->_binding) - 1);
+        if (!auth_derive_csrf(token_digest, p_principal->csrf_token,
+                              sizeof(p_principal->csrf_token)))
+        {
+          OPENSSL_cleanse(session_token, sizeof(session_token));
+          OPENSSL_cleanse(token_digest, sizeof(token_digest));
+          memset(p_principal, 0, sizeof(*p_principal));
+          return FALSE;
+        }
+        OPENSSL_cleanse(session_token, sizeof(session_token));
+        OPENSSL_cleanse(token_digest, sizeof(token_digest));
+        return TRUE;
+      }
+    }
+    OPENSSL_cleanse(session_token, sizeof(session_token));
+  }
+
+  /* --- Try guest cookie --- */
+  char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0};
+  boolean have_ip = auth_peer_ip(p_request, peer_ip, sizeof(peer_ip));
+
+  char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0};
+  if (have_ip &&
+      Auth_Crypto_IP_Binding_Digest(
+          g_cookie_secret, g_cookie_secret_length,
+          peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+    return FALSE;
+  }
+
+  char guest_cookie_val[COOKIE_VALUE_MAX] = {0};
+  boolean guest_valid = FALSE;
+  Auth_Crypto_Guest_Cookie guest_parsed;
+  memset(&guest_parsed, 0, sizeof(guest_parsed));
+
+  if (cookie_header &&
+      auth_parse_cookie(cookie_header, AUTH_API_GUEST_COOKIE_NAME,
+                        guest_cookie_val, sizeof(guest_cookie_val)) &&
+      guest_cookie_val[0] != '\0' &&
+      have_ip)
+  {
+    Auth_Crypto_Result cr = Auth_Crypto_Guest_Cookie_Verify(
+        g_cookie_secret, g_cookie_secret_length,
+        guest_cookie_val, (uint64)now,
+        ip_binding, &guest_parsed);
+    guest_valid = (cr == AUTH_CRYPTO_OK);
+  }
+
+  if (guest_valid)
+  {
+    Auth_Guest_Identity_Record identity;
+    Auth_Store_Result result = Auth_Store_Find_Guest_Identity(
+        g_auth_store, guest_parsed.guest_uuid, now, &identity);
+
+    if (result == AUTH_STORE_OK)
+    {
+      Auth_Store_Upsert_Guest_Identity(
+          g_auth_store, guest_parsed.guest_uuid,
+          ip_binding, now + g_guest_ttl, &identity);
+
+      p_principal->kind = AUTH_PRINCIPAL_GUEST;
+      strncpy(p_principal->guest_id, guest_parsed.guest_uuid,
+              sizeof(p_principal->guest_id) - 1);
+      strncpy(p_principal->_binding, guest_parsed.guest_uuid,
+              sizeof(p_principal->_binding) - 1);
+      if (!auth_derive_csrf(guest_parsed.guest_uuid, p_principal->csrf_token,
+                            sizeof(p_principal->csrf_token)))
+      {
+        OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val));
+        OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+        memset(p_principal, 0, sizeof(*p_principal));
+        return FALSE;
+      }
+      OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val));
+      OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+      return TRUE;
+    }
+  }
+  OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val));
+
+  /* --- Create new guest identity --- */
+  char guest_uuid[AUTH_CRYPTO_GUEST_UUID_SIZE];
+  if (!auth_uuid4(guest_uuid, sizeof(guest_uuid)))
+  {
+    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+    return FALSE;
+  }
+
+  int64 guest_expires = now + g_guest_ttl;
+  Auth_Guest_Identity_Record new_identity;
+  if (Auth_Store_Upsert_Guest_Identity(
+          g_auth_store, guest_uuid, ip_binding, guest_expires,
+          &new_identity) != AUTH_STORE_OK)
+  {
+    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+    return FALSE;
+  }
+
+  p_principal->kind = AUTH_PRINCIPAL_GUEST;
+  strncpy(p_principal->guest_id, guest_uuid,
+          sizeof(p_principal->guest_id) - 1);
+  strncpy(p_principal->_binding, guest_uuid,
+          sizeof(p_principal->_binding) - 1);
+  if (!auth_derive_csrf(guest_uuid, p_principal->csrf_token,
+                        sizeof(p_principal->csrf_token)))
+  {
+    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+    memset(p_principal, 0, sizeof(*p_principal));
+    return FALSE;
+  }
+
+  /* Build new guest cookie for the response */
+  if (new_guest_cookie_out && new_guest_cookie_capacity > 0 && have_ip)
+  {
+    char signed_cookie[AUTH_CRYPTO_GUEST_COOKIE_SIZE];
+    if (Auth_Crypto_Guest_Cookie_Create(
+            g_cookie_secret, g_cookie_secret_length,
+            guest_uuid, (uint64)guest_expires, ip_binding,
+            signed_cookie, sizeof(signed_cookie)) == AUTH_CRYPTO_OK)
+    {
+      auth_build_cookie_directive(
+          AUTH_API_GUEST_COOKIE_NAME, signed_cookie,
+          (int32)g_guest_ttl, TRUE,
+          new_guest_cookie_out, new_guest_cookie_capacity);
+      OPENSSL_cleanse(signed_cookie, sizeof(signed_cookie));
+    }
+  }
+
+  OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+  return TRUE;
+}
+
+boolean Auth_API_Resolve_Existing_Principal(
+    Seobeo_Request_Entry *p_request,
+    Auth_Principal       *p_principal,
+    Dowa_Arena           *p_arena,
+    boolean              *p_found)
+{
+  if (!g_auth_store || !p_principal || !p_found) return FALSE;
+
+  memset(p_principal, 0, sizeof(*p_principal));
+  *p_found = FALSE;
+
+  const char *cookie_header = auth_req_value(p_request, "Cookie");
+  int64 now = auth_now();
+
+  /* --- Try authenticated session first --- */
+  char session_token[COOKIE_VALUE_MAX] = {0};
+  if (cookie_header &&
+      auth_parse_cookie(cookie_header, AUTH_API_SESSION_COOKIE_NAME,
+                        session_token, sizeof(session_token)) &&
+      session_token[0] != '\0')
+  {
+    char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+    if (Auth_Crypto_Token_Digest(session_token, token_digest,
+                                 sizeof(token_digest)) == AUTH_CRYPTO_OK)
+    {
+      Auth_Session_Record session;
+      Auth_User_Record    user;
+      Auth_Store_Result result = Auth_Store_Find_Session(
+          g_auth_store, token_digest, now, &session, &user);
+
+      if (result == AUTH_STORE_OK)
+      {
+        Auth_Store_Touch_Session(
+            g_auth_store, token_digest, now, g_session_idle_ttl);
+
+        p_principal->kind = AUTH_PRINCIPAL_USER;
+        strncpy(p_principal->user_id, user.id, sizeof(p_principal->user_id) - 1);
+        strncpy(p_principal->username, user.username, sizeof(p_principal->username) - 1);
+        strncpy(p_principal->role, user.role, sizeof(p_principal->role) - 1);
+        p_principal->must_change_password = user.must_change_password;
+        strncpy(p_principal->_binding, token_digest, sizeof(p_principal->_binding) - 1);
+        if (!auth_derive_csrf(token_digest, p_principal->csrf_token,
+                              sizeof(p_principal->csrf_token)))
+        {
+          OPENSSL_cleanse(session_token, sizeof(session_token));
+          OPENSSL_cleanse(token_digest, sizeof(token_digest));
+          memset(p_principal, 0, sizeof(*p_principal));
+          return FALSE;
+        }
+        OPENSSL_cleanse(session_token, sizeof(session_token));
+        OPENSSL_cleanse(token_digest, sizeof(token_digest));
+        *p_found = TRUE;
+        return TRUE;
+      }
+    }
+    OPENSSL_cleanse(session_token, sizeof(session_token));
+  }
+
+  /* --- Try existing guest cookie (no new guest created) --- */
+  char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0};
+  boolean have_ip = auth_peer_ip(p_request, peer_ip, sizeof(peer_ip));
+
+  char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0};
+  if (have_ip &&
+      Auth_Crypto_IP_Binding_Digest(
+          g_cookie_secret, g_cookie_secret_length,
+          peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+    return FALSE;
+  }
+
+  char guest_cookie_val[COOKIE_VALUE_MAX] = {0};
+  Auth_Crypto_Guest_Cookie guest_parsed;
+  memset(&guest_parsed, 0, sizeof(guest_parsed));
+
+  if (cookie_header &&
+      auth_parse_cookie(cookie_header, AUTH_API_GUEST_COOKIE_NAME,
+                        guest_cookie_val, sizeof(guest_cookie_val)) &&
+      guest_cookie_val[0] != '\0' &&
+      have_ip)
+  {
+    Auth_Crypto_Result cr = Auth_Crypto_Guest_Cookie_Verify(
+        g_cookie_secret, g_cookie_secret_length,
+        guest_cookie_val, (uint64)now,
+        ip_binding, &guest_parsed);
+    if (cr == AUTH_CRYPTO_OK)
+    {
+      Auth_Guest_Identity_Record identity;
+      Auth_Store_Result result = Auth_Store_Find_Guest_Identity(
+          g_auth_store, guest_parsed.guest_uuid, now, &identity);
+
+      if (result == AUTH_STORE_OK)
+      {
+        Auth_Store_Upsert_Guest_Identity(
+            g_auth_store, guest_parsed.guest_uuid,
+            ip_binding, now + g_guest_ttl, &identity);
+
+        p_principal->kind = AUTH_PRINCIPAL_GUEST;
+        strncpy(p_principal->guest_id, guest_parsed.guest_uuid,
+                sizeof(p_principal->guest_id) - 1);
+        strncpy(p_principal->_binding, guest_parsed.guest_uuid,
+                sizeof(p_principal->_binding) - 1);
+        if (!auth_derive_csrf(guest_parsed.guest_uuid, p_principal->csrf_token,
+                              sizeof(p_principal->csrf_token)))
+        {
+          OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val));
+          OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+          memset(p_principal, 0, sizeof(*p_principal));
+          return FALSE;
+        }
+        OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val));
+        OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+        *p_found = TRUE;
+        return TRUE;
+      }
+    }
+  }
+  OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val));
+  OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+
+  /* No existing identity found — caller should return 401.
+   * We do NOT create a guest row or generate a Set-Cookie directive. */
+  *p_found = FALSE;
+  return TRUE;
+}
+
+/* ------------------------------------------------------------------ */
+/* CSRF verification helper                                             */
+/* ------------------------------------------------------------------ */
+
+/*
+ * Verify a CSRF token provided by the client against the binding for
+ * the current session/guest.  Comparison is by SHA-256 digest equality
+ * to avoid timing-oracle attacks on the base64url token directly.
+ */
+static boolean auth_verify_csrf(
+    const char *provided_token,
+    const char *binding)
+{
+  if (!provided_token || !binding || provided_token[0] == '\0')
+    return FALSE;
+
+  char expected[AUTH_CRYPTO_TOKEN_SIZE];
+  if (!auth_derive_csrf(binding, expected, sizeof(expected)))
+    return FALSE;
+
+  /* Compare SHA-256 digests of both tokens (constant-time length comparison) */
+  char digest_provided[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+  char digest_expected[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+
+  if (Auth_Crypto_Token_Digest(provided_token, digest_provided,
+                                sizeof(digest_provided)) != AUTH_CRYPTO_OK ||
+      Auth_Crypto_Token_Digest(expected, digest_expected,
+                                sizeof(digest_expected)) != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(expected, sizeof(expected));
+    OPENSSL_cleanse(digest_provided, sizeof(digest_provided));
+    OPENSSL_cleanse(digest_expected, sizeof(digest_expected));
+    return FALSE;
+  }
+
+  int match = CRYPTO_memcmp(digest_provided, digest_expected,
+                             sizeof(digest_provided));
+  OPENSSL_cleanse(expected, sizeof(expected));
+  OPENSSL_cleanse(digest_provided, sizeof(digest_provided));
+  OPENSSL_cleanse(digest_expected, sizeof(digest_expected));
+  return match == 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Public: Auth_API_Verify_CSRF                                         */
+/* ------------------------------------------------------------------ */
+
+boolean Auth_API_Verify_CSRF(
+    Seobeo_Request_Entry *p_request,
+    const Auth_Principal *p_principal)
+{
+  if (!p_request || !p_principal)
+    return FALSE;
+  if (!auth_same_origin(p_request))
+    return FALSE;
+  const char *csrf = auth_req_value(p_request, "X-CSRF-Token");
+  if (!csrf || csrf[0] == '\0')
+    return FALSE;
+  return auth_verify_csrf(csrf, p_principal->_binding);
+}
+
+/* ------------------------------------------------------------------ */
+/* Route: GET /api/auth/session                                         */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *auth_session_handler(
+    Seobeo_Request_Entry *p_req,
+    Dowa_Arena            *p_arena)
+{
+  if (!g_auth_store)
+    return auth_error(p_arena, "503", "service_unavailable",
+                      "Auth not initialised");
+
+  Auth_Principal principal;
+  char new_guest_cookie[GUEST_COOKIE_MAX] = {0};
+
+  if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena,
+                                   new_guest_cookie, sizeof(new_guest_cookie)))
+    return auth_error(p_arena, "500", "internal_error", "Session error");
+
+  char body[2048];
+  if (principal.kind == AUTH_PRINCIPAL_USER)
+  {
+    char *safe_username =
+        Dowa_JSON_Escape_String(principal.username, 0, p_arena);
+    char *safe_role =
+        Dowa_JSON_Escape_String(principal.role, 0, p_arena);
+    char *safe_csrf =
+        Dowa_JSON_Escape_String(principal.csrf_token, 0, p_arena);
+    if (!safe_username || !safe_role || !safe_csrf)
+      return auth_error(p_arena, "500", "internal_error", "Encode error");
+
+    snprintf(body, sizeof(body),
+             "{\"kind\":\"user\",\"username\":\"%s\",\"role\":\"%s\","
+             "\"mustChangePassword\":%s,\"csrfToken\":\"%s\","
+             "\"quota\":null}",
+             safe_username, safe_role,
+             principal.must_change_password ? "true" : "false",
+             safe_csrf);
+  }
+  else
+  {
+    char *safe_csrf =
+        Dowa_JSON_Escape_String(principal.csrf_token, 0, p_arena);
+    if (!safe_csrf)
+      return auth_error(p_arena, "500", "internal_error", "Encode error");
+
+    /* Ask conversation layer for quota JSON (null if not registered). */
+    char quota_json[512] = "null";
+    if (g_guest_quota_cb)
+      g_guest_quota_cb(principal.guest_id, auth_now(), quota_json,
+                       sizeof(quota_json));
+
+    snprintf(body, sizeof(body),
+             "{\"kind\":\"guest\",\"csrfToken\":\"%s\",\"quota\":%s}",
+             safe_csrf, quota_json);
+  }
+
+  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body) + 1);
+  if (!body_copy)
+    return auth_error(p_arena, "500", "internal_error", "OOM");
+  strcpy(body_copy, body);
+
+  const char *cookie1 = (new_guest_cookie[0] != '\0') ? new_guest_cookie : NULL;
+  return auth_json_response_with_cookies(
+      p_arena, "200", body_copy, cookie1, NULL);
+}
+
+/* ------------------------------------------------------------------ */
+/* Route: POST /api/auth/login                                          */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *auth_login_handler(
+    Seobeo_Request_Entry *p_req,
+    Dowa_Arena            *p_arena)
+{
+  if (!g_auth_store)
+    return auth_error(p_arena, "503", "service_unavailable",
+                      "Auth not initialised");
+
+  if (!auth_same_origin(p_req))
+    return auth_error(p_arena, "403", "forbidden", "Origin mismatch");
+
+  /* --- Parse body --- */
+  const char *body_str = auth_req_value(p_req, "Body");
+  if (!body_str)
+    return auth_error(p_arena, "400", "bad_request", "Invalid body");
+
+  size_t body_len = strlen(body_str);
+  if (body_len > BODY_MAX_BYTES)
+  {
+    OPENSSL_cleanse((char *)body_str, body_len);
+    return auth_error(p_arena, "400", "bad_request", "Invalid 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 auth_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 *csrf_provided = Dowa_JSON_Get_String(obj, "csrfToken");
+  char password_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1];
+  memset(password_buf, 0, sizeof(password_buf));
+  boolean have_password = auth_extract_secret_field(
+      obj, "password", password_buf, AUTH_CRYPTO_PASSWORD_MAX_BYTES);
+  char *password_raw = password_buf;
+
+  if (!username_raw || !have_password || !csrf_provided ||
+      username_raw[0] == '\0' ||
+      csrf_provided[0] == '\0')
+  {
+    OPENSSL_cleanse(password_buf, sizeof(password_buf));
+    return auth_error(p_arena, "400", "bad_request", "Missing fields");
+  }
+
+  /* --- Password length bounds --- */
+  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 auth_error(p_arena, "401", "invalid_credentials",
+                      "Invalid credentials");
+  }
+
+  /* --- Resolve existing principal for CSRF binding --- */
+  Auth_Principal principal;
+  char ignored_cookie[GUEST_COOKIE_MAX] = {0};
+  if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena,
+                                   ignored_cookie, sizeof(ignored_cookie)))
+  {
+    OPENSSL_cleanse(password_raw, pw_len);
+    return auth_error(p_arena, "500", "internal_error", "Session error");
+  }
+
+  /* --- CSRF check --- */
+  if (!auth_verify_csrf(csrf_provided, principal._binding))
+  {
+    OPENSSL_cleanse(password_raw, pw_len);
+    return auth_error(p_arena, "403", "csrf_invalid", "CSRF token invalid");
+  }
+
+  /* --- Peer IP and rate-limit key --- */
+  char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0};
+  boolean have_ip = auth_peer_ip(p_req, peer_ip, sizeof(peer_ip));
+
+  char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0};
+  if (have_ip &&
+      Auth_Crypto_IP_Binding_Digest(
+          g_cookie_secret, g_cookie_secret_length,
+          peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+    OPENSSL_cleanse(password_raw, pw_len);
+    return auth_error(p_arena, "500", "internal_error", "Binding error");
+  }
+  OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+
+  /* Normalize username */
+  char norm_username[AUTH_STORE_USERNAME_MAX + 1] = {0};
+  if (!Auth_Store_Normalize_Username(
+          username_raw, norm_username, sizeof(norm_username)))
+  {
+    OPENSSL_cleanse(password_raw, pw_len);
+    return auth_error(p_arena, "401", "invalid_credentials",
+                      "Invalid credentials");
+  }
+
+  /* Rate limit check */
+  char rate_key[RATE_KEY_MAX] = {0};
+  boolean have_rate_key =
+      auth_rate_key(ip_binding, norm_username, rate_key);
+
+  if (have_rate_key && auth_rate_check(rate_key))
+  {
+    OPENSSL_cleanse(password_raw, pw_len);
+    OPENSSL_cleanse(norm_username, sizeof(norm_username));
+    return auth_error(p_arena, "429", "too_many_requests",
+                      "Too many login attempts");
+  }
+
+  /* --- Fetch user record --- */
+  Auth_User_Auth_Record auth_record;
+  memset(&auth_record, 0, sizeof(auth_record));
+  Auth_Store_Result find_result =
+      Auth_Store_Find_User_By_Username(
+          g_auth_store, norm_username, &auth_record);
+
+  if (find_result != AUTH_STORE_OK)
+  {
+    /*
+     * User not found; run exactly one scrypt verification against a fixed
+     * precomputed hash to consume constant time, then return a generic error.
+     * AUTH_DUMMY_PASSWORD_HASH is a valid zenbu-scrypt hash of a known
+     * constant string — it is never an account credential.
+     */
+    Auth_Crypto_Password_Verify(password_raw, AUTH_DUMMY_PASSWORD_HASH);
+    OPENSSL_cleanse(password_raw, pw_len);
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    if (have_rate_key)
+      auth_rate_record_failure(rate_key);
+    return auth_error(p_arena, "401", "invalid_credentials",
+                      "Invalid credentials");
+  }
+
+  if (strcmp(auth_record.user.status, "active") != 0)
+  {
+    Auth_Crypto_Password_Verify(password_raw, auth_record.password_hash);
+    OPENSSL_cleanse(password_raw, pw_len);
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    if (have_rate_key)
+      auth_rate_record_failure(rate_key);
+    return auth_error(p_arena, "401", "invalid_credentials",
+                      "Invalid credentials");
+  }
+
+  /* --- Verify password --- */
+  Auth_Crypto_Result verify =
+      Auth_Crypto_Password_Verify(password_raw, auth_record.password_hash);
+  OPENSSL_cleanse(password_raw, pw_len);
+
+  if (verify != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    if (have_rate_key)
+      auth_rate_record_failure(rate_key);
+    return auth_error(p_arena, "401", "invalid_credentials",
+                      "Invalid credentials");
+  }
+
+  /* Successful authentication: reset rate limit */
+  if (have_rate_key)
+    auth_rate_reset(rate_key);
+
+  /* --- Create new session (prevents session fixation) --- */
+  char new_token[AUTH_CRYPTO_TOKEN_SIZE];
+  if (Auth_Crypto_Token_Generate(new_token, sizeof(new_token)) !=
+      AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    return auth_error(p_arena, "500", "internal_error", "Token error");
+  }
+
+  char new_token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+  if (Auth_Crypto_Token_Digest(new_token, new_token_digest,
+                                sizeof(new_token_digest)) != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    OPENSSL_cleanse(new_token, sizeof(new_token));
+    return auth_error(p_arena, "500", "internal_error", "Digest error");
+  }
+
+  /* CSRF for this new session */
+  char new_csrf[AUTH_CRYPTO_TOKEN_SIZE];
+  if (!auth_derive_csrf(new_token_digest, new_csrf, sizeof(new_csrf)))
+  {
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    OPENSSL_cleanse(new_token, sizeof(new_token));
+    OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest));
+    return auth_error(p_arena, "500", "internal_error", "CSRF error");
+  }
+
+  char csrf_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+  if (Auth_Crypto_Token_Digest(new_csrf, csrf_digest,
+                                sizeof(csrf_digest)) != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    OPENSSL_cleanse(new_token, sizeof(new_token));
+    OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest));
+    OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
+    return auth_error(p_arena, "500", "internal_error", "CSRF digest error");
+  }
+
+  int64 now = auth_now();
+  Auth_Session_Record session;
+#ifdef AUTH_API_TEST_HOOKS
+  if (g_login_pre_create_hook)
+    g_login_pre_create_hook(g_login_pre_create_context);
+#endif
+  Auth_Store_Result create_result = Auth_Store_Create_Session_CAS(
+      g_auth_store,
+      auth_record.user.id,
+      auth_record.password_hash,
+      new_token_digest,
+      csrf_digest,
+      g_session_idle_ttl,
+      g_session_abs_ttl,
+      now,
+      &session);
+
+  OPENSSL_cleanse(auth_record.password_hash,
+                  sizeof(auth_record.password_hash));
+  OPENSSL_cleanse(csrf_digest, sizeof(csrf_digest));
+  OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest));
+
+  if (create_result != AUTH_STORE_OK)
+  {
+    OPENSSL_cleanse(new_token, sizeof(new_token));
+    OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
+    if (create_result == AUTH_STORE_STALE_PASSWORD)
+      return auth_error(p_arena, "401", "invalid_credentials",
+                        "Invalid credentials");
+    return auth_error(p_arena, "500", "internal_error", "Session create failed");
+  }
+
+  /* --- Call guest transfer hook (before clearing guest state) --- */
+  if (g_transfer_hook &&
+      principal.kind == AUTH_PRINCIPAL_GUEST &&
+      principal.guest_id[0] != '\0')
+  {
+    boolean transferred = g_transfer_hook(
+        principal.guest_id, auth_record.user.id, g_transfer_hook_ctx);
+    if (!transferred)
+    {
+      /* Transfer failed — revoke the new session and return 500.
+       * Guest cookie/data are preserved (no clear_guest in response). */
+      char rev_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+      if (Auth_Crypto_Token_Digest(new_token, rev_digest,
+                                   sizeof(rev_digest)) == AUTH_CRYPTO_OK)
+      {
+        Auth_Store_Result revoke_result =
+            Auth_Store_Revoke_Session(g_auth_store, rev_digest);
+        if (revoke_result == AUTH_STORE_ERROR)
+          Seobeo_Log(SEOBEO_ERROR,
+                     "[AUTH] Failed to revoke session after transfer failure\n");
+        OPENSSL_cleanse(rev_digest, sizeof(rev_digest));
+      }
+      OPENSSL_cleanse(new_token, sizeof(new_token));
+      OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
+      return auth_error(p_arena, "500", "transfer_failed",
+                        "Resource transfer failed");
+    }
+  }
+
+  /* --- Build response --- */
+  char *safe_username =
+      Dowa_JSON_Escape_String(auth_record.user.username, 0, p_arena);
+  char *safe_role =
+      Dowa_JSON_Escape_String(auth_record.user.role, 0, p_arena);
+  char *safe_csrf =
+      Dowa_JSON_Escape_String(new_csrf, 0, p_arena);
+  OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
+
+  if (!safe_username || !safe_role || !safe_csrf)
+  {
+    OPENSSL_cleanse(new_token, sizeof(new_token));
+    return auth_error(p_arena, "500", "internal_error", "Encode error");
+  }
+
+  char body_buf[512];
+  snprintf(body_buf, sizeof(body_buf),
+           "{\"kind\":\"user\",\"username\":\"%s\",\"role\":\"%s\","
+           "\"mustChangePassword\":%s,\"csrfToken\":\"%s\","
+           "\"quota\":null}",
+           safe_username, safe_role,
+           auth_record.user.must_change_password ? "true" : "false",
+           safe_csrf);
+
+  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1);
+  if (!body_copy)
+  {
+    OPENSSL_cleanse(new_token, sizeof(new_token));
+    return auth_error(p_arena, "500", "internal_error", "OOM");
+  }
+  strcpy(body_copy, body_buf);
+
+  /* Session cookie */
+  char session_cookie[SESSION_COOKIE_MAX];
+  auth_build_cookie_directive(
+      AUTH_API_SESSION_COOKIE_NAME, new_token, 0,
+      TRUE, session_cookie, sizeof(session_cookie));
+  OPENSSL_cleanse(new_token, sizeof(new_token));
+
+  char *session_cookie_copy =
+      Dowa_Arena_Allocate(p_arena, strlen(session_cookie) + 1);
+  if (session_cookie_copy) strcpy(session_cookie_copy, session_cookie);
+  OPENSSL_cleanse(session_cookie, sizeof(session_cookie));
+
+  /* Clear guest cookie */
+  char clear_guest[256];
+  auth_build_cookie_directive(
+      AUTH_API_GUEST_COOKIE_NAME, "", -1,
+      TRUE, clear_guest, sizeof(clear_guest));
+
+  char *clear_guest_copy =
+      Dowa_Arena_Allocate(p_arena, strlen(clear_guest) + 1);
+  if (clear_guest_copy) strcpy(clear_guest_copy, clear_guest);
+
+  return auth_json_response_with_cookies(
+      p_arena, "200", body_copy,
+      session_cookie_copy, clear_guest_copy);
+}
+
+/* ------------------------------------------------------------------ */
+/* Route: POST /api/auth/logout                                         */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *auth_logout_handler(
+    Seobeo_Request_Entry *p_req,
+    Dowa_Arena            *p_arena)
+{
+  if (!g_auth_store)
+    return auth_error(p_arena, "503", "service_unavailable",
+                      "Auth not initialised");
+
+  if (!auth_same_origin(p_req))
+    return auth_error(p_arena, "403", "forbidden", "Origin mismatch");
+
+  /* --- Resolve current principal for CSRF binding --- */
+  Auth_Principal principal;
+  char ignored_cookie[GUEST_COOKIE_MAX] = {0};
+  if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena,
+                                   ignored_cookie, sizeof(ignored_cookie)))
+    return auth_error(p_arena, "500", "internal_error", "Session error");
+
+  /* --- CSRF check --- */
+  const char *body_str = auth_req_value(p_req, "Body");
+  char csrf_provided[AUTH_CRYPTO_TOKEN_SIZE] = {0};
+  if (body_str && strlen(body_str) <= BODY_MAX_BYTES)
+  {
+    Dowa_JSON_Value jv =
+        Dowa_JSON_Parse(body_str, (int32)strlen(body_str), p_arena);
+    if (jv.type == DOWA_JSON_OBJECT)
+    {
+      char *t = Dowa_JSON_Get_String((Dowa_JSON_Entry *)jv.object_val,
+                                     "csrfToken");
+      if (t)
+        strncpy(csrf_provided, t,
+                sizeof(csrf_provided) - 1);
+    }
+  }
+
+  /* Also accept CSRF from X-CSRF-Token header */
+  if (csrf_provided[0] == '\0')
+  {
+    const char *hdr = auth_req_value(p_req, "X-CSRF-Token");
+    if (hdr)
+      strncpy(csrf_provided, hdr, sizeof(csrf_provided) - 1);
+  }
+
+  if (!auth_verify_csrf(csrf_provided, principal._binding))
+    return auth_error(p_arena, "403", "csrf_invalid", "CSRF token invalid");
+
+  /* --- Revoke session if authenticated --- */
+  const char *cookie_hdr = auth_req_value(p_req, "Cookie");
+  char session_token[COOKIE_VALUE_MAX] = {0};
+  if (cookie_hdr &&
+      auth_parse_cookie(cookie_hdr, AUTH_API_SESSION_COOKIE_NAME,
+                        session_token, sizeof(session_token)) &&
+      session_token[0] != '\0')
+  {
+    char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE] = {0};
+    Auth_Crypto_Result digest_result =
+        Auth_Crypto_Token_Digest(
+            session_token, token_digest, sizeof(token_digest));
+    if (digest_result == AUTH_CRYPTO_OK)
+    {
+      Auth_Store_Result revoke_result =
+          Auth_Store_Revoke_Session(g_auth_store, token_digest);
+      OPENSSL_cleanse(token_digest, sizeof(token_digest));
+      if (revoke_result == AUTH_STORE_ERROR)
+      {
+        OPENSSL_cleanse(session_token, sizeof(session_token));
+        return auth_error(p_arena, "500", "internal_error", "Logout failed");
+      }
+    }
+    else
+    {
+      OPENSSL_cleanse(token_digest, sizeof(token_digest));
+      OPENSSL_cleanse(session_token, sizeof(session_token));
+      return auth_error(p_arena, "500", "internal_error", "Logout failed");
+    }
+    OPENSSL_cleanse(session_token, sizeof(session_token));
+  }
+
+  /* --- Create fresh guest identity --- */
+  char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0};
+  boolean have_ip = auth_peer_ip(p_req, peer_ip, sizeof(peer_ip));
+
+  char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0};
+  if (have_ip &&
+      Auth_Crypto_IP_Binding_Digest(
+          g_cookie_secret, g_cookie_secret_length,
+          peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+    return auth_error(p_arena, "500", "internal_error", "Binding error");
+  }
+  OPENSSL_cleanse(peer_ip, sizeof(peer_ip));
+
+  char guest_uuid[AUTH_CRYPTO_GUEST_UUID_SIZE] = {0};
+  if (!auth_uuid4(guest_uuid, sizeof(guest_uuid)))
+    return auth_error(p_arena, "500", "internal_error", "UUID error");
+
+  int64 now = auth_now();
+  int64 guest_expires = now + g_guest_ttl;
+  Auth_Guest_Identity_Record new_identity;
+  Auth_Store_Upsert_Guest_Identity(
+      g_auth_store, guest_uuid, ip_binding, guest_expires, &new_identity);
+
+  char new_csrf[AUTH_CRYPTO_TOKEN_SIZE] = {0};
+  if (!auth_derive_csrf(guest_uuid, new_csrf, sizeof(new_csrf)))
+    return auth_error(p_arena, "500", "internal_error", "CSRF error");
+
+  char signed_cookie[AUTH_CRYPTO_GUEST_COOKIE_SIZE] = {0};
+  char new_guest_cookie[GUEST_COOKIE_MAX] = {0};
+  if (have_ip &&
+      Auth_Crypto_Guest_Cookie_Create(
+          g_cookie_secret, g_cookie_secret_length,
+          guest_uuid, (uint64)guest_expires, ip_binding,
+          signed_cookie, sizeof(signed_cookie)) == AUTH_CRYPTO_OK)
+  {
+    auth_build_cookie_directive(
+        AUTH_API_GUEST_COOKIE_NAME, signed_cookie,
+        (int32)g_guest_ttl, TRUE,
+        new_guest_cookie, sizeof(new_guest_cookie));
+    OPENSSL_cleanse(signed_cookie, sizeof(signed_cookie));
+  }
+
+  /* --- Build response --- */
+  char *safe_csrf = Dowa_JSON_Escape_String(new_csrf, 0, p_arena);
+  OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
+  if (!safe_csrf)
+    return auth_error(p_arena, "500", "internal_error", "Encode error");
+
+  char body_buf[256];
+  snprintf(body_buf, sizeof(body_buf),
+           "{\"kind\":\"guest\",\"csrfToken\":\"%s\",\"quota\":null}",
+           safe_csrf);
+
+  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1);
+  if (!body_copy)
+    return auth_error(p_arena, "500", "internal_error", "OOM");
+  strcpy(body_copy, body_buf);
+
+  /* Clear session cookie */
+  char clear_session[256];
+  auth_build_cookie_directive(
+      AUTH_API_SESSION_COOKIE_NAME, "", -1,
+      TRUE, clear_session, sizeof(clear_session));
+
+  char *clear_session_copy =
+      Dowa_Arena_Allocate(p_arena, strlen(clear_session) + 1);
+  if (!clear_session_copy)
+    return auth_error(p_arena, "500", "internal_error", "OOM");
+  strcpy(clear_session_copy, clear_session);
+
+  char *new_guest_copy = NULL;
+  if (new_guest_cookie[0] != '\0')
+  {
+    new_guest_copy =
+        Dowa_Arena_Allocate(p_arena, strlen(new_guest_cookie) + 1);
+    if (!new_guest_copy)
+      return auth_error(p_arena, "500", "internal_error", "OOM");
+    strcpy(new_guest_copy, new_guest_cookie);
+  }
+
+  return auth_json_response_with_cookies(
+      p_arena, "200", body_copy,
+      clear_session_copy,
+      new_guest_copy);
+}
+
+/* ------------------------------------------------------------------ */
+/* Route: POST /api/auth/password                                       */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *auth_password_handler(
+    Seobeo_Request_Entry *p_req,
+    Dowa_Arena            *p_arena)
+{
+  if (!g_auth_store)
+    return auth_error(p_arena, "503", "service_unavailable",
+                      "Auth not initialised");
+
+  if (!auth_same_origin(p_req))
+    return auth_error(p_arena, "403", "forbidden", "Origin mismatch");
+
+  /* --- Resolve principal — must be authenticated user --- */
+  Auth_Principal principal;
+  char ignored_cookie[GUEST_COOKIE_MAX] = {0};
+  if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena,
+                                   ignored_cookie, sizeof(ignored_cookie)))
+    return auth_error(p_arena, "500", "internal_error", "Session error");
+
+  if (principal.kind != AUTH_PRINCIPAL_USER)
+    return auth_error(p_arena, "401", "unauthenticated",
+                      "Authentication required");
+
+  /* --- Parse body --- */
+  const char *body_str = auth_req_value(p_req, "Body");
+  if (!body_str)
+    return auth_error(p_arena, "400", "bad_request", "Invalid body");
+
+  size_t body_len = strlen(body_str);
+  if (body_len > BODY_MAX_BYTES)
+  {
+    OPENSSL_cleanse((char *)body_str, body_len);
+    return auth_error(p_arena, "400", "bad_request", "Invalid 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 auth_error(p_arena, "400", "bad_request", "Expected JSON object");
+
+  Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val;
+  char *csrf_provided = Dowa_JSON_Get_String(obj, "csrfToken");
+  char current_pw_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1];
+  char new_pw_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1];
+  memset(current_pw_buf, 0, sizeof(current_pw_buf));
+  memset(new_pw_buf, 0, sizeof(new_pw_buf));
+  boolean have_current = auth_extract_secret_field(
+      obj, "currentPassword", current_pw_buf,
+      AUTH_CRYPTO_PASSWORD_MAX_BYTES);
+  boolean have_new = auth_extract_secret_field(
+      obj, "newPassword", new_pw_buf, AUTH_CRYPTO_PASSWORD_MAX_BYTES);
+  char *current_pw_raw = current_pw_buf;
+  char *new_pw_raw = new_pw_buf;
+
+  /* Also accept CSRF from header */
+  if (!csrf_provided || csrf_provided[0] == '\0')
+    csrf_provided = (char *)auth_req_value(p_req, "X-CSRF-Token");
+
+  if (!have_current || !have_new || !csrf_provided)
+  {
+    OPENSSL_cleanse(current_pw_buf, sizeof(current_pw_buf));
+    OPENSSL_cleanse(new_pw_buf, sizeof(new_pw_buf));
+    return auth_error(p_arena, "400", "bad_request", "Missing fields");
+  }
+
+  /* --- CSRF check --- */
+  if (!auth_verify_csrf(csrf_provided, principal._binding))
+  {
+    OPENSSL_cleanse(current_pw_buf, sizeof(current_pw_buf));
+    OPENSSL_cleanse(new_pw_buf, sizeof(new_pw_buf));
+    return auth_error(p_arena, "403", "csrf_invalid", "CSRF token invalid");
+  }
+
+  /* --- Password policy --- */
+  size_t new_pw_len = strlen(new_pw_raw);
+  size_t cur_pw_len = strlen(current_pw_raw);
+  if (new_pw_len < 12 || new_pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES ||
+      cur_pw_len  < 1  || cur_pw_len  > AUTH_CRYPTO_PASSWORD_MAX_BYTES)
+  {
+    OPENSSL_cleanse(current_pw_raw, cur_pw_len);
+    OPENSSL_cleanse(new_pw_raw, new_pw_len);
+    return auth_error(p_arena, "400", "password_policy",
+                      "New password must be at least 12 characters");
+  }
+
+  /* --- Fetch user with password hash --- */
+  char norm_username[AUTH_STORE_USERNAME_MAX + 1];
+  if (!Auth_Store_Normalize_Username(
+          principal.username, norm_username, sizeof(norm_username)))
+  {
+    OPENSSL_cleanse(current_pw_raw, cur_pw_len);
+    OPENSSL_cleanse(new_pw_raw, new_pw_len);
+    return auth_error(p_arena, "500", "internal_error", "Username error");
+  }
+
+  Auth_User_Auth_Record auth_record;
+  memset(&auth_record, 0, sizeof(auth_record));
+  if (Auth_Store_Find_User_By_Username(
+          g_auth_store, norm_username, &auth_record) != AUTH_STORE_OK)
+  {
+    OPENSSL_cleanse(current_pw_raw, cur_pw_len);
+    OPENSSL_cleanse(new_pw_raw, new_pw_len);
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    return auth_error(p_arena, "401", "invalid_credentials",
+                      "Invalid credentials");
+  }
+
+  /* --- Verify current password --- */
+  Auth_Crypto_Result verify =
+      Auth_Crypto_Password_Verify(current_pw_raw, auth_record.password_hash);
+  OPENSSL_cleanse(current_pw_raw, cur_pw_len);
+
+  if (verify != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    OPENSSL_cleanse(new_pw_raw, new_pw_len);
+    return auth_error(p_arena, "401", "invalid_credentials",
+                      "Invalid credentials");
+  }
+
+  /* --- Hash new password --- */
+  char new_encoded_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  Auth_Crypto_Result hash_result = Auth_Crypto_Password_Hash(
+      new_pw_raw, new_encoded_hash, sizeof(new_encoded_hash));
+  OPENSSL_cleanse(new_pw_raw, new_pw_len);
+
+  if (hash_result != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash));
+    return auth_error(p_arena, "500", "internal_error", "Hash error");
+  }
+
+  /* Generate the replacement session before entering the transaction. */
+  char new_token[AUTH_CRYPTO_TOKEN_SIZE];
+  if (Auth_Crypto_Token_Generate(new_token, sizeof(new_token)) !=
+      AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash));
+    return auth_error(p_arena, "500", "internal_error", "Token error");
+  }
+
+  char new_token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+  if (Auth_Crypto_Token_Digest(new_token, new_token_digest,
+                                sizeof(new_token_digest)) != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash));
+    OPENSSL_cleanse(new_token, sizeof(new_token));
+    return auth_error(p_arena, "500", "internal_error", "Digest error");
+  }
+
+  char new_csrf[AUTH_CRYPTO_TOKEN_SIZE];
+  if (!auth_derive_csrf(new_token_digest, new_csrf, sizeof(new_csrf)))
+  {
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash));
+    OPENSSL_cleanse(new_token, sizeof(new_token));
+    OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest));
+    return auth_error(p_arena, "500", "internal_error", "CSRF error");
+  }
+
+  char new_csrf_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+  if (Auth_Crypto_Token_Digest(new_csrf, new_csrf_digest,
+                                sizeof(new_csrf_digest)) != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(auth_record.password_hash,
+                    sizeof(auth_record.password_hash));
+    OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash));
+    OPENSSL_cleanse(new_token, sizeof(new_token));
+    OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest));
+    OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
+    return auth_error(p_arena, "500", "internal_error", "CSRF digest error");
+  }
+
+  int64 now = auth_now();
+  Auth_Session_Record new_session;
+  Auth_Store_Result password_result = Auth_Store_Self_Change_Password(
+      g_auth_store,
+      principal.user_id,
+      auth_record.password_hash,
+      new_encoded_hash,
+      new_token_digest,
+      new_csrf_digest,
+      g_session_idle_ttl,
+      g_session_abs_ttl,
+      now,
+      &new_session);
+
+  OPENSSL_cleanse(auth_record.password_hash,
+                  sizeof(auth_record.password_hash));
+  OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash));
+  OPENSSL_cleanse(new_csrf_digest, sizeof(new_csrf_digest));
+  OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest));
+
+  if (password_result != AUTH_STORE_OK)
+  {
+    OPENSSL_cleanse(new_token, sizeof(new_token));
+    OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
+    return auth_error(p_arena, "500", "internal_error",
+                      "Password update failed");
+  }
+
+  /* --- Build response --- */
+  char *safe_username =
+      Dowa_JSON_Escape_String(principal.username, 0, p_arena);
+  char *safe_role =
+      Dowa_JSON_Escape_String(principal.role, 0, p_arena);
+  char *safe_csrf =
+      Dowa_JSON_Escape_String(new_csrf, 0, p_arena);
+  OPENSSL_cleanse(new_csrf, sizeof(new_csrf));
+
+  if (!safe_username || !safe_role || !safe_csrf)
+  {
+    OPENSSL_cleanse(new_token, sizeof(new_token));
+    return auth_error(p_arena, "500", "internal_error", "Encode error");
+  }
+
+  char body_buf[512];
+  snprintf(body_buf, sizeof(body_buf),
+           "{\"kind\":\"user\",\"username\":\"%s\",\"role\":\"%s\","
+           "\"mustChangePassword\":false,\"csrfToken\":\"%s\","
+           "\"quota\":null}",
+           safe_username, safe_role, safe_csrf);
+
+  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1);
+  if (!body_copy)
+  {
+    OPENSSL_cleanse(new_token, sizeof(new_token));
+    return auth_error(p_arena, "500", "internal_error", "OOM");
+  }
+  strcpy(body_copy, body_buf);
+
+  /* Updated session cookie */
+  char session_cookie[SESSION_COOKIE_MAX];
+  auth_build_cookie_directive(
+      AUTH_API_SESSION_COOKIE_NAME, new_token, 0,
+      TRUE, session_cookie, sizeof(session_cookie));
+  OPENSSL_cleanse(new_token, sizeof(new_token));
+
+  char *session_cookie_copy =
+      Dowa_Arena_Allocate(p_arena, strlen(session_cookie) + 1);
+  if (session_cookie_copy) strcpy(session_cookie_copy, session_cookie);
+  OPENSSL_cleanse(session_cookie, sizeof(session_cookie));
+
+  return auth_json_response_with_cookies(
+      p_arena, "200", body_copy,
+      session_cookie_copy, NULL);
+}
+
+/* ------------------------------------------------------------------ */
+/* Route: GET /account/password                                         */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *auth_password_page_handler(
+    Seobeo_Request_Entry *p_req,
+    Dowa_Arena            *p_arena)
+{
+  (void)p_req;
+  char *body = Dowa_Arena_Allocate(p_arena, 128 * 1024);
+  if (!body || !Mjj_Template_Render_File(body, 128 * 1024, "/account/password.html", p_arena))
+  {
+    Seobeo_Request_Entry *resp = NULL;
+    Dowa_HashMap_Push_Arena(resp, "status", "500", p_arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain; charset=utf-8", p_arena);
+    Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "Internal Server Error", p_arena);
+    return resp;
+  }
+
+  Seobeo_Request_Entry *resp = NULL;
+  Dowa_HashMap_Push_Arena(resp, "body", body, 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);
+  return resp;
+}
+
+/* ------------------------------------------------------------------ */
+/* Public API                                                           */
+/* ------------------------------------------------------------------ */
+
+boolean Auth_API_Is_Forced_Password_Change_Only(const char *http_path)
+{
+  if (!http_path) return FALSE;
+  return strcmp(http_path, AUTH_API_PATH_SESSION)       == 0 ||
+         strcmp(http_path, AUTH_API_PATH_LOGIN)         == 0 ||
+         strcmp(http_path, AUTH_API_PATH_LOGOUT)        == 0 ||
+         strcmp(http_path, AUTH_API_PATH_PASSWORD)      == 0 ||
+         strcmp(http_path, AUTH_API_PATH_PASSWORD_PAGE) == 0;
+}
+
+void Auth_API_Register_Guest_Transfer_Hook(
+    Auth_Guest_Transfer_Hook hook,
+    void                    *context)
+{
+  g_transfer_hook     = hook;
+  g_transfer_hook_ctx = context;
+}
+
+void Auth_API_Register_Guest_Quota_Cb(Auth_API_Guest_Quota_Cb cb)
+{
+  g_guest_quota_cb = cb;
+}
+
+boolean Auth_API_Init(
+    const char  *database_path,
+    const uint8 *cookie_secret,
+    size_t       cookie_secret_length,
+    const char  *bootstrap_username,
+    const char  *bootstrap_password_hash,
+    const char  *trusted_proxy_ip,
+    int64        session_idle_ttl_secs,
+    int64        session_absolute_ttl_secs,
+    int64        guest_ttl_secs,
+    boolean      dev_insecure_cookie)
+{
+  /* Fail closed: cookie secret required */
+  if (!cookie_secret ||
+      cookie_secret_length < AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES ||
+      cookie_secret_length > AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES)
+  {
+    Seobeo_Log(SEOBEO_ERROR,
+               "[AUTH] Init failed: cookie secret missing or invalid length\n");
+    return FALSE;
+  }
+
+  if (!database_path || database_path[0] == '\0')
+  {
+    Seobeo_Log(SEOBEO_ERROR,
+               "[AUTH] Init failed: database path required\n");
+    return FALSE;
+  }
+
+  g_auth_store = Auth_Store_Create(database_path);
+  if (!g_auth_store)
+  {
+    Seobeo_Log(SEOBEO_ERROR, "[AUTH] Failed to open auth store\n");
+    return FALSE;
+  }
+
+  memcpy(g_cookie_secret, cookie_secret, cookie_secret_length);
+  g_cookie_secret_length = cookie_secret_length;
+
+  if (trusted_proxy_ip && trusted_proxy_ip[0] != '\0')
+  {
+    /* Canonicalize via inet_pton/inet_ntop; reject invalid addresses. */
+    struct in_addr  addr4;
+    struct in6_addr addr6;
+    char canonical[AUTH_CRYPTO_IP_MAX_BYTES];
+    canonical[0] = '\0';
+    if (inet_pton(AF_INET, trusted_proxy_ip, &addr4) == 1)
+    {
+      if (!inet_ntop(AF_INET, &addr4, canonical, sizeof(canonical)))
+      {
+        Seobeo_Log(SEOBEO_ERROR,
+                   "[AUTH] Init failed: trusted proxy IPv4 canonicalization\n");
+        Auth_Store_Destroy(g_auth_store);
+        g_auth_store = NULL;
+        return FALSE;
+      }
+    }
+    else if (inet_pton(AF_INET6, trusted_proxy_ip, &addr6) == 1)
+    {
+      if (!inet_ntop(AF_INET6, &addr6, canonical, sizeof(canonical)))
+      {
+        Seobeo_Log(SEOBEO_ERROR,
+                   "[AUTH] Init failed: trusted proxy IPv6 canonicalization\n");
+        Auth_Store_Destroy(g_auth_store);
+        g_auth_store = NULL;
+        return FALSE;
+      }
+    }
+    else
+    {
+      Seobeo_Log(SEOBEO_ERROR,
+                 "[AUTH] Init failed: trusted proxy is not a valid IP address\n");
+      Auth_Store_Destroy(g_auth_store);
+      g_auth_store = NULL;
+      return FALSE;
+    }
+    strncpy(g_trusted_proxy_ip, canonical, sizeof(g_trusted_proxy_ip) - 1);
+    g_trusted_proxy_ip[sizeof(g_trusted_proxy_ip) - 1] = '\0';
+    g_has_trusted_proxy = TRUE;
+  }
+
+  if (session_idle_ttl_secs > 0)
+    g_session_idle_ttl = session_idle_ttl_secs;
+  if (session_absolute_ttl_secs > 0)
+    g_session_abs_ttl = session_absolute_ttl_secs;
+  if (guest_ttl_secs > 0)
+    g_guest_ttl = guest_ttl_secs;
+
+  g_dev_insecure_cookie = dev_insecure_cookie;
+
+  /* Bootstrap admin — fail closed on any store error. */
+  if (bootstrap_username && bootstrap_username[0] != '\0' &&
+      bootstrap_password_hash && bootstrap_password_hash[0] != '\0')
+  {
+    Auth_Store_Bootstrap_Result bootstrap_result;
+    char bootstrap_id[37];
+    Auth_Store_Result r = Auth_Store_Bootstrap_Admin(
+        g_auth_store,
+        bootstrap_username,
+        bootstrap_password_hash,
+        &bootstrap_result,
+        bootstrap_id);
+
+    if (r == AUTH_STORE_OK &&
+        bootstrap_result == AUTH_STORE_BOOTSTRAP_CREATED)
+    {
+      Seobeo_Log(SEOBEO_INFO, "[AUTH] Bootstrap admin created\n");
+    }
+    else if (r == AUTH_STORE_OK &&
+             bootstrap_result == AUTH_STORE_BOOTSTRAP_ALREADY_PRESENT)
+    {
+      Seobeo_Log(SEOBEO_INFO, "[AUTH] Bootstrap admin already present\n");
+    }
+    else
+    {
+      Seobeo_Log(SEOBEO_ERROR,
+                 "[AUTH] Bootstrap admin failed: store error %d — refusing to start\n", r);
+      OPENSSL_cleanse(g_cookie_secret, sizeof(g_cookie_secret));
+      g_cookie_secret_length = 0;
+      g_has_trusted_proxy = FALSE;
+      Auth_Store_Destroy(g_auth_store);
+      g_auth_store = NULL;
+      return FALSE;
+    }
+  }
+
+  Seobeo_Log(SEOBEO_INFO, "[AUTH] Initialised (dev_insecure=%s)\n",
+             dev_insecure_cookie ? "yes" : "no");
+  return TRUE;
+}
+
+void Auth_API_Destroy(void)
+{
+  if (g_auth_store)
+  {
+    Auth_Store_Destroy(g_auth_store);
+    g_auth_store = NULL;
+  }
+  OPENSSL_cleanse(g_cookie_secret, sizeof(g_cookie_secret));
+  g_cookie_secret_length = 0;
+  g_has_trusted_proxy    = FALSE;
+  g_transfer_hook        = NULL;
+  g_transfer_hook_ctx    = NULL;
+  g_guest_quota_cb       = NULL;
+#ifdef AUTH_API_TEST_HOOKS
+  g_login_pre_create_hook = NULL;
+  g_login_pre_create_context = NULL;
+#endif
+}
+
+void Auth_API_Register_Routes(void)
+{
+  Seobeo_Router_Register("GET",  "/api/auth/session",  auth_session_handler);
+  Seobeo_Router_Register("POST", "/api/auth/login",    auth_login_handler);
+  Seobeo_Router_Register("POST", "/api/auth/logout",   auth_logout_handler);
+  Seobeo_Router_Register("POST", "/api/auth/password", auth_password_handler);
+  Seobeo_Router_Register("GET",  "/account/password",  auth_password_page_handler);
+}
+
+Auth_Store *Auth_API_Get_Store(void)
+{
+  return g_auth_store;
+}
+
+/* ------------------------------------------------------------------ */
+/* Test hooks (compiled in only for test builds)                        */
+/* ------------------------------------------------------------------ */
+
+#ifdef AUTH_API_TEST_HOOKS
+void Auth_API_Test_Set_Login_Pre_Create_Hook(
+    Auth_API_Test_Login_Pre_Create_Hook hook,
+    void                               *p_context)
+{
+  g_login_pre_create_hook = hook;
+  g_login_pre_create_context = p_context;
+}
+
+Seobeo_Request_Entry *Auth_API_Test_Session_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
+{ return auth_session_handler(p_req, p_arena); }
+
+Seobeo_Request_Entry *Auth_API_Test_Login_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
+{ return auth_login_handler(p_req, p_arena); }
+
+Seobeo_Request_Entry *Auth_API_Test_Logout_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
+{ return auth_logout_handler(p_req, p_arena); }
+
+Seobeo_Request_Entry *Auth_API_Test_Password_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
+{ return auth_password_handler(p_req, p_arena); }
+#endif /* AUTH_API_TEST_HOOKS */
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/auth_api.h	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,204 @@
+#ifndef MRJUNEJUNE_AUTH_API_H
+#define MRJUNEJUNE_AUTH_API_H
+
+#include "dowa/dowa.h"
+#include "auth/auth_store.h"
+#include "seobeo/seobeo.h"
+
+/* Cookie names */
+#define AUTH_API_SESSION_COOKIE_NAME "mjj_session"
+#define AUTH_API_GUEST_COOKIE_NAME   "mjj_guest"
+
+/* Default TTLs (seconds) */
+#define AUTH_API_SESSION_IDLE_TTL_DEFAULT  (7  * 24 * 3600)
+#define AUTH_API_SESSION_ABS_TTL_DEFAULT   (30 * 24 * 3600)
+#define AUTH_API_GUEST_TTL_DEFAULT         (30 * 24 * 3600)
+
+/* Auth-only paths permitted during forced-password-change */
+#define AUTH_API_PATH_SESSION       "/api/auth/session"
+#define AUTH_API_PATH_LOGIN         "/api/auth/login"
+#define AUTH_API_PATH_LOGOUT        "/api/auth/logout"
+#define AUTH_API_PATH_PASSWORD      "/api/auth/password"
+#define AUTH_API_PATH_PASSWORD_PAGE "/account/password"
+
+typedef enum {
+  AUTH_PRINCIPAL_GUEST = 0,
+  AUTH_PRINCIPAL_USER  = 1,
+} Auth_Principal_Kind;
+
+/*
+ * Resolved identity for a single request.
+ * For users:  user_id, username, role, must_change_password are valid.
+ * For guests: guest_id is valid.
+ * csrf_token: a derived CSRF token safe to return to the client (never stored
+ *             raw; only its digest appears in the store).
+ * _token_digest: internal session binding for CSRF derivation; not for logging.
+ */
+typedef struct {
+  Auth_Principal_Kind kind;
+
+  /* --- user fields --- */
+  char    user_id[37];
+  char    username[AUTH_STORE_USERNAME_MAX + 1];
+  char    role[8];
+  boolean must_change_password;
+
+  /* --- guest fields --- */
+  char    guest_id[37];
+
+  /* --- common --- */
+  char    csrf_token[AUTH_CRYPTO_TOKEN_SIZE]; /* base64url, return to client */
+
+  /* internal: session token digest (user) or guest_id (guest) used as CSRF binding */
+  char    _binding[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+} Auth_Principal;
+
+/*
+ * Optional callback that provides guest quota JSON for the session endpoint.
+ * Registered by conversation_api on init; called from auth_session_handler.
+ * json_out: buffer of json_capacity bytes; write null-terminated JSON or "null".
+ * Returns TRUE on success; on FALSE the session response uses "null".
+ */
+typedef boolean (*Auth_API_Guest_Quota_Cb)(
+    const char *guest_id,
+    int64       current_unix,
+    char       *json_out,
+    size_t      json_capacity);
+
+void Auth_API_Register_Guest_Quota_Cb(Auth_API_Guest_Quota_Cb cb);
+
+/*
+ * Hook called after a successful login to initiate guest-resource transfer.
+ * Called with the logged-out guest_id and the newly authenticated user_id.
+ * Must not call any Auth_API function; executes on the request thread.
+ *
+ * Returns TRUE on success.  On FALSE the login handler revokes the new
+ * session and returns 500; the guest cookie is preserved.
+ * The hook must be idempotent: it may be called more than once for the
+ * same (guest_id, user_id) pair during retries.
+ */
+typedef boolean (*Auth_Guest_Transfer_Hook)(
+    const char *guest_id,
+    const char *user_id,
+    void       *context);
+
+/*
+ * Initialise the auth module.
+ *
+ * cookie_secret       must be at least AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES.
+ * bootstrap_username  / bootstrap_password_hash: create bootstrap admin on
+ *   first startup only when no admin exists; pass NULL to skip.
+ * trusted_proxy_ip    exact direct peer IP that may forward X-Real-IP; NULL
+ *   to disable proxy trust.
+ * dev_insecure_cookie TRUE allows non-Secure cookies; only valid on loopback.
+ *
+ * Returns FALSE and fails closed if cookie_secret is missing/too short.
+ */
+boolean Auth_API_Init(
+    const char *database_path,
+    const uint8 *cookie_secret,
+    size_t       cookie_secret_length,
+    const char  *bootstrap_username,
+    const char  *bootstrap_password_hash,
+    const char  *trusted_proxy_ip,
+    int64        session_idle_ttl_secs,
+    int64        session_absolute_ttl_secs,
+    int64        guest_ttl_secs,
+    boolean      dev_insecure_cookie);
+
+void Auth_API_Destroy(void);
+void Auth_API_Register_Routes(void);
+
+/*
+ * Register a hook for guest-to-user resource transfer on login.
+ * Only one hook is supported; a second call replaces the previous one.
+ */
+void Auth_API_Register_Guest_Transfer_Hook(
+    Auth_Guest_Transfer_Hook hook,
+    void                    *context);
+
+/*
+ * Resolve the caller's identity from request cookies.
+ * Creates a guest identity if no valid session or guest cookie is found.
+ * new_guest_cookie_out: if non-NULL and non-empty on return, the caller
+ *   should include a Set-Cookie header with this value in the response.
+ * Returns TRUE on success; FALSE only on internal error (treat as 500).
+ */
+boolean Auth_API_Resolve_Principal(
+    Seobeo_Request_Entry *p_request,
+    Auth_Principal       *p_principal,
+    Dowa_Arena           *p_arena,
+    char                 *new_guest_cookie_out,
+    size_t                new_guest_cookie_capacity);
+
+/*
+ * Resolve identity from request cookies WITHOUT creating a new guest.
+ * Returns TRUE on success (no internal error):
+ *   - If an existing user session or valid guest cookie is found,
+ *     p_principal is filled and *p_found is set to TRUE.
+ *   - If no valid session/guest is found, *p_found is set to FALSE;
+ *     the caller must return HTTP 401.
+ * Returns FALSE on internal error (treat as 500).
+ * Never writes a guest identity row or generates a Set-Cookie directive.
+ */
+boolean Auth_API_Resolve_Existing_Principal(
+    Seobeo_Request_Entry *p_request,
+    Auth_Principal       *p_principal,
+    Dowa_Arena           *p_arena,
+    boolean              *p_found);
+
+/*
+ * Returns TRUE if the path is permitted for forced-password-change sessions
+ * (i.e., the principal should NOT be blocked at this path).
+ * Conversation and admin code gate their routes with:
+ *   if (principal.must_change_password &&
+ *       !Auth_API_Is_Forced_Password_Change_Only(path)) { return 403; }
+ */
+boolean Auth_API_Is_Forced_Password_Change_Only(const char *http_path);
+
+/*
+ * Verify same-origin AND CSRF for state-changing routes.
+ *
+ * Enforces:
+ *   1. The Origin header matches the Host header (same-origin).
+ *   2. The X-CSRF-Token request header is present and matches the token
+ *      derived from the principal's session binding.
+ *
+ * Use this as the single centralized CSRF gate.  Do not duplicate the
+ * origin-check or CSRF-derivation logic in other modules.
+ *
+ * Returns TRUE on success; the caller MUST return HTTP 403 on FALSE.
+ */
+boolean Auth_API_Verify_CSRF(
+    Seobeo_Request_Entry *p_request,
+    const Auth_Principal *p_principal);
+
+/*
+ * Returns the initialized auth store pointer.
+ * Valid only after Auth_API_Init returns TRUE; NULL before that.
+ * Admin API uses this to issue store operations directly.
+ */
+Auth_Store *Auth_API_Get_Store(void);
+
+#ifdef AUTH_API_TEST_HOOKS
+typedef void (*Auth_API_Test_Login_Pre_Create_Hook)(void *p_context);
+
+void Auth_API_Test_Set_Login_Pre_Create_Hook(
+    Auth_API_Test_Login_Pre_Create_Hook hook,
+    void                               *p_context);
+
+/*
+ * Direct handler entry-points for in-process testing.
+ * Only available when AUTH_API_TEST_HOOKS is defined (test builds).
+ */
+Seobeo_Request_Entry *Auth_API_Test_Session_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena);
+Seobeo_Request_Entry *Auth_API_Test_Login_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena);
+Seobeo_Request_Entry *Auth_API_Test_Logout_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena);
+Seobeo_Request_Entry *Auth_API_Test_Password_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena);
+#endif /* AUTH_API_TEST_HOOKS */
+
+#endif /* MRJUNEJUNE_AUTH_API_H */
--- a/mrjunejune/conversation_api.c	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/conversation_api.c	Fri Aug 07 07:34:12 2026 -0700
@@ -1,7 +1,9 @@
 #include "mrjunejune/conversation_api.h"
 
+#include "mrjunejune/auth_api.h"
 #include "mrjunejune/inference_bridge.h"
 #include "mrjunejune/conversation_store.h"
+#include "auth/auth_store.h"
 #include "seobeo/seobeo.h"
 
 #include <pthread.h>
@@ -20,19 +22,39 @@
 #define CONVERSATION_EVENT_NAME_MAX 64
 #define CONVERSATION_ACTIVE_MAX 4
 #define CONVERSATION_TURNS_PER_MINUTE 60
+/* Buffer large enough for a guest Set-Cookie directive */
+#define CONV_GUEST_COOKIE_CAPACITY (AUTH_CRYPTO_GUEST_COOKIE_SIZE + 256)
+
+/* Quota policy — read from env at init, validated at startup. */
+#define CONV_GUEST_DAILY_TURNS_DEFAULT          10
+#define CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT  20000
+#define CONV_GUEST_DAILY_TURNS_MIN              1
+#define CONV_GUEST_DAILY_TURNS_MAX              10000
+#define CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN      1
+#define CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX      1000000
+#define CONV_GUEST_REQUEST_OUTPUT_TOKENS_MIN    1
+/* Default per-request reservation (tokens); capped to daily at init. */
+#define CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT 2048
 
 static Conversation_Store *g_conversation_store = NULL;
 static Inference_Bridge *g_inference_bridge = NULL;
-static boolean g_anonymous_inference_enabled = FALSE;
+static boolean g_guest_inference_enabled = FALSE;
 static pthread_mutex_t g_pending_mutex = PTHREAD_MUTEX_INITIALIZER;
 static pthread_mutex_t g_admission_mutex = PTHREAD_MUTEX_INITIALIZER;
 static time_t g_admission_window = 0;
 static uint32 g_admission_turns = 0;
 static uint32 g_active_turns = 0;
 
+/* Quota policy (validated at init). */
+static int64 g_guest_daily_turns          = CONV_GUEST_DAILY_TURNS_DEFAULT;
+static int64 g_guest_daily_output_tokens  = CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT;
+static int64 g_guest_request_output_tokens = CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT;
+
 typedef struct Pending_Turn {
   char request_id[37];
   char conversation_id[37];
+  /* owner identity copied before request arena expires (req 8) */
+  Conversation_Owner owner;
   Seobeo_SSE_Stream *p_stream;
   char *content;
   size_t content_length;
@@ -41,7 +63,13 @@
   int64 output_tokens;
   boolean failed;
   boolean aborted;
+  boolean has_usage_event;   /* TRUE once assistant.usage received           */
+  boolean finalized;         /* TRUE once Finalize_Pending has run (once-only) */
   char error_message[512];
+  /* Guest quota fields — copied before arena expires. */
+  boolean is_guest_reserved;   /* TRUE if quota was reserved for this turn */
+  int64   reserved_output_tokens;
+  char    guest_id[37];        /* guest_id from principal (safe copy)      */
   struct Pending_Turn *p_next;
 } Pending_Turn;
 
@@ -110,10 +138,138 @@
   pthread_mutex_unlock(&g_admission_mutex);
 }
 
-static Seobeo_Request_Entry *Conversation_API_JSON_Response(
+static const char *Conversation_API_Request_Value(
+    Seobeo_Request_Entry *p_request,
+    const char *key)
+{
+  void *p_value = Dowa_HashMap_Get_Ptr(p_request, (char *)key);
+  return p_value ? ((Seobeo_Request_Entry *)p_value)->value : NULL;
+}
+
+static boolean Conversation_API_Is_Inference_Ready(void)
+{
+  return g_guest_inference_enabled;
+}
+
+/* UTC midnight for a Unix timestamp. */
+static int64 conv__utc_window_start(int64 unix_ts)
+{
+  return (unix_ts / 86400LL) * 86400LL;
+}
+
+/* Build guest quota JSON into a fixed buffer (null-terminated). */
+static boolean conv_guest_quota_cb(
+    const char *guest_id,
+    int64       current_unix,
+    char       *json_out,
+    size_t      json_capacity)
+{
+  Auth_Store *p_store = Auth_API_Get_Store();
+  if (!p_store || !guest_id || !json_out || json_capacity == 0)
+  {
+    if (json_out && json_capacity > 0)
+      strncpy(json_out, "null", json_capacity);
+    return TRUE;
+  }
+
+  int64 window_start = conv__utc_window_start(current_unix);
+  Auth_Store_Guest_Usage usage;
+  memset(&usage, 0, sizeof(usage));
+  Auth_Store_Guest_Get_Usage(p_store, guest_id, window_start, &usage);
+
+  int64 turns_remaining =
+      g_guest_daily_turns - usage.turns_used;
+  if (turns_remaining < 0) turns_remaining = 0;
+  int64 tokens_remaining =
+      g_guest_daily_output_tokens - usage.output_tokens_used - usage.output_tokens_reserved;
+  if (tokens_remaining < 0) tokens_remaining = 0;
+  int64 resets_at = window_start + 86400LL;
+
+  snprintf(
+      json_out, json_capacity,
+      "{\"turnsLimit\":%lld,\"turnsUsed\":%lld,\"turnsRemaining\":%lld,"
+      "\"outputTokensLimit\":%lld,\"outputTokensUsed\":%lld,"
+      "\"outputTokensReserved\":%lld,\"outputTokensRemaining\":%lld,"
+      "\"resetsAt\":%lld}",
+      (long long)g_guest_daily_turns,
+      (long long)usage.turns_used,
+      (long long)turns_remaining,
+      (long long)g_guest_daily_output_tokens,
+      (long long)usage.output_tokens_used,
+      (long long)usage.output_tokens_reserved,
+      (long long)tokens_remaining,
+      (long long)resets_at);
+  return TRUE;
+}
+
+static Dowa_JSON_Entry *Conversation_API_Parse_Body(
+    Seobeo_Request_Entry *p_request,
+    Dowa_Arena *p_arena)
+{
+  const char *body = Conversation_API_Request_Value(p_request, "Body");
+  if (!body)
+    return NULL;
+  Dowa_JSON_Value value = Dowa_JSON_Parse(
+      body, (int32)strlen(body), p_arena);
+  return value.type == DOWA_JSON_OBJECT
+      ? (Dowa_JSON_Entry *)value.object_val
+      : NULL;
+}
+
+static const char *Conversation_API_Query_Param(
+    Seobeo_Request_Entry *p_request,
+    const char *param_name,
+    char *output,
+    size_t output_capacity)
+{
+  const char *qs = Conversation_API_Request_Value(p_request, "QueryString");
+  if (!qs || !param_name || !output || output_capacity == 0)
+    return NULL;
+  output[0] = '\0';
+  size_t name_len = strlen(param_name);
+  const char *p = qs;
+  while (*p)
+  {
+    if (strncmp(p, param_name, name_len) == 0 && p[name_len] == '=')
+    {
+      p += name_len + 1;
+      size_t i = 0;
+      while (*p && *p != '&' && i < output_capacity - 1)
+      {
+        if (p[0] == '%' && p[1] && p[2])
+        {
+          char hex[3] = {p[1], p[2], '\0'};
+          output[i++] = (char)(int)strtol(hex, NULL, 16);
+          p += 3;
+        }
+        else if (*p == '+')
+        {
+          output[i++] = ' ';
+          p++;
+        }
+        else
+        {
+          output[i++] = *p++;
+        }
+      }
+      output[i] = '\0';
+      return output[0] != '\0' ? output : NULL;
+    }
+    while (*p && *p != '&') p++;
+    if (*p == '&') p++;
+  }
+  return NULL;
+}
+
+/*
+ * Build a JSON response, optionally setting a guest cookie when one was
+ * freshly issued by Auth_API_Resolve_Principal.
+ */
+static Seobeo_Request_Entry *Conversation_API_JSON_Response_With_Cookie(
     Dowa_Arena *p_arena,
     const char *status,
-    const char *body)
+    const char *body,
+    const char *new_guest_cookie)
 {
   Seobeo_Request_Entry *p_response = NULL;
   Dowa_HashMap_Push_Arena(p_response, "status", (char *)status, p_arena);
@@ -121,9 +277,20 @@
       p_response, "content-type", "application/json; charset=utf-8", p_arena);
   Dowa_HashMap_Push_Arena(p_response, "cache-control", "no-store", p_arena);
   Dowa_HashMap_Push_Arena(p_response, "body", (char *)body, p_arena);
+  if (new_guest_cookie && new_guest_cookie[0] != '\0')
+    Dowa_HashMap_Push_Arena(
+        p_response, "Set-Cookie", (char *)new_guest_cookie, p_arena);
   return p_response;
 }
 
+static Seobeo_Request_Entry *Conversation_API_JSON_Response(
+    Dowa_Arena *p_arena,
+    const char *status,
+    const char *body)
+{
+  return Conversation_API_JSON_Response_With_Cookie(p_arena, status, body, NULL);
+}
+
 static Seobeo_Request_Entry *Conversation_API_Error(
     Dowa_Arena *p_arena,
     const char *status,
@@ -142,47 +309,77 @@
   return Conversation_API_JSON_Response(p_arena, status, body);
 }
 
-static const char *Conversation_API_Request_Value(
+static Seobeo_Request_Entry *Conversation_API_Error_With_Cookie(
+    Dowa_Arena *p_arena,
+    const char *status,
+    const char *code,
+    const char *message,
+    const char *new_guest_cookie)
+{
+  char *escaped = Dowa_JSON_Escape_String(message, 0, p_arena);
+  size_t capacity = strlen(code) + strlen(escaped) + 64;
+  char *body = Dowa_Arena_Allocate(p_arena, capacity);
+  snprintf(
+      body,
+      capacity,
+      "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
+      code,
+      escaped);
+  return Conversation_API_JSON_Response_With_Cookie(p_arena, status, body, new_guest_cookie);
+}
+
+/*
+ * Resolve the principal for this request, optionally setting a new guest
+ * cookie on the response when returned.
+ * Returns FALSE only on internal error (treat as 500).
+ */
+static boolean Conversation_API_Resolve(
     Seobeo_Request_Entry *p_request,
-    const char *key)
+    Auth_Principal *p_principal,
+    char *new_guest_cookie,
+    Dowa_Arena *p_arena)
 {
-  void *p_value = Dowa_HashMap_Get_Ptr(p_request, (char *)key);
-  return p_value ? ((Seobeo_Request_Entry *)p_value)->value : NULL;
+  return Auth_API_Resolve_Principal(
+      p_request, p_principal, p_arena,
+      new_guest_cookie, CONV_GUEST_COOKIE_CAPACITY);
 }
 
-static boolean Conversation_API_Is_Same_Origin(
-    Seobeo_Request_Entry *p_request)
+/*
+ * Resolve an existing principal for mutation requests.
+ * Does NOT create a new guest identity.
+ * Returns FALSE on internal error (500); sets *p_found=FALSE when no
+ * existing session/guest is present (caller must return 401).
+ */
+static boolean Conversation_API_Resolve_Existing(
+    Seobeo_Request_Entry *p_request,
+    Auth_Principal *p_principal,
+    Dowa_Arena *p_arena,
+    boolean *p_found)
 {
-  const char *host = Conversation_API_Request_Value(p_request, "Host");
-  const char *origin = Conversation_API_Request_Value(p_request, "Origin");
-  if (!host || !origin)
-    return FALSE;
-  const char *origin_host = strstr(origin, "://");
-  if (!origin_host)
-    return FALSE;
-  origin_host += 3;
-  const char *end = strchr(origin_host, '/');
-  size_t length = end ? (size_t)(end - origin_host) : strlen(origin_host);
-  return strlen(host) == length && strncmp(host, origin_host, length) == 0;
+  return Auth_API_Resolve_Existing_Principal(
+      p_request, p_principal, p_arena, p_found);
 }
 
-static boolean Conversation_API_Is_Enabled(void)
-{
-  return g_anonymous_inference_enabled;
-}
-
-static Dowa_JSON_Entry *Conversation_API_Parse_Body(
-    Seobeo_Request_Entry *p_request,
-    Dowa_Arena *p_arena)
+/*
+ * Map a resolved principal to a Conversation_Owner.
+ * Always succeeds for USER and GUEST principals.
+ */
+static void Conversation_API_Owner_From_Principal(
+    const Auth_Principal *p_principal,
+    Conversation_Owner *p_owner)
 {
-  const char *body = Conversation_API_Request_Value(p_request, "Body");
-  if (!body)
-    return NULL;
-  Dowa_JSON_Value value = Dowa_JSON_Parse(
-      body, (int32)strlen(body), p_arena);
-  return value.type == DOWA_JSON_OBJECT
-      ? (Dowa_JSON_Entry *)value.object_val
-      : NULL;
+  if (p_principal->kind == AUTH_PRINCIPAL_USER)
+  {
+    p_owner->kind = CONVERSATION_OWNER_KIND_USER;
+    strncpy(p_owner->id, p_principal->user_id, sizeof(p_owner->id) - 1);
+    p_owner->id[sizeof(p_owner->id) - 1] = '\0';
+  }
+  else
+  {
+    p_owner->kind = CONVERSATION_OWNER_KIND_GUEST;
+    strncpy(p_owner->id, p_principal->guest_id, sizeof(p_owner->id) - 1);
+    p_owner->id[sizeof(p_owner->id) - 1] = '\0';
+  }
 }
 
 static void Conversation_API_Send_Stream_Error(
@@ -255,6 +452,30 @@
 
 static void Conversation_API_Finalize_Pending(Pending_Turn *p_turn)
 {
+  /* Idempotence guard — called with g_pending_mutex held. */
+  if (p_turn->finalized)
+    return;
+  p_turn->finalized = TRUE;
+
+  /* Reconcile or release guest quota reservation before persistence. */
+  if (p_turn->is_guest_reserved)
+  {
+    Auth_Store *p_auth_store = Auth_API_Get_Store();
+    if (p_auth_store)
+    {
+      if (!p_turn->failed && !p_turn->aborted)
+      {
+        int64 actual = p_turn->has_usage_event
+            ? p_turn->output_tokens
+            : p_turn->reserved_output_tokens;
+        Auth_Store_Guest_Reconcile(
+            p_auth_store, p_turn->request_id, actual);
+      }
+      else
+        Auth_Store_Guest_Release(p_auth_store, p_turn->request_id);
+    }
+  }
+
   Conversation_Store_Result persistence;
   if (p_turn->failed || p_turn->aborted)
   {
@@ -623,6 +844,7 @@
   {
     p_turn->input_tokens = p_event->input_tokens;
     p_turn->output_tokens = p_event->output_tokens;
+    p_turn->has_usage_event = TRUE;
     char usage[256];
     snprintf(
         usage,
@@ -686,48 +908,9 @@
         g_inference_bridge, abort_request_id, abort_conversation_id);
 }
 
-static Seobeo_Request_Entry *Conversation_API_Create(
-    Seobeo_Request_Entry *p_request,
-    Dowa_Arena *p_arena)
-{
-  if (!Conversation_API_Is_Enabled())
-    return Conversation_API_Error(
-        p_arena, "503", "inference_disabled", "Inference API is disabled");
-  if (!Conversation_API_Is_Same_Origin(p_request))
-    return Conversation_API_Error(
-        p_arena, "403", "origin_rejected", "Same-origin request required");
-  if (!g_conversation_store)
-    return Conversation_API_Error(
-        p_arena, "503", "store_unavailable", "Conversation store unavailable");
-
-  const char *title = "";
-  const char *body = Conversation_API_Request_Value(p_request, "Body");
-  if (body && body[0] != '\0')
-  {
-    Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
-    if (!object)
-      return Conversation_API_Error(
-          p_arena, "400", "invalid_json", "Request body must be a JSON object");
-    char *parsed_title = Dowa_JSON_Get_String(object, "title");
-    if (parsed_title)
-      title = parsed_title;
-  }
-  if (strlen(title) > CONVERSATION_TITLE_MAX)
-    return Conversation_API_Error(
-        p_arena, "400", "invalid_title", "Title exceeds 200 bytes");
-
-  char conversation_id[37];
-  if (Conversation_Store_Create_Conversation(
-          g_conversation_store,
-          title,
-          conversation_id) != CONVERSATION_STORE_OK)
-    return Conversation_API_Error(
-        p_arena, "500", "create_failed", "Unable to create conversation");
-
-  char *response_body = Dowa_Arena_Allocate(p_arena, 64);
-  snprintf(response_body, 64, "{\"id\":\"%s\"}", conversation_id);
-  return Conversation_API_JSON_Response(p_arena, "201", response_body);
-}
+/* ------------------------------------------------------------------ */
+/* HTTP handler helpers                                                  */
+/* ------------------------------------------------------------------ */
 
 static boolean Conversation_API_Append(
     char *output,
@@ -749,55 +932,281 @@
   return TRUE;
 }
 
+/* ------------------------------------------------------------------ */
+/* GET /api/conversations?cursor=<ts>_<id>&limit=20                    */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *Conversation_API_List(
+    Seobeo_Request_Entry *p_request,
+    Dowa_Arena *p_arena)
+{
+  if (!g_conversation_store)
+    return Conversation_API_Error(
+        p_arena, "503", "store_unavailable", "Conversation store unavailable");
+
+  Auth_Principal principal;
+  char new_guest_cookie[CONV_GUEST_COOKIE_CAPACITY] = {0};
+  if (!Conversation_API_Resolve(p_request, &principal, new_guest_cookie, p_arena))
+    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
+  if (principal.must_change_password)
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "403", "password_change_required",
+        "Password change required", new_guest_cookie);
+
+  Conversation_Owner owner;
+  Conversation_API_Owner_From_Principal(&principal, &owner);
+
+  /* Parse cursor and limit from query string */
+  char cursor_buf[80] = {0};
+  char limit_buf[8] = {0};
+  Conversation_API_Query_Param(p_request, "cursor", cursor_buf, sizeof(cursor_buf));
+  Conversation_API_Query_Param(p_request, "limit", limit_buf, sizeof(limit_buf));
+
+  int64 cursor_updated_at = 0;
+  char cursor_id[37] = {0};
+  if (cursor_buf[0] != '\0')
+  {
+    /* cursor format: <updated_at>_<uuid> */
+    const char *underscore = strchr(cursor_buf, '_');
+    if (!underscore || underscore == cursor_buf ||
+        strlen(underscore + 1) != 36)
+      return Conversation_API_Error_With_Cookie(
+          p_arena, "400", "invalid_cursor", "Cursor format invalid",
+          new_guest_cookie);
+    char ts_part[32] = {0};
+    size_t ts_len = (size_t)(underscore - cursor_buf);
+    if (ts_len >= sizeof(ts_part))
+      return Conversation_API_Error_With_Cookie(
+          p_arena, "400", "invalid_cursor", "Cursor format invalid",
+          new_guest_cookie);
+    memcpy(ts_part, cursor_buf, ts_len);
+    cursor_updated_at = (int64)atoll(ts_part);
+    if (cursor_updated_at <= 0)
+      return Conversation_API_Error_With_Cookie(
+          p_arena, "400", "invalid_cursor", "Cursor timestamp invalid",
+          new_guest_cookie);
+    strncpy(cursor_id, underscore + 1, 36);
+    cursor_id[36] = '\0';
+  }
+
+  int32 limit = 20;
+  if (limit_buf[0] != '\0')
+  {
+    int parsed = atoi(limit_buf);
+    if (parsed >= 1 && parsed <= 50)
+      limit = parsed;
+    else if (parsed > 50)
+      limit = 50;
+  }
+
+  Conversation_Summary *summaries = NULL;
+  int32 count = 0;
+  Conversation_Store_Result result = Conversation_Store_List(
+      g_conversation_store, &owner,
+      cursor_updated_at, cursor_id[0] ? cursor_id : NULL,
+      limit, &summaries, &count, p_arena);
+  if (result != CONVERSATION_STORE_OK)
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "list_failed", "Unable to list conversations",
+        new_guest_cookie);
+
+  /* Estimate response size */
+  size_t capacity = 128;
+  for (int32 i = 0; i < count; i++)
+  {
+    Conversation_Summary *s = &summaries[i];
+    capacity += (s->title ? strlen(s->title) : 0) * 6 +
+                (s->last_message_preview ? strlen(s->last_message_preview) : 0) * 6 +
+                256;
+  }
+  char *body = Dowa_Arena_Allocate(p_arena, capacity);
+  if (!body)
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Response exceeds memory limit",
+        new_guest_cookie);
+
+  size_t offset = 0;
+  if (!Conversation_API_Append(body, capacity, &offset,
+                               "{\"conversations\":["))
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Response too large",
+        new_guest_cookie);
+
+  for (int32 i = 0; i < count; i++)
+  {
+    Conversation_Summary *s = &summaries[i];
+    char *esc_title = Dowa_JSON_Escape_String(
+        s->title ? s->title : "", 0, p_arena);
+    char *esc_preview = Dowa_JSON_Escape_String(
+        s->last_message_preview ? s->last_message_preview : "", 0, p_arena);
+    if (!Conversation_API_Append(
+            body, capacity, &offset,
+            "%s{\"id\":\"%s\",\"title\":\"%s\",\"status\":\"%s\","
+            "\"created_at\":%lld,\"updated_at\":%lld,"
+            "\"turn_count\":%lld,\"last_message_preview\":\"%s\"}",
+            i == 0 ? "" : ",",
+            s->id ? s->id : "",
+            esc_title ? esc_title : "",
+            s->status ? s->status : "",
+            (long long)s->created_at,
+            (long long)s->updated_at,
+            (long long)s->turn_count,
+            esc_preview ? esc_preview : ""))
+      return Conversation_API_Error_With_Cookie(
+          p_arena, "500", "serialize_failed", "Response too large",
+          new_guest_cookie);
+  }
+
+  /* Next cursor: last item's (updated_at, id) */
+  char cursor_out[80] = "null";
+  if (count == limit && count > 0)
+  {
+    Conversation_Summary *last = &summaries[count - 1];
+    if (last->id)
+      snprintf(cursor_out, sizeof(cursor_out), "\"%lld_%s\"",
+               (long long)last->updated_at, last->id);
+  }
+  if (!Conversation_API_Append(body, capacity, &offset,
+                               "],\"cursor\":%s}", cursor_out))
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Response too large",
+        new_guest_cookie);
+
+  return Conversation_API_JSON_Response_With_Cookie(
+      p_arena, "200", body, new_guest_cookie);
+}
+
+/* ------------------------------------------------------------------ */
+/* POST /api/conversations                                              */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *Conversation_API_Create(
+    Seobeo_Request_Entry *p_request,
+    Dowa_Arena *p_arena)
+{
+  if (!g_conversation_store)
+    return Conversation_API_Error(
+        p_arena, "503", "store_unavailable", "Conversation store unavailable");
+
+  Auth_Principal principal;
+  boolean found = FALSE;
+  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
+    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
+  if (!found)
+    return Conversation_API_Error(
+        p_arena, "401", "auth_required", "Bootstrap session first");
+  if (principal.must_change_password)
+    return Conversation_API_Error(
+        p_arena, "403", "password_change_required",
+        "Password change required");
+  if (!Auth_API_Verify_CSRF(p_request, &principal))
+    return Conversation_API_Error(
+        p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");
+
+  Conversation_Owner owner;
+  Conversation_API_Owner_From_Principal(&principal, &owner);
+
+  const char *title = "";
+  const char *body = Conversation_API_Request_Value(p_request, "Body");
+  if (body && body[0] != '\0')
+  {
+    Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
+    if (!object)
+      return Conversation_API_Error(
+          p_arena, "400", "invalid_json", "Request body must be a JSON object");
+    char *parsed_title = Dowa_JSON_Get_String(object, "title");
+    if (parsed_title)
+      title = parsed_title;
+  }
+  if (strlen(title) > CONVERSATION_TITLE_MAX)
+    return Conversation_API_Error(
+        p_arena, "400", "invalid_title", "Title exceeds 200 bytes");
+
+  char conversation_id[37];
+  if (Conversation_Store_Create_Owned(
+          g_conversation_store, title, &owner,
+          conversation_id) != CONVERSATION_STORE_OK)
+    return Conversation_API_Error(
+        p_arena, "500", "create_failed", "Unable to create conversation");
+
+  char *response_body = Dowa_Arena_Allocate(p_arena, 64);
+  snprintf(response_body, 64, "{\"id\":\"%s\"}", conversation_id);
+  return Conversation_API_JSON_Response(p_arena, "201", response_body);
+}
+
+/* ------------------------------------------------------------------ */
+/* GET /api/conversations/:conversation_id                              */
+/* ------------------------------------------------------------------ */
+
 static Seobeo_Request_Entry *Conversation_API_Get(
     Seobeo_Request_Entry *p_request,
     Dowa_Arena *p_arena)
 {
-  if (!Conversation_API_Is_Enabled())
+  if (!g_conversation_store)
     return Conversation_API_Error(
-        p_arena, "503", "inference_disabled", "Inference API is disabled");
+        p_arena, "503", "store_unavailable", "Conversation store unavailable");
+
+  Auth_Principal principal;
+  char new_guest_cookie[CONV_GUEST_COOKIE_CAPACITY] = {0};
+  if (!Conversation_API_Resolve(p_request, &principal, new_guest_cookie, p_arena))
+    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
+  if (principal.must_change_password)
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "403", "password_change_required",
+        "Password change required", new_guest_cookie);
+
+  Conversation_Owner owner;
+  Conversation_API_Owner_From_Principal(&principal, &owner);
+
   const char *conversation_id =
       Conversation_API_Request_Value(p_request, ":conversation_id");
   if (!conversation_id)
-    return Conversation_API_Error(
-        p_arena, "400", "missing_id", "Conversation ID is required");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "400", "missing_id", "Conversation ID is required",
+        new_guest_cookie);
 
   Conversation_Record record;
-  Conversation_Store_Result result = Conversation_Store_Get(
-      g_conversation_store, conversation_id, &record, p_arena);
+  Conversation_Store_Result result = Conversation_Store_Get_Owned(
+      g_conversation_store, conversation_id, &owner, &record, p_arena);
   if (result == CONVERSATION_STORE_NOT_FOUND)
-    return Conversation_API_Error(
-        p_arena, "404", "not_found", "Conversation not found");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "404", "not_found", "Conversation not found",
+        new_guest_cookie);
   if (result != CONVERSATION_STORE_OK)
-    return Conversation_API_Error(
-        p_arena, "500", "load_failed", "Unable to load conversation");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "load_failed", "Unable to load conversation",
+        new_guest_cookie);
 
   if (!record.id || !record.title || !record.status)
-    return Conversation_API_Error(
-        p_arena, "500", "load_failed", "Conversation data exceeded limits");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "load_failed", "Conversation data exceeded limits",
+        new_guest_cookie);
   size_t history_size = strlen(record.title) + strlen(record.status);
   for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
   {
     Conversation_Turn *p_turn = &record.turns[i];
     if (!p_turn->role || !p_turn->content || !p_turn->status ||
         !p_turn->request_id || !p_turn->error_message)
-      return Conversation_API_Error(
-          p_arena, "500", "load_failed", "Conversation data exceeded limits");
+      return Conversation_API_Error_With_Cookie(
+          p_arena, "500", "load_failed", "Conversation data exceeded limits",
+          new_guest_cookie);
     history_size +=
         strlen(p_turn->role) + strlen(p_turn->content) +
         strlen(p_turn->status) + strlen(p_turn->request_id) +
         strlen(p_turn->error_message);
     if (history_size > CONVERSATION_HISTORY_MAX)
-      return Conversation_API_Error(
+      return Conversation_API_Error_With_Cookie(
           p_arena,
           "413",
           "history_too_large",
-          "Conversation history exceeds response limit");
+          "Conversation history exceeds response limit",
+          new_guest_cookie);
   }
   char *escaped_title = Dowa_JSON_Escape_String(record.title, 0, p_arena);
   if (!escaped_title)
-    return Conversation_API_Error(
-        p_arena, "500", "serialize_failed", "Unable to serialize conversation");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Unable to serialize conversation",
+        new_guest_cookie);
   size_t capacity = strlen(escaped_title) + 512;
   for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
   {
@@ -809,8 +1218,9 @@
   }
   char *body = Dowa_Arena_Allocate(p_arena, capacity);
   if (!body)
-    return Conversation_API_Error(
-        p_arena, "500", "serialize_failed", "Response exceeds memory limit");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Response exceeds memory limit",
+        new_guest_cookie);
   size_t offset = 0;
   if (!Conversation_API_Append(
           body,
@@ -823,8 +1233,9 @@
           record.status,
           (long long)record.created_at,
           (long long)record.updated_at))
-    return Conversation_API_Error(
-        p_arena, "500", "serialize_failed", "Response too large");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Response too large",
+        new_guest_cookie);
 
   for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
   {
@@ -857,25 +1268,48 @@
             (long long)p_turn->output_tokens,
             (long long)p_turn->created_at,
             (long long)p_turn->completed_at))
-      return Conversation_API_Error(
-          p_arena, "500", "serialize_failed", "Response too large");
+      return Conversation_API_Error_With_Cookie(
+          p_arena, "500", "serialize_failed", "Response too large",
+          new_guest_cookie);
   }
   if (!Conversation_API_Append(body, capacity, &offset, "]}"))
-    return Conversation_API_Error(
-        p_arena, "500", "serialize_failed", "Response too large");
-  return Conversation_API_JSON_Response(p_arena, "200", body);
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Response too large",
+        new_guest_cookie);
+  return Conversation_API_JSON_Response_With_Cookie(
+      p_arena, "200", body, new_guest_cookie);
 }
 
+/* ------------------------------------------------------------------ */
+/* PATCH /api/conversations/:conversation_id                            */
+/* ------------------------------------------------------------------ */
+
 static Seobeo_Request_Entry *Conversation_API_Update(
     Seobeo_Request_Entry *p_request,
     Dowa_Arena *p_arena)
 {
-  if (!Conversation_API_Is_Enabled())
+  if (!g_conversation_store)
+    return Conversation_API_Error(
+        p_arena, "503", "store_unavailable", "Conversation store unavailable");
+
+  Auth_Principal principal;
+  boolean found = FALSE;
+  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
+    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
+  if (!found)
     return Conversation_API_Error(
-        p_arena, "503", "inference_disabled", "Inference API is disabled");
-  if (!Conversation_API_Is_Same_Origin(p_request))
+        p_arena, "401", "auth_required", "Bootstrap session first");
+  if (principal.must_change_password)
     return Conversation_API_Error(
-        p_arena, "403", "origin_rejected", "Same-origin request required");
+        p_arena, "403", "password_change_required",
+        "Password change required");
+  if (!Auth_API_Verify_CSRF(p_request, &principal))
+    return Conversation_API_Error(
+        p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");
+
+  Conversation_Owner owner;
+  Conversation_API_Owner_From_Principal(&principal, &owner);
+
   const char *conversation_id =
       Conversation_API_Request_Value(p_request, ":conversation_id");
   Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
@@ -887,8 +1321,8 @@
     return Conversation_API_Error(
         p_arena, "400", "invalid_title", "Title exceeds 200 bytes");
 
-  Conversation_Store_Result result = Conversation_Store_Update_Title(
-      g_conversation_store, conversation_id, title);
+  Conversation_Store_Result result = Conversation_Store_Update_Title_Owned(
+      g_conversation_store, conversation_id, &owner, title);
   if (result == CONVERSATION_STORE_NOT_FOUND)
     return Conversation_API_Error(
         p_arena, "404", "not_found", "Conversation not found");
@@ -898,29 +1332,51 @@
   return Conversation_API_JSON_Response(p_arena, "200", "{\"ok\":true}");
 }
 
+/* ------------------------------------------------------------------ */
+/* DELETE /api/conversations/:conversation_id                           */
+/* ------------------------------------------------------------------ */
+
 static Seobeo_Request_Entry *Conversation_API_Delete(
     Seobeo_Request_Entry *p_request,
     Dowa_Arena *p_arena)
 {
-  if (!Conversation_API_Is_Enabled())
+  if (!g_conversation_store)
+    return Conversation_API_Error(
+        p_arena, "503", "store_unavailable", "Conversation store unavailable");
+
+  Auth_Principal principal;
+  boolean found = FALSE;
+  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
+    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
+  if (!found)
     return Conversation_API_Error(
-        p_arena, "503", "inference_disabled", "Inference API is disabled");
-  if (!Conversation_API_Is_Same_Origin(p_request))
+        p_arena, "401", "auth_required", "Bootstrap session first");
+  if (principal.must_change_password)
     return Conversation_API_Error(
-        p_arena, "403", "origin_rejected", "Same-origin request required");
+        p_arena, "403", "password_change_required",
+        "Password change required");
+  if (!Auth_API_Verify_CSRF(p_request, &principal))
+    return Conversation_API_Error(
+        p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");
+
+  Conversation_Owner owner;
+  Conversation_API_Owner_From_Principal(&principal, &owner);
+
   const char *conversation_id =
       Conversation_API_Request_Value(p_request, ":conversation_id");
   if (!conversation_id)
     return Conversation_API_Error(
         p_arena, "400", "missing_id", "Conversation ID is required");
-  Conversation_Store_Result result = Conversation_Store_Delete(
-      g_conversation_store, conversation_id);
+
+  Conversation_Store_Result result = Conversation_Store_Delete_Owned(
+      g_conversation_store, conversation_id, &owner);
   if (result == CONVERSATION_STORE_NOT_FOUND)
     return Conversation_API_Error(
         p_arena, "404", "not_found", "Conversation not found");
   if (result != CONVERSATION_STORE_OK)
     return Conversation_API_Error(
         p_arena, "500", "delete_failed", "Unable to delete conversation");
+
   if (Inference_Bridge_Is_Ready(g_inference_bridge))
   {
     char request_id[37];
@@ -935,13 +1391,76 @@
   return p_response;
 }
 
+/* ------------------------------------------------------------------ */
+/* POST /api/conversations/claim  (body: {"conversationId":"<uuid>"}) */
+/* ID stays in request body — never appears in URL or request log.    */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *Conversation_API_Claim_Body(
+    Seobeo_Request_Entry *p_request,
+    Dowa_Arena *p_arena)
+{
+  if (!g_conversation_store)
+    return Conversation_API_Error(
+        p_arena, "503", "store_unavailable", "Conversation store unavailable");
+
+  Auth_Principal principal;
+  boolean found = FALSE;
+  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
+    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
+  if (!found)
+    return Conversation_API_Error(
+        p_arena, "401", "auth_required", "Bootstrap session first");
+  /* Guests may not claim; forced-password-change users blocked */
+  if (principal.kind != AUTH_PRINCIPAL_USER)
+    return Conversation_API_Error(
+        p_arena, "403", "forbidden", "Must be authenticated to claim");
+  if (principal.must_change_password)
+    return Conversation_API_Error(
+        p_arena, "403", "password_change_required",
+        "Password change required");
+  if (!Auth_API_Verify_CSRF(p_request, &principal))
+    return Conversation_API_Error(
+        p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");
+
+  Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
+  if (!object)
+    return Conversation_API_Error(
+        p_arena, "400", "invalid_json", "Request body must be a JSON object");
+  const char *conversation_id = Dowa_JSON_Get_String(object, "conversationId");
+  if (!conversation_id || conversation_id[0] == '\0')
+    return Conversation_API_Error(
+        p_arena, "400", "missing_id", "conversationId is required");
+  if (strlen(conversation_id) > 36)
+    return Conversation_API_Error(
+        p_arena, "400", "invalid_id", "conversationId is invalid");
+
+  Conversation_Store_Result result = Conversation_Store_Claim_Legacy(
+      g_conversation_store, conversation_id, principal.user_id);
+  if (result == CONVERSATION_STORE_NOT_FOUND)
+    return Conversation_API_Error(
+        p_arena, "404", "not_found", "Conversation not found");
+  if (result == CONVERSATION_STORE_CONFLICT)
+    return Conversation_API_Error(
+        p_arena, "409", "already_owned", "Conversation is already owned");
+  if (result != CONVERSATION_STORE_OK)
+    return Conversation_API_Error(
+        p_arena, "500", "claim_failed", "Unable to claim conversation");
+
+  return Conversation_API_JSON_Response(p_arena, "200", "{\"ok\":true}");
+}
+
+/* ------------------------------------------------------------------ */
+/* GET /api/inference/health                                            */
+/* ------------------------------------------------------------------ */
+
 static Seobeo_Request_Entry *Conversation_API_Health(
     Seobeo_Request_Entry *p_request,
     Dowa_Arena *p_arena)
 {
   (void)p_request;
   boolean ready =
-      Conversation_API_Is_Enabled() &&
+      Conversation_API_Is_Inference_Ready() &&
       g_conversation_store &&
       Inference_Bridge_Is_Ready(g_inference_bridge);
   return Conversation_API_JSON_Response(
@@ -950,29 +1469,92 @@
       ready ? "{\"status\":\"ready\"}" : "{\"status\":\"unavailable\"}");
 }
 
+/* ------------------------------------------------------------------ */
+/* POST /api/conversations/:conversation_id/turns  (streaming)          */
+/* ------------------------------------------------------------------ */
+
+/*
+ * SSE client-disconnect callback.
+ * Called by Seobeo_SSE_Server_Detach_Handle after g_sse_mutex is released.
+ * Finds the pending turn for the disconnected stream and finalizes it once.
+ * Must NOT hold g_sse_mutex when called; acquires g_pending_mutex.
+ */
+static void Conversation_API_On_SSE_Detach(
+    Seobeo_SSE_Stream *p_stream,
+    void              *ctx)
+{
+  (void)ctx;
+  pthread_mutex_lock(&g_pending_mutex);
+  for (Pending_Turn *p_turn = g_pending_turns;
+       p_turn;
+       p_turn = p_turn->p_next)
+  {
+    if (p_turn->p_stream == p_stream && !p_turn->finalized)
+    {
+      p_turn->aborted = TRUE;
+      snprintf(p_turn->error_message, sizeof(p_turn->error_message),
+               "Client disconnected");
+      Conversation_API_Finalize_Pending(p_turn);
+      break;
+    }
+  }
+  pthread_mutex_unlock(&g_pending_mutex);
+}
+
 static void Conversation_API_Turn_Stream(
     Seobeo_Handle *p_handle,
     Seobeo_Request_Entry *p_request,
     Dowa_Arena *p_arena)
 {
-  if (!Conversation_API_Is_Enabled())
+  /* Resolve existing identity first so we can apply controls per-principal. */
+  Auth_Principal principal;
+  boolean found = FALSE;
+  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
+  {
+    Conversation_API_Send_Stream_Error(
+        p_handle, 500, "internal_error", "Session error");
+    return;
+  }
+  if (!found)
+  {
+    Conversation_API_Send_Stream_Error(
+        p_handle, 401, "auth_required", "Bootstrap session first");
+    return;
+  }
+  if (principal.must_change_password)
+  {
+    Conversation_API_Send_Stream_Error(
+        p_handle, 403, "password_change_required",
+        "Password change required");
+    return;
+  }
+  if (!Auth_API_Verify_CSRF(p_request, &principal))
+  {
+    Conversation_API_Send_Stream_Error(
+        p_handle, 403, "csrf_invalid", "Same-origin and CSRF token required");
+    return;
+  }
+
+  /* Guests require inference to be explicitly enabled; authenticated users
+   * always proceed subject to the global bridge-ready check below. */
+  if (principal.kind == AUTH_PRINCIPAL_GUEST &&
+      !Conversation_API_Is_Inference_Ready())
   {
     Conversation_API_Send_Stream_Error(
         p_handle, 503, "inference_disabled", "Inference API is disabled");
     return;
   }
-  if (!Conversation_API_Is_Same_Origin(p_request))
-  {
-    Conversation_API_Send_Stream_Error(
-        p_handle, 403, "origin_rejected", "Same-origin request required");
-    return;
-  }
   if (!g_conversation_store || !Inference_Bridge_Is_Ready(g_inference_bridge))
   {
     Conversation_API_Send_Stream_Error(
         p_handle, 503, "inference_unavailable", "Inference runtime unavailable");
     return;
   }
+
+  /* Map principal to owner — copy IDs before arena may expire (req 8) */
+  Conversation_Owner owner;
+  Conversation_API_Owner_From_Principal(&principal, &owner);
+
   const char *conversation_id =
       Conversation_API_Request_Value(p_request, ":conversation_id");
   Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
@@ -992,8 +1574,25 @@
   }
   if (!Conversation_API_Acquire_Turn_Slot())
   {
-    Conversation_API_Send_Stream_Error(
-        p_handle, 429, "rate_limited", "Inference capacity exhausted");
+    /* Temporary capacity limit — advise client to retry in 5 s. */
+    static const char rate_body[] =
+        "{\"error\":{\"code\":\"rate_limited\","
+        "\"message\":\"Inference capacity exhausted\"}}";
+    char rate_header[512];
+    snprintf(rate_header, sizeof(rate_header),
+        "HTTP/1.1 429 Too Many Requests\r\n"
+        "Content-Type: application/json; charset=utf-8\r\n"
+        "Content-Length: %zu\r\n"
+        "Retry-After: 5\r\n"
+        "Connection: close\r\n"
+        "\r\n",
+        sizeof(rate_body) - 1);
+    Seobeo_Handle_Queue(
+        p_handle, (const uint8 *)rate_header, (uint32)strlen(rate_header));
+    Seobeo_Handle_Queue(
+        p_handle, (const uint8 *)rate_body,
+        (uint32)(sizeof(rate_body) - 1));
+    Seobeo_Handle_Flush(p_handle);
     return;
   }
 
@@ -1005,13 +1604,113 @@
         p_handle, 500, "id_failed", "Unable to create request ID");
     return;
   }
-  Conversation_Store_Result result = Conversation_Store_Begin_Turn(
-      g_conversation_store,
-      conversation_id,
-      request_id,
-      prompt);
+
+  /* --- Guest quota reservation (before persisting turn) --- */
+  boolean is_guest_reserved = FALSE;
+  char    quota_guest_id[37] = {0};
+  if (principal.kind == AUTH_PRINCIPAL_GUEST)
+  {
+    Auth_Store *p_auth_store = Auth_API_Get_Store();
+    if (!p_auth_store)
+    {
+      Conversation_API_Release_Turn_Slot();
+      Conversation_API_Send_Stream_Error(
+          p_handle, 503, "store_unavailable", "Auth store unavailable");
+      return;
+    }
+
+    int64 now_unix     = (int64)time(NULL);
+    int64 window_start = conv__utc_window_start(now_unix);
+    int64 resets_at    = window_start + 86400LL;
+
+    Auth_Store_Guest_Quota_Result quota_result = Auth_Store_Guest_Reserve(
+        p_auth_store,
+        principal.guest_id,
+        request_id,
+        window_start,
+        g_guest_request_output_tokens,
+        g_guest_daily_turns,
+        g_guest_daily_output_tokens,
+        now_unix + 3600);
+
+    if (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED ||
+        quota_result == AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED)
+    {
+      /* Fetch current usage for 429 payload. */
+      Auth_Store_Guest_Usage usage;
+      memset(&usage, 0, sizeof(usage));
+      Auth_Store_Guest_Get_Usage(
+          p_auth_store, principal.guest_id, window_start, &usage);
+
+      int64 turns_remaining =
+          g_guest_daily_turns - usage.turns_used;
+      if (turns_remaining < 0) turns_remaining = 0;
+      int64 tokens_remaining =
+          g_guest_daily_output_tokens
+          - usage.output_tokens_used
+          - usage.output_tokens_reserved;
+      if (tokens_remaining < 0) tokens_remaining = 0;
+
+      const char *code = (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED)
+          ? "guest_quota_turns_exhausted"
+          : "guest_quota_tokens_exhausted";
+      const char *message = (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED)
+          ? "Daily turn limit reached"
+          : "Daily output token limit reached";
+
+      char quota_body[1024];
+      int qlen = snprintf(
+          quota_body, sizeof(quota_body),
+          "{\"error\":{\"code\":\"%s\",\"message\":\"%s\","
+          "\"quota\":{\"turnsLimit\":%lld,\"turnsUsed\":%lld,"
+          "\"turnsRemaining\":%lld,\"outputTokensLimit\":%lld,"
+          "\"outputTokensUsed\":%lld,\"outputTokensReserved\":%lld,"
+          "\"outputTokensRemaining\":%lld,\"resetsAt\":%lld}}}",
+          code, message,
+          (long long)g_guest_daily_turns,
+          (long long)usage.turns_used,
+          (long long)turns_remaining,
+          (long long)g_guest_daily_output_tokens,
+          (long long)usage.output_tokens_used,
+          (long long)usage.output_tokens_reserved,
+          (long long)tokens_remaining,
+          (long long)resets_at);
+      if (qlen <= 0 || (size_t)qlen >= sizeof(quota_body))
+      {
+        Conversation_API_Release_Turn_Slot();
+        Conversation_API_Send_Stream_Error(
+            p_handle, 429, code, message);
+        return;
+      }
+      char header[512];
+      Seobeo_Web_Header_Generate(header, 429,
+          "application/json; charset=utf-8", qlen);
+      Seobeo_Handle_Queue(
+          p_handle, (const uint8 *)header, (uint32)strlen(header));
+      Seobeo_Handle_Queue(
+          p_handle, (const uint8 *)quota_body, (uint32)qlen);
+      Seobeo_Handle_Flush(p_handle);
+      Conversation_API_Release_Turn_Slot();
+      return;
+    }
+    if (quota_result != AUTH_STORE_GUEST_QUOTA_OK)
+    {
+      Conversation_API_Release_Turn_Slot();
+      Conversation_API_Send_Stream_Error(
+          p_handle, 500, "quota_error", "Unable to check guest quota");
+      return;
+    }
+    is_guest_reserved = TRUE;
+    snprintf(quota_guest_id, sizeof(quota_guest_id), "%s",
+             principal.guest_id);
+  }
+
+  Conversation_Store_Result result = Conversation_Store_Begin_Turn_Owned(
+      g_conversation_store, conversation_id, &owner, request_id, prompt);
   if (result == CONVERSATION_STORE_NOT_FOUND)
   {
+    if (is_guest_reserved)
+      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
     Conversation_API_Release_Turn_Slot();
     Conversation_API_Send_Stream_Error(
         p_handle, 404, "not_found", "Conversation not found");
@@ -1019,6 +1718,8 @@
   }
   if (result == CONVERSATION_STORE_CONFLICT)
   {
+    if (is_guest_reserved)
+      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
     Conversation_API_Release_Turn_Slot();
     Conversation_API_Send_Stream_Error(
         p_handle, 409, "turn_in_progress", "Conversation already has an active turn");
@@ -1026,6 +1727,8 @@
   }
   if (result != CONVERSATION_STORE_OK)
   {
+    if (is_guest_reserved)
+      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
     Conversation_API_Release_Turn_Slot();
     Conversation_API_Send_Stream_Error(
         p_handle, 500, "turn_failed", "Unable to persist turn");
@@ -1037,6 +1740,8 @@
       "/api/conversations/turns");
   if (!p_stream || !Seobeo_SSE_Retain(p_stream))
   {
+    if (is_guest_reserved)
+      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
     Conversation_API_Release_Turn_Slot();
     Conversation_Store_Fail_Turn(
         g_conversation_store,
@@ -1053,6 +1758,8 @@
   Pending_Turn *p_turn = calloc(1, sizeof(*p_turn));
   if (!p_turn)
   {
+    if (is_guest_reserved)
+      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
     Conversation_API_Release_Turn_Slot();
     Conversation_Store_Fail_Turn(
         g_conversation_store,
@@ -1073,8 +1780,17 @@
       sizeof(p_turn->conversation_id),
       "%s",
       conversation_id);
+  /* Copy owner and quota fields before arena expires (req 8) */
+  p_turn->owner              = owner;
+  p_turn->is_guest_reserved  = is_guest_reserved;
+  p_turn->reserved_output_tokens =
+      is_guest_reserved ? g_guest_request_output_tokens : 0;
+  snprintf(p_turn->guest_id, sizeof(p_turn->guest_id), "%s", quota_guest_id);
   p_turn->p_stream = p_stream;
 
+  /* Register detach callback so client disconnect triggers finalization. */
+  Seobeo_SSE_Set_Detach_Callback(p_stream, Conversation_API_On_SSE_Detach, NULL);
+
   pthread_mutex_lock(&g_pending_mutex);
   p_turn->p_next = g_pending_turns;
   g_pending_turns = p_turn;
@@ -1103,16 +1819,72 @@
   }
 }
 
-boolean Conversation_API_Init(const char *database_path)
+boolean Conversation_API_Init(
+    const char                          *database_path,
+    const Conversation_API_Guest_Policy *p_policy)
 {
   if (g_conversation_store)
     return TRUE;
-  const char *allow_anonymous = getenv(
-      "MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE");
-  g_anonymous_inference_enabled =
-      allow_anonymous &&
-      (strcmp(allow_anonymous, "1") == 0 ||
-       strcasecmp(allow_anonymous, "true") == 0);
+
+  if (p_policy)
+  {
+    /* Validate policy ranges. */
+    if (p_policy->daily_turns < CONV_GUEST_DAILY_TURNS_MIN ||
+        p_policy->daily_turns > CONV_GUEST_DAILY_TURNS_MAX)
+    {
+      Seobeo_Log(SEOBEO_ERROR,
+                 "[CONV] daily_turns must be %d..%d\n",
+                 CONV_GUEST_DAILY_TURNS_MIN, CONV_GUEST_DAILY_TURNS_MAX);
+      return FALSE;
+    }
+    if (p_policy->daily_output_tokens < CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN ||
+        p_policy->daily_output_tokens > CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX)
+    {
+      Seobeo_Log(SEOBEO_ERROR,
+                 "[CONV] daily_output_tokens must be %d..%d\n",
+                 CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN,
+                 CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX);
+      return FALSE;
+    }
+    if (p_policy->request_output_tokens < CONV_GUEST_REQUEST_OUTPUT_TOKENS_MIN ||
+        p_policy->request_output_tokens > p_policy->daily_output_tokens)
+    {
+      Seobeo_Log(SEOBEO_ERROR,
+                 "[CONV] request_output_tokens must be 1..%lld\n",
+                 (long long)p_policy->daily_output_tokens);
+      return FALSE;
+    }
+    g_guest_inference_enabled     = p_policy->guest_inference_enabled;
+    g_guest_daily_turns           = p_policy->daily_turns;
+    g_guest_daily_output_tokens   = p_policy->daily_output_tokens;
+    g_guest_request_output_tokens = p_policy->request_output_tokens;
+  }
+  else
+  {
+    /* No explicit policy: read from environment (backwards compat). */
+    const char *allow_guest = getenv("MRJUNEJUNE_ALLOW_GUEST_INFERENCE");
+    if (!allow_guest)
+    {
+      const char *allow_anon = getenv("MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE");
+      if (allow_anon)
+      {
+        Seobeo_Log(SEOBEO_WARNING,
+                   "[CONV] MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE is deprecated;"
+                   " use MRJUNEJUNE_ALLOW_GUEST_INFERENCE\n");
+        allow_guest = allow_anon;
+      }
+    }
+    g_guest_inference_enabled =
+        allow_guest &&
+        (strcmp(allow_guest, "1") == 0 ||
+         strcasecmp(allow_guest, "true") == 0);
+
+    g_guest_request_output_tokens =
+        g_guest_daily_output_tokens < CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT
+        ? g_guest_daily_output_tokens
+        : CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT;
+  }
+
   g_conversation_store = Conversation_Store_Create(database_path);
   return g_conversation_store != NULL;
 }
@@ -1141,11 +1913,40 @@
   return TRUE;
 }
 
+/* Guest-to-user transfer hook registered with Auth_API on init */
+static boolean Conversation_API_Guest_Transfer_Hook(
+    const char *guest_id,
+    const char *user_id,
+    void *context)
+{
+  (void)context;
+  if (!g_conversation_store)
+    return FALSE;
+
+  /* Atomically transfer conversation ownership AND clear outstanding quota
+   * reservations in one transaction (auth and conv tables share the same
+   * SQLite file, so the write lock covers both). */
+  return Conversation_Store_Transfer_Guest_To_User_Atomic(
+      g_conversation_store, guest_id, user_id) == CONVERSATION_STORE_OK;
+}
+
 void Conversation_API_Register_Routes(void)
 {
+  /* Wire guest-to-user transfer on login */
+  Auth_API_Register_Guest_Transfer_Hook(
+      Conversation_API_Guest_Transfer_Hook, NULL);
+
+  /* Wire quota callback for the session endpoint. */
+  Auth_API_Register_Guest_Quota_Cb(conv_guest_quota_cb);
+
   Seobeo_Router_Register(
       "GET", "/api/inference/health", Conversation_API_Health);
+  Seobeo_Router_Register(
+      "GET", "/api/conversations", Conversation_API_List);
   Seobeo_Router_Register("POST", "/api/conversations", Conversation_API_Create);
+  /* Body-based claim: ID stays in request body, not in URL or logs. */
+  Seobeo_Router_Register(
+      "POST", "/api/conversations/claim", Conversation_API_Claim_Body);
   Seobeo_Router_Register(
       "GET", "/api/conversations/:conversation_id", Conversation_API_Get);
   Seobeo_Router_Register(
@@ -1167,6 +1968,13 @@
   {
     Pending_Turn *p_turn = g_pending_turns;
     g_pending_turns = p_turn->p_next;
+    /* Release guest quota reservation on server shutdown (keep turn charge). */
+    if (p_turn->is_guest_reserved)
+    {
+      Auth_Store *p_auth_store = Auth_API_Get_Store();
+      if (p_auth_store)
+        Auth_Store_Guest_Release(p_auth_store, p_turn->request_id);
+    }
     Conversation_Store_Fail_Turn(
         g_conversation_store,
         p_turn->conversation_id,
@@ -1182,5 +1990,8 @@
   pthread_mutex_unlock(&g_pending_mutex);
   Conversation_Store_Destroy(g_conversation_store);
   g_conversation_store = NULL;
-  g_anonymous_inference_enabled = FALSE;
+  g_guest_inference_enabled       = FALSE;
+  g_guest_daily_turns             = CONV_GUEST_DAILY_TURNS_DEFAULT;
+  g_guest_daily_output_tokens     = CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT;
+  g_guest_request_output_tokens   = CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT;
 }
--- a/mrjunejune/conversation_api.h	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/conversation_api.h	Fri Aug 07 07:34:12 2026 -0700
@@ -3,7 +3,25 @@
 
 #include "dowa/dowa.h"
 
-boolean Conversation_API_Init(const char *database_path);
+/*
+ * Guest quota policy passed from the config loader into Conversation_API_Init.
+ * All fields must be validated (positive integers within supported ranges)
+ * before passing; Init returns FALSE if any field is out of range.
+ * Set guest_inference_enabled = FALSE to block all guest inference; TRUE to
+ * allow it subject to quota.
+ * request_output_tokens: per-request reservation cap; must be <= daily_output_tokens.
+ */
+typedef struct {
+  boolean guest_inference_enabled;
+  int64   daily_turns;
+  int64   daily_output_tokens;
+  int64   request_output_tokens;
+} Conversation_API_Guest_Policy;
+
+boolean Conversation_API_Init(
+    const char                      *database_path,
+    const Conversation_API_Guest_Policy *p_policy);
+
 boolean Conversation_API_Enable_Inference(
     const char *sidecar_path,
     const char *copilot_cli_path);
--- a/mrjunejune/conversation_store.c	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/conversation_store.c	Fri Aug 07 07:34:12 2026 -0700
@@ -89,6 +89,173 @@
   return result;
 }
 
+/* ------------------------------------------------------------------ */
+/* Schema migration helpers (called from Conversation_Store_Create)    */
+/* ------------------------------------------------------------------ */
+
+static const char *Conversation_Store_Owner_Kind_String(
+    Conversation_Owner_Kind kind)
+{
+  switch (kind)
+  {
+  case CONVERSATION_OWNER_KIND_USER:   return "user";
+  case CONVERSATION_OWNER_KIND_GUEST:  return "guest";
+  case CONVERSATION_OWNER_KIND_LEGACY: return "legacy";
+  default:                             return "legacy";
+  }
+}
+
+/* Returns TRUE if column exists in table (mutex must NOT be held). */
+static boolean Conversation_Store_Column_Exists(
+    Conversation_Store *p_store,
+    const char *table_name,
+    const char *column_name)
+{
+  char sql[256];
+  snprintf(sql, sizeof(sql),
+           "SELECT 1 FROM pragma_table_info('%s') WHERE name = '%s'",
+           table_name, column_name);
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+    return FALSE;
+  Deita_Result_Set *p_result =
+      Deita_Query_Execute_Prepared(p_store->p_connection, sql, 0, NULL, p_arena);
+  boolean exists = p_result && Deita_Result_Set_Next(p_result);
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+  return exists;
+}
+
+/*
+ * Migration 1: add owner_kind / owner_id columns and listing index.
+ * Existing rows become owner_kind='legacy', owner_id=NULL.
+ * Safe to call on a database that was created by new code (idempotent).
+ */
+static boolean Conversation_Store_Apply_Migration_1(
+    Conversation_Store *p_store)
+{
+  /* Check migrations ledger */
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+    return FALSE;
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT 1 FROM conversation_schema_migrations WHERE version = 1",
+      0, NULL, p_arena);
+  boolean already_done = p_result && Deita_Result_Set_Next(p_result);
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+  if (already_done)
+    return TRUE;
+
+  if (!Conversation_Store_Column_Exists(p_store, "conversations", "owner_kind"))
+  {
+    if (Deita_Query_Execute_Update(
+            p_store->p_connection,
+            "ALTER TABLE conversations ADD COLUMN owner_kind TEXT "
+            "NOT NULL DEFAULT 'legacy'") < 0)
+      return FALSE;
+  }
+  if (!Conversation_Store_Column_Exists(p_store, "conversations", "owner_id"))
+  {
+    if (Deita_Query_Execute_Update(
+            p_store->p_connection,
+            "ALTER TABLE conversations ADD COLUMN owner_id TEXT") < 0)
+      return FALSE;
+  }
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "CREATE INDEX IF NOT EXISTS idx_conversations_owner_listing "
+          "ON conversations(owner_kind, owner_id, updated_at DESC, id)") < 0)
+    return FALSE;
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "INSERT OR IGNORE INTO conversation_schema_migrations (version) "
+          "VALUES (1)") < 0)
+    return FALSE;
+  return TRUE;
+}
+
+/*
+ * Migration 2: create the guest-to-user transfer mapping table.
+ * Records a permanent mapping from guest_id → user_id set at transfer time.
+ * Used by Create_Owned to redirect stale guest creates to the mapped user.
+ */
+static boolean Conversation_Store_Apply_Migration_2(
+    Conversation_Store *p_store)
+{
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+    return FALSE;
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT 1 FROM conversation_schema_migrations WHERE version = 2",
+      0, NULL, p_arena);
+  boolean already_done = p_result && Deita_Result_Set_Next(p_result);
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+  if (already_done)
+    return TRUE;
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "CREATE TABLE IF NOT EXISTS conversation_guest_transfers ("
+          "guest_id TEXT PRIMARY KEY,"
+          "user_id TEXT NOT NULL,"
+          "transferred_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
+          ")") < 0)
+    return FALSE;
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "INSERT OR IGNORE INTO conversation_schema_migrations (version) "
+          "VALUES (2)") < 0)
+    return FALSE;
+  return TRUE;
+}
+
+/*
+ * Migration 3: recreate the owner listing index with id DESC so that
+ * keyset pagination is stable when updated_at values collide.
+ * Drops and recreates the index atomically from the migration ledger's
+ * perspective; safe to run on any database that has migration 1 applied.
+ */
+static boolean Conversation_Store_Apply_Migration_3(
+    Conversation_Store *p_store)
+{
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+    return FALSE;
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT 1 FROM conversation_schema_migrations WHERE version = 3",
+      0, NULL, p_arena);
+  boolean already_done = p_result && Deita_Result_Set_Next(p_result);
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+  if (already_done)
+    return TRUE;
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "DROP INDEX IF EXISTS idx_conversations_owner_listing") < 0)
+    return FALSE;
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "CREATE INDEX IF NOT EXISTS idx_conversations_owner_listing "
+          "ON conversations(owner_kind, owner_id, updated_at DESC, id DESC)") < 0)
+    return FALSE;
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "INSERT OR IGNORE INTO conversation_schema_migrations (version) "
+          "VALUES (3)") < 0)
+    return FALSE;
+  return TRUE;
+}
+
 Conversation_Store *Conversation_Store_Create(const char *database_path)
 {
   if (!database_path)
@@ -150,12 +317,31 @@
       "CREATE INDEX IF NOT EXISTS idx_conversations_updated "
       "ON conversations(updated_at DESC);"
       "CREATE INDEX IF NOT EXISTS idx_turns_conversation_sequence "
-      "ON conversation_turns(conversation_id, sequence);";
+      "ON conversation_turns(conversation_id, sequence);"
+      "CREATE TABLE IF NOT EXISTS conversation_schema_migrations ("
+      "version INTEGER PRIMARY KEY,"
+      "applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
+      ");";
   if (Deita_Query_Execute_Update(p_store->p_connection, schema) < 0)
   {
     Conversation_Store_Destroy(p_store);
     return NULL;
   }
+  if (!Conversation_Store_Apply_Migration_1(p_store))
+  {
+    Conversation_Store_Destroy(p_store);
+    return NULL;
+  }
+  if (!Conversation_Store_Apply_Migration_2(p_store))
+  {
+    Conversation_Store_Destroy(p_store);
+    return NULL;
+  }
+  if (!Conversation_Store_Apply_Migration_3(p_store))
+  {
+    Conversation_Store_Destroy(p_store);
+    return NULL;
+  }
   if (Deita_Query_Execute_Update(
           p_store->p_connection,
           "UPDATE conversation_turns "
@@ -170,6 +356,8 @@
   return p_store;
 }
 
+
+
 void Conversation_Store_Destroy(Conversation_Store *p_store)
 {
   if (!p_store)
@@ -494,3 +682,757 @@
     return CONVERSATION_STORE_ERROR;
   return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK;
 }
+
+/* ------------------------------------------------------------------ */
+/* Owner-aware APIs                                                     */
+/* ------------------------------------------------------------------ */
+
+Conversation_Store_Result Conversation_Store_Create_Owned(
+    Conversation_Store *p_store,
+    const char *title,
+    const Conversation_Owner *p_owner,
+    char output_id[37])
+{
+  if (!p_store || !p_owner || !output_id)
+    return CONVERSATION_STORE_ERROR;
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY ||
+      p_owner->id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+  if (!Conversation_Store_Generate_UUID(output_id))
+    return CONVERSATION_STORE_ERROR;
+
+  /* Resolved owner fields — may be overridden by transfer mapping below */
+  const char *resolved_kind = Conversation_Store_Owner_Kind_String(p_owner->kind);
+  char resolved_id[37];
+  strncpy(resolved_id, p_owner->id, 36);
+  resolved_id[36] = '\0';
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  /* For guest owners: check transfer mapping within the same transaction.
+   * If the guest was transferred to a user, assign to that user instead. */
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_GUEST)
+  {
+    Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+    if (!p_arena)
+    {
+      Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return CONVERSATION_STORE_ERROR;
+    }
+    const char *check_params[] = {p_owner->id};
+    Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+        p_store->p_connection,
+        "SELECT user_id FROM conversation_guest_transfers WHERE guest_id = ?",
+        1, check_params, p_arena);
+    if (p_result && Deita_Result_Set_Next(p_result))
+    {
+      const char *mapped_user = Deita_Result_Set_Get_Text(p_result, 0);
+      if (mapped_user && mapped_user[0] != '\0')
+      {
+        strncpy(resolved_id, mapped_user, 36);
+        resolved_id[36] = '\0';
+        resolved_kind = "user";
+      }
+    }
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_arena);
+  }
+
+  const char *parameters[] = {
+    output_id,
+    output_id,
+    title ? title : "",
+    resolved_kind,
+    resolved_id,
+  };
+  int32 result = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "INSERT INTO conversations "
+      "(id, copilot_session_id, title, owner_kind, owner_id) "
+      "VALUES (?, ?, ?, ?, ?)",
+      5,
+      parameters);
+  if (result < 0 ||
+      Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  pthread_mutex_unlock(&p_store->mutex);
+  return result < 0 ? CONVERSATION_STORE_ERROR : CONVERSATION_STORE_OK;
+}
+
+Conversation_Store_Result Conversation_Store_Get_Owned(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner,
+    Conversation_Record *p_record,
+    Dowa_Arena *p_arena)
+{
+  if (!p_store || !conversation_id || !p_owner || !p_record || !p_arena)
+    return CONVERSATION_STORE_ERROR;
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY ||
+      p_owner->id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+  memset(p_record, 0, sizeof(*p_record));
+
+  const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind);
+  const char *parameters[] = {conversation_id, kind_str, p_owner->id};
+  pthread_mutex_lock(&p_store->mutex);
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT id, copilot_session_id, title, status, created_at, updated_at "
+      "FROM conversations "
+      "WHERE id = ? AND status != 'deleted' "
+      "AND owner_kind = ? AND owner_id = ?",
+      3,
+      parameters,
+      p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_NOT_FOUND;
+  }
+  p_record->id = Conversation_Store_Copy_Text(
+      Deita_Result_Set_Get_Text(p_result, 0), p_arena);
+  p_record->copilot_session_id = Conversation_Store_Copy_Text(
+      Deita_Result_Set_Get_Text(p_result, 1), p_arena);
+  p_record->title = Conversation_Store_Copy_Text(
+      Deita_Result_Set_Get_Text(p_result, 2), p_arena);
+  p_record->status = Conversation_Store_Copy_Text(
+      Deita_Result_Set_Get_Text(p_result, 3), p_arena);
+  p_record->created_at = Deita_Result_Set_Get_Integer(p_result, 4);
+  p_record->updated_at = Deita_Result_Set_Get_Integer(p_result, 5);
+  Deita_Result_Set_Free(p_result);
+
+  const char *turn_params[] = {conversation_id};
+  p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT id, sequence, role, content, status, request_id, "
+      "error_message, input_tokens, output_tokens, created_at, completed_at "
+      "FROM (SELECT id, sequence, role, content, status, request_id, "
+      "error_message, input_tokens, output_tokens, created_at, completed_at "
+      "FROM conversation_turns WHERE conversation_id = ? "
+      "ORDER BY sequence DESC LIMIT 20) ORDER BY sequence",
+      1,
+      turn_params,
+      p_arena);
+  if (!p_result)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  while (Deita_Result_Set_Next(p_result))
+  {
+    Conversation_Turn turn = {0};
+    turn.id = Deita_Result_Set_Get_Integer(p_result, 0);
+    turn.sequence = Deita_Result_Set_Get_Integer(p_result, 1);
+    turn.role = Conversation_Store_Copy_Text(
+        Deita_Result_Set_Get_Text(p_result, 2), p_arena);
+    turn.content = Conversation_Store_Copy_Text(
+        Deita_Result_Set_Get_Text(p_result, 3), p_arena);
+    turn.status = Conversation_Store_Copy_Text(
+        Deita_Result_Set_Get_Text(p_result, 4), p_arena);
+    turn.request_id = Conversation_Store_Copy_Text(
+        Deita_Result_Set_Get_Text(p_result, 5), p_arena);
+    turn.error_message = Conversation_Store_Copy_Text(
+        Deita_Result_Set_Get_Text(p_result, 6), p_arena);
+    turn.input_tokens = Deita_Result_Set_Get_Integer(p_result, 7);
+    turn.output_tokens = Deita_Result_Set_Get_Integer(p_result, 8);
+    turn.created_at = Deita_Result_Set_Get_Integer(p_result, 9);
+    turn.completed_at = Deita_Result_Set_Get_Integer(p_result, 10);
+    Dowa_Array_Push_Arena(p_record->turns, turn, p_arena);
+  }
+  boolean turns_error = Deita_Result_Set_Has_Error(p_result);
+  Deita_Result_Set_Free(p_result);
+  pthread_mutex_unlock(&p_store->mutex);
+  return turns_error ? CONVERSATION_STORE_ERROR : CONVERSATION_STORE_OK;
+}
+
+Conversation_Store_Result Conversation_Store_Update_Title_Owned(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner,
+    const char *title)
+{
+  if (!p_store || !conversation_id || !p_owner || !title)
+    return CONVERSATION_STORE_ERROR;
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY ||
+      p_owner->id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+
+  const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind);
+  const char *parameters[] = {title, conversation_id, kind_str, p_owner->id};
+  pthread_mutex_lock(&p_store->mutex);
+  int32 result = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE conversations SET title = ?, "
+      "updated_at = strftime('%s','now') "
+      "WHERE id = ? AND status != 'deleted' "
+      "AND owner_kind = ? AND owner_id = ?",
+      4,
+      parameters);
+  pthread_mutex_unlock(&p_store->mutex);
+  if (result < 0)
+    return CONVERSATION_STORE_ERROR;
+  return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK;
+}
+
+Conversation_Store_Result Conversation_Store_Delete_Owned(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner)
+{
+  if (!p_store || !conversation_id || !p_owner)
+    return CONVERSATION_STORE_ERROR;
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY ||
+      p_owner->id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+
+  const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind);
+  const char *parameters[] = {conversation_id, kind_str, p_owner->id};
+  pthread_mutex_lock(&p_store->mutex);
+  int32 result = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "DELETE FROM conversations WHERE id = ? "
+      "AND owner_kind = ? AND owner_id = ?",
+      3,
+      parameters);
+  pthread_mutex_unlock(&p_store->mutex);
+  if (result < 0)
+    return CONVERSATION_STORE_ERROR;
+  return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK;
+}
+
+/* Checks conversation ownership without loading turns (called under mutex). */
+static boolean Conversation_Store_Owns_Locked(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner)
+{
+  const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind);
+  const char *parameters[] = {conversation_id, kind_str, p_owner->id};
+  Dowa_Arena *p_arena = Dowa_Arena_Create(1024);
+  if (!p_arena)
+    return FALSE;
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT 1 FROM conversations "
+      "WHERE id = ? AND status != 'deleted' "
+      "AND owner_kind = ? AND owner_id = ?",
+      3,
+      parameters,
+      p_arena);
+  boolean owns = p_result && Deita_Result_Set_Next(p_result);
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+  return owns;
+}
+
+Conversation_Store_Result Conversation_Store_Begin_Turn_Owned(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner,
+    const char *request_id,
+    const char *prompt)
+{
+  if (!p_store || !conversation_id || !p_owner || !request_id || !prompt)
+    return CONVERSATION_STORE_ERROR;
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY ||
+      p_owner->id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  if (!Conversation_Store_Owns_Locked(p_store, conversation_id, p_owner))
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_NOT_FOUND);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_NOT_FOUND;
+  }
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
+  const char *seq_params[] = {conversation_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT COALESCE(MAX(sequence), 0), "
+      "SUM(CASE WHEN role = 'assistant' AND status = 'active' "
+      "THEN 1 ELSE 0 END) "
+      "FROM conversation_turns WHERE conversation_id = ?",
+      1,
+      seq_params,
+      p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_arena);
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  int64 next_sequence = Deita_Result_Set_Get_Integer(p_result, 0) + 1;
+  int64 active_count  = Deita_Result_Set_Get_Integer(p_result, 1);
+  Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+  if (active_count > 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_CONFLICT;
+  }
+
+  char user_sequence[32];
+  char assistant_sequence[32];
+  snprintf(user_sequence, sizeof(user_sequence), "%lld",
+           (long long)next_sequence);
+  snprintf(assistant_sequence, sizeof(assistant_sequence), "%lld",
+           (long long)(next_sequence + 1));
+  const char *user_params[] = {
+    conversation_id, user_sequence, prompt, request_id,
+  };
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "INSERT INTO conversation_turns "
+          "(conversation_id, sequence, role, content, status, request_id, "
+          "completed_at) VALUES (?, ?, 'user', ?, 'complete', ?, "
+          "strftime('%s','now'))",
+          4,
+          user_params) < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  const char *asst_params[] = {conversation_id, assistant_sequence, request_id};
+  const char *conv_params[]  = {conversation_id};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "INSERT INTO conversation_turns "
+          "(conversation_id, sequence, role, status, request_id) "
+          "VALUES (?, ?, 'assistant', 'active', ?)",
+          3,
+          asst_params) < 0 ||
+      Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "UPDATE conversations SET updated_at = strftime('%s','now') "
+          "WHERE id = ?",
+          1,
+          conv_params) < 0 ||
+      Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  pthread_mutex_unlock(&p_store->mutex);
+  return CONVERSATION_STORE_OK;
+}
+
+Conversation_Store_Result Conversation_Store_List(
+    Conversation_Store *p_store,
+    const Conversation_Owner *p_owner,
+    int64 cursor_updated_at,
+    const char *cursor_id,
+    int32 limit,
+    Conversation_Summary **pp_summaries,
+    int32 *p_count,
+    Dowa_Arena *p_arena)
+{
+  if (!p_store || !p_owner || !pp_summaries || !p_count || !p_arena)
+    return CONVERSATION_STORE_ERROR;
+  *pp_summaries = NULL;
+  *p_count = 0;
+
+  /* Legacy conversations are never listed */
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY ||
+      p_owner->id[0] == '\0')
+    return CONVERSATION_STORE_OK;
+
+  if (limit < 1) limit = 1;
+  if (limit > 50) limit = 50;
+
+  const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind);
+  char limit_str[16];
+  snprintf(limit_str, sizeof(limit_str), "%d", limit);
+
+  Deita_Result_Set *p_result;
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (cursor_updated_at > 0 && cursor_id && cursor_id[0] != '\0')
+  {
+    char ts_str[32];
+    snprintf(ts_str, sizeof(ts_str), "%lld", (long long)cursor_updated_at);
+    const char *params[] = {
+      kind_str, p_owner->id, ts_str, ts_str, cursor_id, limit_str,
+    };
+    p_result = Deita_Query_Execute_Prepared(
+        p_store->p_connection,
+        "SELECT id, title, status, created_at, updated_at, "
+        "(SELECT COUNT(*) FROM conversation_turns "
+        " WHERE conversation_id = conversations.id) AS turn_count, "
+        "(SELECT SUBSTR(content, 1, 201) FROM conversation_turns "
+        " WHERE conversation_id = conversations.id "
+        " ORDER BY sequence DESC LIMIT 1) AS last_msg "
+        "FROM conversations "
+        "WHERE owner_kind = ? AND owner_id = ? AND status != 'deleted' "
+        "AND (updated_at < ? OR (updated_at = ? AND id < ?)) "
+        "ORDER BY updated_at DESC, id DESC LIMIT ?",
+        6, params, p_arena);
+  }
+  else
+  {
+    const char *params[] = {kind_str, p_owner->id, limit_str};
+    p_result = Deita_Query_Execute_Prepared(
+        p_store->p_connection,
+        "SELECT id, title, status, created_at, updated_at, "
+        "(SELECT COUNT(*) FROM conversation_turns "
+        " WHERE conversation_id = conversations.id) AS turn_count, "
+        "(SELECT SUBSTR(content, 1, 201) FROM conversation_turns "
+        " WHERE conversation_id = conversations.id "
+        " ORDER BY sequence DESC LIMIT 1) AS last_msg "
+        "FROM conversations "
+        "WHERE owner_kind = ? AND owner_id = ? AND status != 'deleted' "
+        "ORDER BY updated_at DESC, id DESC LIMIT ?",
+        3, params, p_arena);
+  }
+
+  if (!p_result)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  Conversation_Summary *summaries = NULL;
+  while (Deita_Result_Set_Next(p_result))
+  {
+    Conversation_Summary s = {0};
+    s.id     = Conversation_Store_Copy_Text(Deita_Result_Set_Get_Text(p_result, 0), p_arena);
+    s.title  = Conversation_Store_Copy_Text(Deita_Result_Set_Get_Text(p_result, 1), p_arena);
+    s.status = Conversation_Store_Copy_Text(Deita_Result_Set_Get_Text(p_result, 2), p_arena);
+    s.created_at  = Deita_Result_Set_Get_Integer(p_result, 3);
+    s.updated_at  = Deita_Result_Set_Get_Integer(p_result, 4);
+    s.turn_count  = Deita_Result_Set_Get_Integer(p_result, 5);
+    const char *last_msg_raw = Deita_Result_Set_Get_Text(p_result, 6);
+    if (last_msg_raw && last_msg_raw[0] != '\0')
+    {
+      /* Truncate to 200 chars maximum */
+      size_t msg_len = strlen(last_msg_raw);
+      if (msg_len > 200) msg_len = 200;
+      char *preview = Dowa_Arena_Allocate(p_arena, msg_len + 1);
+      if (preview)
+      {
+        memcpy(preview, last_msg_raw, msg_len);
+        preview[msg_len] = '\0';
+      }
+      s.last_message_preview = preview;
+    }
+    else
+    {
+      s.last_message_preview = Conversation_Store_Copy_Text("", p_arena);
+    }
+    Dowa_Array_Push_Arena(summaries, s, p_arena);
+  }
+  boolean has_error = Deita_Result_Set_Has_Error(p_result);
+  Deita_Result_Set_Free(p_result);
+  pthread_mutex_unlock(&p_store->mutex);
+
+  if (has_error)
+    return CONVERSATION_STORE_ERROR;
+
+  *pp_summaries = summaries;
+  *p_count = (int32)Dowa_Array_Length(summaries);
+  return CONVERSATION_STORE_OK;
+}
+
+Conversation_Store_Result Conversation_Store_Claim_Legacy(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const char *user_id)
+{
+  if (!p_store || !conversation_id || !user_id || user_id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  const char *check_params[] = {conversation_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT owner_kind FROM conversations "
+      "WHERE id = ? AND status != 'deleted'",
+      1, check_params, p_arena);
+  boolean found = p_result && Deita_Result_Set_Next(p_result);
+  const char *existing_kind = found
+      ? Deita_Result_Set_Get_Text(p_result, 0)
+      : NULL;
+  boolean is_claimable =
+      existing_kind && strcmp(existing_kind, "legacy") == 0;
+  if (p_result) Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  if (!found)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_NOT_FOUND);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_NOT_FOUND;
+  }
+  if (!is_claimable)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_CONFLICT;
+  }
+
+  const char *update_params[] = {user_id, conversation_id};
+  int32 updated = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE conversations SET owner_kind = 'user', owner_id = ?, "
+      "updated_at = strftime('%s','now') "
+      "WHERE id = ? AND owner_kind = 'legacy'",
+      2, update_params);
+  if (updated < 0 ||
+      Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  pthread_mutex_unlock(&p_store->mutex);
+  return updated > 0 ? CONVERSATION_STORE_OK : CONVERSATION_STORE_NOT_FOUND;
+}
+
+Conversation_Store_Result Conversation_Store_Transfer_Guest_To_User(
+    Conversation_Store *p_store,
+    const char *guest_id,
+    const char *user_id)
+{
+  if (!p_store || !guest_id || !user_id ||
+      guest_id[0] == '\0' || user_id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  /* Check for an existing mapping for this guest_id */
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  const char *check_params[] = {guest_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT user_id FROM conversation_guest_transfers WHERE guest_id = ?",
+      1, check_params, p_arena);
+  boolean mapping_exists = p_result && Deita_Result_Set_Next(p_result);
+  const char *existing_user = mapping_exists
+      ? Deita_Result_Set_Get_Text(p_result, 0) : NULL;
+  boolean same_user = mapping_exists && existing_user &&
+      strcmp(existing_user, user_id) == 0;
+  boolean conflict = mapping_exists && !same_user;
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  if (conflict)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_CONFLICT;
+  }
+
+  if (!mapping_exists)
+  {
+    /* Record the mapping so future Create_Owned for this guest uses user */
+    const char *map_params[] = {guest_id, user_id};
+    if (Deita_Query_Execute_Update_Prepared(
+            p_store->p_connection,
+            "INSERT OR IGNORE INTO conversation_guest_transfers "
+            "(guest_id, user_id) VALUES (?, ?)",
+            2, map_params) < 0)
+    {
+      Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return CONVERSATION_STORE_ERROR;
+    }
+
+    /* Transfer existing conversations from guest to user */
+    const char *transfer_params[] = {user_id, guest_id};
+    if (Deita_Query_Execute_Update_Prepared(
+            p_store->p_connection,
+            "UPDATE conversations SET owner_kind = 'user', owner_id = ?, "
+            "updated_at = strftime('%s','now') "
+            "WHERE owner_kind = 'guest' AND owner_id = ?",
+            2, transfer_params) < 0)
+    {
+      Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return CONVERSATION_STORE_ERROR;
+    }
+  }
+  /* same_user mapping already exists: idempotent no-op */
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  pthread_mutex_unlock(&p_store->mutex);
+  return CONVERSATION_STORE_OK;
+}
+
+Conversation_Store_Result Conversation_Store_Transfer_Guest_To_User_Atomic(
+    Conversation_Store *p_store,
+    const char *guest_id,
+    const char *user_id)
+{
+  if (!p_store || !guest_id || !user_id ||
+      guest_id[0] == '\0' || user_id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  /* Check for an existing mapping for this guest_id. */
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  const char *check_params[] = {guest_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT user_id FROM conversation_guest_transfers WHERE guest_id = ?",
+      1, check_params, p_arena);
+  boolean mapping_exists = p_result && Deita_Result_Set_Next(p_result);
+  const char *existing_user = mapping_exists
+      ? Deita_Result_Set_Get_Text(p_result, 0) : NULL;
+  boolean same_user = mapping_exists && existing_user &&
+      strcmp(existing_user, user_id) == 0;
+  boolean conflict = mapping_exists && !same_user;
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  if (conflict)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_CONFLICT;
+  }
+
+  if (!same_user)
+  {
+    const char *map_params[] = {guest_id, user_id};
+    if (Deita_Query_Execute_Update_Prepared(
+            p_store->p_connection,
+            "INSERT OR IGNORE INTO conversation_guest_transfers "
+            "(guest_id, user_id) VALUES (?, ?)",
+            2, map_params) < 0)
+    {
+      Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return CONVERSATION_STORE_ERROR;
+    }
+
+    const char *transfer_params[] = {user_id, guest_id};
+    if (Deita_Query_Execute_Update_Prepared(
+            p_store->p_connection,
+            "UPDATE conversations SET owner_kind = 'user', owner_id = ?, "
+            "updated_at = strftime('%s','now') "
+            "WHERE owner_kind = 'guest' AND owner_id = ?",
+            2, transfer_params) < 0)
+    {
+      Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return CONVERSATION_STORE_ERROR;
+    }
+  }
+
+  /*
+   * Quota cleanup — auth tables reside in the same SQLite file so the write
+   * lock already held by this transaction covers them too.  Decrement
+   * output_tokens_reserved and delete reservation rows for this guest.
+   */
+  const char *quota_upd_params[] = {guest_id, guest_id};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "UPDATE guest_usage"
+          "  SET output_tokens_reserved = MAX(0, output_tokens_reserved - ("
+          "    SELECT COALESCE(SUM(r.output_tokens_reserved), 0)"
+          "    FROM guest_usage_reservations r"
+          "    WHERE r.guest_id = ? AND r.window_start = guest_usage.window_start"
+          "  ))"
+          "  WHERE guest_id = ?",
+          2, quota_upd_params) < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  const char *quota_del_params[] = {guest_id};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "DELETE FROM guest_usage_reservations WHERE guest_id = ?",
+          1, quota_del_params) < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  pthread_mutex_unlock(&p_store->mutex);
+  return CONVERSATION_STORE_OK;
+}
--- a/mrjunejune/conversation_store.h	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/conversation_store.h	Fri Aug 07 07:34:12 2026 -0700
@@ -12,6 +12,46 @@
   CONVERSATION_STORE_CONFLICT = 2,
 } Conversation_Store_Result;
 
+/* ------------------------------------------------------------------ */
+/* Ownership types                                                      */
+/* ------------------------------------------------------------------ */
+
+typedef enum {
+  CONVERSATION_OWNER_KIND_USER   = 0,
+  CONVERSATION_OWNER_KIND_GUEST  = 1,
+  CONVERSATION_OWNER_KIND_LEGACY = 2,
+} Conversation_Owner_Kind;
+
+/*
+ * Owner identity for a conversation.
+ * kind=USER/GUEST: id holds user_id or guest_id (UUID, 36 chars).
+ * kind=LEGACY:     id is empty; owned by no one.
+ */
+typedef struct {
+  Conversation_Owner_Kind kind;
+  char id[37]; /* user_id or guest_id; empty string for legacy */
+} Conversation_Owner;
+
+/*
+ * Lightweight summary returned by the listing endpoint.
+ * All string pointers are arena-allocated.
+ * last_message_preview is truncated to 200 chars (UTF-8 may split mid-char;
+ * callers must not rely on it being valid UTF-8 at the boundary).
+ */
+typedef struct {
+  char  *id;
+  char  *title;
+  char  *status;
+  int64  created_at;
+  int64  updated_at;
+  int64  turn_count;
+  char  *last_message_preview; /* at most 200 bytes + NUL */
+} Conversation_Summary;
+
+/* ------------------------------------------------------------------ */
+/* Turn and record types                                                */
+/* ------------------------------------------------------------------ */
+
 typedef struct {
   int64 id;
   int64 sequence;
@@ -36,10 +76,116 @@
   Conversation_Turn *turns;
 } Conversation_Record;
 
+/* ------------------------------------------------------------------ */
+/* Lifecycle                                                            */
+/* ------------------------------------------------------------------ */
+
 Conversation_Store *Conversation_Store_Create(const char *database_path);
 void Conversation_Store_Destroy(Conversation_Store *p_store);
 boolean Conversation_Store_Generate_UUID(char output[37]);
 
+/* ------------------------------------------------------------------ */
+/* Owner-aware APIs (production HTTP handlers must use these)           */
+/* ------------------------------------------------------------------ */
+
+/*
+ * Create a conversation assigned to p_owner.
+ * p_owner->kind must be USER or GUEST; LEGACY is rejected.
+ */
+Conversation_Store_Result Conversation_Store_Create_Owned(
+    Conversation_Store *p_store,
+    const char *title,
+    const Conversation_Owner *p_owner,
+    char output_id[37]);
+
+/*
+ * Get a conversation; returns NOT_FOUND if the conversation does not belong
+ * to p_owner (existence leaks prevented).
+ */
+Conversation_Store_Result Conversation_Store_Get_Owned(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner,
+    Conversation_Record *p_record,
+    Dowa_Arena *p_arena);
+
+Conversation_Store_Result Conversation_Store_Update_Title_Owned(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner,
+    const char *title);
+
+Conversation_Store_Result Conversation_Store_Delete_Owned(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner);
+
+Conversation_Store_Result Conversation_Store_Begin_Turn_Owned(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner,
+    const char *request_id,
+    const char *prompt);
+
+/*
+ * List conversations for an owner, ordered newest-first.
+ * Legacy conversations are never listed (returns empty list for LEGACY owner).
+ *
+ * cursor_updated_at / cursor_id: pass 0 / NULL for the first page.
+ * limit: capped to 50 internally; must be >= 1.
+ *
+ * *pp_summaries is arena-allocated; *p_count is the number of items.
+ * A cursor for the next page (if *p_count == limit) can be derived from
+ * (*pp_summaries)[*p_count - 1].{updated_at, id}.
+ */
+Conversation_Store_Result Conversation_Store_List(
+    Conversation_Store *p_store,
+    const Conversation_Owner *p_owner,
+    int64 cursor_updated_at,
+    const char *cursor_id,
+    int32 limit,
+    Conversation_Summary **pp_summaries,
+    int32 *p_count,
+    Dowa_Arena *p_arena);
+
+/*
+ * Claim a legacy conversation: reassign it to user_id.
+ * Returns NOT_FOUND if the conversation is not a legacy row (or does not
+ * exist), preventing existence leaks.
+ * Returns CONFLICT if the conversation is already owned by someone else.
+ */
+Conversation_Store_Result Conversation_Store_Claim_Legacy(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const char *user_id);
+
+/*
+ * Atomically transfer all guest conversations to a user.
+ * Idempotent: re-running with the same (guest_id, user_id) pair is safe.
+ * Returns CONVERSATION_STORE_OK on success (including the no-op case).
+ */
+Conversation_Store_Result Conversation_Store_Transfer_Guest_To_User(
+    Conversation_Store *p_store,
+    const char *guest_id,
+    const char *user_id);
+
+/*
+ * Like Conversation_Store_Transfer_Guest_To_User but also clears outstanding
+ * guest quota reservations atomically in the same transaction.
+ * Use this on login: both the conversation transfer and quota cleanup commit
+ * or roll back together.  The quota tables must reside in the same SQLite
+ * database file as the conversation tables (the shared mrjunejune.db).
+ */
+Conversation_Store_Result Conversation_Store_Transfer_Guest_To_User_Atomic(
+    Conversation_Store *p_store,
+    const char *guest_id,
+    const char *user_id);
+
+/* ------------------------------------------------------------------ */
+/* Legacy/internal APIs (kept only for migration-compatibility tests)   */
+/* Production HTTP handlers must not use these.                         */
+/* ------------------------------------------------------------------ */
+
 Conversation_Store_Result Conversation_Store_Create_Conversation(
     Conversation_Store *p_store,
     const char *title,
--- a/mrjunejune/inference_stack.sh	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/inference_stack.sh	Fri Aug 07 07:34:12 2026 -0700
@@ -70,7 +70,10 @@
   fi
   export MRJUNEJUNE_COPILOT_CLI_PATH="$MRJUNEJUNE_INFERENCE_SIDECAR_PATH"
   export MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE=1
-  echo "Starting mock inference at http://127.0.0.1:${MRJUNEJUNE_PORT:-6969}/jrpg"
+  # Bind to loopback for local dev; insecure cookies are only allowed here.
+  export SERVER_HOST="${SERVER_HOST:-127.0.0.1}"
+  export AUTH_DEV_INSECURE_COOKIE="${AUTH_DEV_INSECURE_COOKIE:-true}"
+  echo "Starting mock inference at http://${SERVER_HOST}:${MRJUNEJUNE_PORT:-6969}/jrpg"
 else
   : "${LITELLM_MASTER_KEY:?LITELLM_MASTER_KEY must be set}"
   token_dir="${GITHUB_COPILOT_TOKEN_DIR:-$state_root/litellm-copilot}"
@@ -124,6 +127,9 @@
   export LITELLM_WIRE_API="${LITELLM_WIRE_API:-completions}"
   export LITELLM_API_KEY="$LITELLM_MASTER_KEY"
   export MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE="${MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE:-0}"
+  # run_inference_stack is a local dev command; bind to loopback.
+  export SERVER_HOST="${SERVER_HOST:-127.0.0.1}"
+  export AUTH_DEV_INSECURE_COOKIE="${AUTH_DEV_INSECURE_COOKIE:-true}"
 fi
 
 setsid "$server" &
--- a/mrjunejune/main.c	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/main.c	Fri Aug 07 07:34:12 2026 -0700
@@ -4,11 +4,21 @@
 #include "deita/deita.h"
 #include "mrjunejune/latex_renderer.h"
 #include "mrjunejune/conversation_api.h"
+#include "mrjunejune/auth_api.h"
+#include "mrjunejune/admin_api.h"
+#include "mrjunejune/template_renderer.h"
+#include "auth/auth_crypto.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
 #include <time.h>
 #include <sys/stat.h>
 #include <stdarg.h>
 #include <stdatomic.h>
 #include <pthread.h>
+#include <arpa/inet.h>
+#include <openssl/crypto.h>
 
 // UUID + /tmp/ + format (max 4)
 #define TMP_FILE_LENGTH 47
@@ -45,38 +55,128 @@
 static int  g_s3_url_expires = 3600;
 static S3_Config g_s3_config = {0};
 static Deita_Connection *g_db_connection = NULL;
+/* S3 credentials — never logged; zero after use in init. */
+static char g_s3_access_key[128] = {0};
+static char g_s3_secret_key[128] = {0};
+
+/* Auth configuration */
+static char    g_auth_cookie_secret[AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES * 2 + 1] = {0};
+static char    g_auth_bootstrap_username[64] = {0};
+static char    g_auth_bootstrap_password_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE] = {0};
+static char    g_auth_trusted_proxy[AUTH_CRYPTO_IP_MAX_BYTES] = {0};
+static int64   g_auth_session_idle_ttl = AUTH_API_SESSION_IDLE_TTL_DEFAULT;
+static int64   g_auth_session_abs_ttl  = AUTH_API_SESSION_ABS_TTL_DEFAULT;
+static int64   g_auth_guest_ttl        = AUTH_API_GUEST_TTL_DEFAULT;
+static boolean g_auth_dev_insecure     = FALSE;
+static char    g_server_host[128]      = {0};   /* SERVER_HOST; empty → 0.0.0.0 */
+
+/* Guest inference / quota configuration */
+#define G_GUEST_DAILY_TURNS_DEFAULT         10
+#define G_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT 20000
+#define G_GUEST_REQUEST_OUTPUT_TOKENS_MIN   1
+#define G_GUEST_DAILY_TURNS_MIN             1
+#define G_GUEST_DAILY_TURNS_MAX             10000
+#define G_GUEST_DAILY_OUTPUT_TOKENS_MIN     1
+#define G_GUEST_DAILY_OUTPUT_TOKENS_MAX     1000000
+#define G_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT 2048
+static boolean g_guest_inference_enabled     = FALSE;
+static int64   g_guest_daily_turns           = G_GUEST_DAILY_TURNS_DEFAULT;
+static int64   g_guest_daily_output_tokens   = G_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT;
+static int64   g_guest_request_output_tokens = G_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT;
+
+/*
+ * Strict full-string integer parse: returns FALSE if value is empty,
+ * has trailing non-digit characters, or overflows a long.
+ */
+static boolean config__parse_int64_strict(const char *value, int64 *out)
+{
+  if (!value || value[0] == '\0')
+    return FALSE;
+  char *end = NULL;
+  long v = strtol(value, &end, 10);
+  if (end == value || *end != '\0')
+    return FALSE;
+  *out = (int64)v;
+  return TRUE;
+}
+
+/*
+ * Decode a lowercase or uppercase hex string into raw bytes.
+ * Returns the number of decoded bytes, or 0 on any error.
+ * hex_len must be even; each pair of hex chars produces one byte.
+ */
+static size_t config__hex_decode(const char *hex, uint8 *out, size_t out_capacity)
+{
+  if (!hex || !out) return 0;
+  size_t hex_len = strlen(hex);
+  if (hex_len == 0 || hex_len % 2 != 0) return 0;
+  size_t byte_count = hex_len / 2;
+  if (byte_count > out_capacity) return 0;
+  for (size_t i = 0; i < byte_count; i++)
+  {
+    int h, l;
+    char hi = hex[i * 2];
+    char lo = hex[i * 2 + 1];
+    if      (hi >= '0' && hi <= '9') h = hi - '0';
+    else if (hi >= 'a' && hi <= 'f') h = hi - 'a' + 10;
+    else if (hi >= 'A' && hi <= 'F') h = hi - 'A' + 10;
+    else return 0;
+    if      (lo >= '0' && lo <= '9') l = lo - '0';
+    else if (lo >= 'a' && lo <= 'f') l = lo - 'a' + 10;
+    else if (lo >= 'A' && lo <= 'F') l = lo - 'A' + 10;
+    else return 0;
+    out[i] = (uint8)((h << 4) | l);
+  }
+  return byte_count;
+}
 
 static void load_config(const char *config_path)
 {
   FILE *f = fopen(config_path, "r");
+  char workspace_config_path[1024] = {0};
+  if (!f)
+  {
+    const char *workspace = getenv("BUILD_WORKSPACE_DIRECTORY");
+    if (workspace && workspace[0] != '\0')
+    {
+      int written = snprintf(
+          workspace_config_path,
+          sizeof(workspace_config_path),
+          "%s/%s",
+          workspace,
+          config_path);
+      if (written > 0 && (size_t)written < sizeof(workspace_config_path))
+        f = fopen(workspace_config_path, "r");
+    }
+  }
   if (!f)
   {
     printf("[CONFIG] Warning: Could not open %s, using defaults\n", config_path);
-    return;
   }
-
-  char line[512];
-  while (fgets(line, sizeof(line), f))
+  else
   {
-    // Skip comments and empty lines
-    if (line[0] == '#' || line[0] == '\n' || line[0] == '\r') continue;
+    char line[512];
+    while (fgets(line, sizeof(line), f))
+    {
+      // Skip comments and empty lines
+      if (line[0] == '#' || line[0] == '\n' || line[0] == '\r') continue;
 
-    char *eq = strchr(line, '=');
-    if (!eq) continue;
+      char *eq = strchr(line, '=');
+      if (!eq) continue;
 
-    *eq = '\0';
-    char *key = line;
-    char *value = eq + 1;
+      *eq = '\0';
+      char *key = line;
+      char *value = eq + 1;
 
-    // Trim newline from value
-    size_t vlen = strlen(value);
-    while (vlen > 0 && (value[vlen-1] == '\n' || value[vlen-1] == '\r'))
-      value[--vlen] = '\0';
+      // Trim newline from value
+      size_t vlen = strlen(value);
+      while (vlen > 0 && (value[vlen-1] == '\n' || value[vlen-1] == '\r'))
+        value[--vlen] = '\0';
 
-    if (strcmp(key, "UPLOAD_AUTH_TOKEN") == 0)
-    {
-      strncpy(g_upload_auth_token, value, sizeof(g_upload_auth_token) - 1);
-    }
+      if (strcmp(key, "UPLOAD_AUTH_TOKEN") == 0)
+      {
+        strncpy(g_upload_auth_token, value, sizeof(g_upload_auth_token) - 1);
+      }
     else if (strcmp(key, "S3_REGION") == 0)
     {
       strncpy(g_s3_region, value, sizeof(g_s3_region) - 1);
@@ -87,7 +187,13 @@
     }
     else if (strcmp(key, "S3_URL_EXPIRES") == 0)
     {
-      g_s3_url_expires = atoi(value);
+      int64 v;
+      if (!config__parse_int64_strict(value, &v) || v <= 0)
+      {
+        fprintf(stderr, "[CONFIG] ERROR: S3_URL_EXPIRES must be a positive integer\n");
+        exit(1);
+      }
+      g_s3_url_expires = (int)v;
     }
     else if (strcmp(key, "S3_CLOUDFRONT_URL") == 0)
     {
@@ -97,14 +203,262 @@
     {
       strncpy(g_db_path, value, sizeof(g_db_path) - 1);
     }
+    else if (strcmp(key, "AWS_MRJUNEJUNE_ACCESS_KEY") == 0)
+    {
+      strncpy(g_s3_access_key, value, sizeof(g_s3_access_key) - 1);
+    }
+    else if (strcmp(key, "AWS_MRJUNEJUNE_SECRET_ACCESS_KEY") == 0)
+    {
+      strncpy(g_s3_secret_key, value, sizeof(g_s3_secret_key) - 1);
+    }
+    else if (strcmp(key, "AUTH_COOKIE_SECRET") == 0)
+    {
+      /* Never log this value */
+      strncpy(g_auth_cookie_secret, value, sizeof(g_auth_cookie_secret) - 1);
+    }
+    else if (strcmp(key, "AUTH_BOOTSTRAP_USERNAME") == 0)
+    {
+      strncpy(g_auth_bootstrap_username, value,
+              sizeof(g_auth_bootstrap_username) - 1);
+    }
+    else if (strcmp(key, "AUTH_BOOTSTRAP_PASSWORD_HASH") == 0)
+    {
+      strncpy(g_auth_bootstrap_password_hash, value,
+              sizeof(g_auth_bootstrap_password_hash) - 1);
+    }
+    else if (strcmp(key, "AUTH_TRUSTED_PROXY") == 0)
+    {
+      strncpy(g_auth_trusted_proxy, value, sizeof(g_auth_trusted_proxy) - 1);
+    }
+    else if (strcmp(key, "AUTH_SESSION_IDLE_TTL") == 0)
+    {
+      int64 v;
+      if (!config__parse_int64_strict(value, &v) || v <= 0)
+      {
+        fprintf(stderr, "[CONFIG] ERROR: AUTH_SESSION_IDLE_TTL must be a positive integer\n");
+        exit(1);
+      }
+      g_auth_session_idle_ttl = v;
+    }
+    else if (strcmp(key, "AUTH_SESSION_ABS_TTL") == 0)
+    {
+      int64 v;
+      if (!config__parse_int64_strict(value, &v) || v <= 0)
+      {
+        fprintf(stderr, "[CONFIG] ERROR: AUTH_SESSION_ABS_TTL must be a positive integer\n");
+        exit(1);
+      }
+      g_auth_session_abs_ttl = v;
+    }
+    else if (strcmp(key, "AUTH_GUEST_TTL") == 0)
+    {
+      int64 v;
+      if (!config__parse_int64_strict(value, &v) || v <= 0)
+      {
+        fprintf(stderr, "[CONFIG] ERROR: AUTH_GUEST_TTL must be a positive integer\n");
+        exit(1);
+      }
+      g_auth_guest_ttl = v;
+    }
+      else if (strcmp(key, "AUTH_DEV_INSECURE_COOKIE") == 0)
+      {
+        g_auth_dev_insecure =
+            (strcmp(value, "1") == 0 || strcmp(value, "true") == 0);
+      }
+      else if (strcmp(key, "SERVER_HOST") == 0)
+      {
+        strncpy(g_server_host, value, sizeof(g_server_host) - 1);
+      }
+      else if (strcmp(key, "AUTH_GUEST_DAILY_TURNS") == 0)
+      {
+        int64 v;
+        if (!config__parse_int64_strict(value, &v) ||
+            v < G_GUEST_DAILY_TURNS_MIN || v > G_GUEST_DAILY_TURNS_MAX)
+        {
+          printf("[CONFIG] ERROR: AUTH_GUEST_DAILY_TURNS must be %d..%d\n",
+                 G_GUEST_DAILY_TURNS_MIN, G_GUEST_DAILY_TURNS_MAX);
+          exit(1);
+        }
+        g_guest_daily_turns = v;
+      }
+      else if (strcmp(key, "AUTH_GUEST_DAILY_OUTPUT_TOKENS") == 0)
+      {
+        int64 v;
+        if (!config__parse_int64_strict(value, &v) ||
+            v < G_GUEST_DAILY_OUTPUT_TOKENS_MIN ||
+            v > G_GUEST_DAILY_OUTPUT_TOKENS_MAX)
+        {
+          printf("[CONFIG] ERROR: AUTH_GUEST_DAILY_OUTPUT_TOKENS must be %d..%d\n",
+                 G_GUEST_DAILY_OUTPUT_TOKENS_MIN, G_GUEST_DAILY_OUTPUT_TOKENS_MAX);
+          exit(1);
+        }
+        g_guest_daily_output_tokens = v;
+      }
+      else if (strcmp(key, "AUTH_GUEST_REQUEST_OUTPUT_TOKENS") == 0)
+      {
+        int64 v;
+        if (!config__parse_int64_strict(value, &v) ||
+            v < G_GUEST_REQUEST_OUTPUT_TOKENS_MIN)
+        {
+          printf("[CONFIG] ERROR: AUTH_GUEST_REQUEST_OUTPUT_TOKENS must be >= %d\n",
+                 G_GUEST_REQUEST_OUTPUT_TOKENS_MIN);
+          exit(1);
+        }
+        g_guest_request_output_tokens = v;
+      }
+    }
+    fclose(f);
   }
-  fclose(f);
 
   printf("[CONFIG] Loaded: token=%s..., region=%s, bucket=%s, expires=%d, cloudfront=%s, db=%s\n",
          g_upload_auth_token[0] ? "***" : "(empty)",
          g_s3_region, g_s3_bucket, g_s3_url_expires,
          g_s3_cloudfront_url[0] ? g_s3_cloudfront_url : "(none)",
          g_db_path);
+  printf("[CONFIG] Auth: secret=%s, bootstrap_user=%s, trusted_proxy=%s\n",
+         g_auth_cookie_secret[0] ? "(set)" : "(not set)",
+         g_auth_bootstrap_username[0] ? g_auth_bootstrap_username : "(none)",
+         g_auth_trusted_proxy[0] ? "(set)" : "(not set)");
+
+  const char *database_path_override = getenv("DB_PATH");
+  const char *test_tmpdir = getenv("TEST_TMPDIR");
+  if (database_path_override && database_path_override[0] != '\0')
+  {
+    strncpy(g_db_path, database_path_override, sizeof(g_db_path) - 1);
+  }
+  else if (test_tmpdir && test_tmpdir[0] != '\0')
+  {
+    snprintf(g_db_path, sizeof(g_db_path), "%s/mrjunejune.db", test_tmpdir);
+  }
+
+  /* Environment overrides: env vars always take precedence over file.
+   * Values are never logged. */
+  {
+    const char *env;
+
+    /* S3 / server config env overrides */
+    if ((env = getenv("UPLOAD_AUTH_TOKEN")) && env[0] != '\0')
+      strncpy(g_upload_auth_token, env, sizeof(g_upload_auth_token) - 1);
+    if ((env = getenv("S3_REGION")) && env[0] != '\0')
+      strncpy(g_s3_region, env, sizeof(g_s3_region) - 1);
+    if ((env = getenv("S3_BUCKET")) && env[0] != '\0')
+      strncpy(g_s3_bucket, env, sizeof(g_s3_bucket) - 1);
+    if ((env = getenv("S3_CLOUDFRONT_URL")) && env[0] != '\0')
+      strncpy(g_s3_cloudfront_url, env, sizeof(g_s3_cloudfront_url) - 1);
+    if ((env = getenv("S3_URL_EXPIRES")) && env[0] != '\0')
+    {
+      int64 v;
+      if (!config__parse_int64_strict(env, &v) || v <= 0)
+      {
+        fprintf(stderr, "[CONFIG] ERROR: S3_URL_EXPIRES must be a positive integer\n");
+        exit(1);
+      }
+      g_s3_url_expires = (int)v;
+    }
+    if ((env = getenv("MRJUNEJUNE_DB_PATH")) && env[0] != '\0')
+      strncpy(g_db_path, env, sizeof(g_db_path) - 1);
+    if ((env = getenv("AWS_MRJUNEJUNE_ACCESS_KEY")) && env[0] != '\0')
+      strncpy(g_s3_access_key, env, sizeof(g_s3_access_key) - 1);
+    if ((env = getenv("AWS_MRJUNEJUNE_SECRET_ACCESS_KEY")) && env[0] != '\0')
+      strncpy(g_s3_secret_key, env, sizeof(g_s3_secret_key) - 1);
+
+    /* Auth env overrides */
+    if ((env = getenv("AUTH_COOKIE_SECRET")) && env[0] != '\0')
+      strncpy(g_auth_cookie_secret, env, sizeof(g_auth_cookie_secret) - 1);
+    if ((env = getenv("AUTH_BOOTSTRAP_USERNAME")) && env[0] != '\0')
+      strncpy(g_auth_bootstrap_username, env,
+              sizeof(g_auth_bootstrap_username) - 1);
+    if ((env = getenv("AUTH_BOOTSTRAP_PASSWORD_HASH")) && env[0] != '\0')
+      strncpy(g_auth_bootstrap_password_hash, env,
+              sizeof(g_auth_bootstrap_password_hash) - 1);
+    if ((env = getenv("AUTH_TRUSTED_PROXY")) && env[0] != '\0')
+      strncpy(g_auth_trusted_proxy, env, sizeof(g_auth_trusted_proxy) - 1);
+    if ((env = getenv("AUTH_SESSION_IDLE_TTL")) && env[0] != '\0')
+    {
+      int64 v;
+      if (!config__parse_int64_strict(env, &v) || v <= 0)
+      {
+        fprintf(stderr, "[CONFIG] ERROR: AUTH_SESSION_IDLE_TTL must be a positive integer\n");
+        exit(1);
+      }
+      g_auth_session_idle_ttl = v;
+    }
+    if ((env = getenv("AUTH_SESSION_ABS_TTL")) && env[0] != '\0')
+    {
+      int64 v;
+      if (!config__parse_int64_strict(env, &v) || v <= 0)
+      {
+        fprintf(stderr, "[CONFIG] ERROR: AUTH_SESSION_ABS_TTL must be a positive integer\n");
+        exit(1);
+      }
+      g_auth_session_abs_ttl = v;
+    }
+    if ((env = getenv("AUTH_GUEST_TTL")) && env[0] != '\0')
+    {
+      int64 v;
+      if (!config__parse_int64_strict(env, &v) || v <= 0)
+      {
+        fprintf(stderr, "[CONFIG] ERROR: AUTH_GUEST_TTL must be a positive integer\n");
+        exit(1);
+      }
+      g_auth_guest_ttl = v;
+    }
+    if ((env = getenv("AUTH_DEV_INSECURE_COOKIE")) && env[0] != '\0')
+      g_auth_dev_insecure =
+          (strcmp(env, "1") == 0 || strcmp(env, "true") == 0);
+    if ((env = getenv("SERVER_HOST")) && env[0] != '\0')
+      strncpy(g_server_host, env, sizeof(g_server_host) - 1);
+
+    /* Guest inference enable: runtime-only, boolean parsed strictly. */
+    if ((env = getenv("MRJUNEJUNE_ALLOW_GUEST_INFERENCE")) && env[0] != '\0')
+      g_guest_inference_enabled =
+          (strcmp(env, "1") == 0 || strcasecmp(env, "true") == 0);
+    else if ((env = getenv("MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE")) && env[0] != '\0')
+      g_guest_inference_enabled =
+          (strcmp(env, "1") == 0 || strcasecmp(env, "true") == 0);
+
+    /* Guest quota env overrides: strict full-string integer, fail on malformed. */
+    if ((env = getenv("AUTH_GUEST_DAILY_TURNS")) && env[0] != '\0')
+    {
+      int64 v;
+      if (!config__parse_int64_strict(env, &v) ||
+          v < G_GUEST_DAILY_TURNS_MIN || v > G_GUEST_DAILY_TURNS_MAX)
+      {
+        fprintf(stderr,
+                "[CONFIG] ERROR: AUTH_GUEST_DAILY_TURNS must be %d..%d\n",
+                G_GUEST_DAILY_TURNS_MIN, G_GUEST_DAILY_TURNS_MAX);
+        exit(1);
+      }
+      g_guest_daily_turns = v;
+    }
+    if ((env = getenv("AUTH_GUEST_DAILY_OUTPUT_TOKENS")) && env[0] != '\0')
+    {
+      int64 v;
+      if (!config__parse_int64_strict(env, &v) ||
+          v < G_GUEST_DAILY_OUTPUT_TOKENS_MIN ||
+          v > G_GUEST_DAILY_OUTPUT_TOKENS_MAX)
+      {
+        fprintf(stderr,
+                "[CONFIG] ERROR: AUTH_GUEST_DAILY_OUTPUT_TOKENS must be %d..%d\n",
+                G_GUEST_DAILY_OUTPUT_TOKENS_MIN, G_GUEST_DAILY_OUTPUT_TOKENS_MAX);
+        exit(1);
+      }
+      g_guest_daily_output_tokens = v;
+    }
+    if ((env = getenv("AUTH_GUEST_REQUEST_OUTPUT_TOKENS")) && env[0] != '\0')
+    {
+      int64 v;
+      if (!config__parse_int64_strict(env, &v) ||
+          v < G_GUEST_REQUEST_OUTPUT_TOKENS_MIN)
+      {
+        fprintf(stderr,
+                "[CONFIG] ERROR: AUTH_GUEST_REQUEST_OUTPUT_TOKENS must be >= %d\n",
+                G_GUEST_REQUEST_OUTPUT_TOKENS_MIN);
+        exit(1);
+      }
+      g_guest_request_output_tokens = v;
+    }
+  }
 }
 
 static void init_database(void)
@@ -197,88 +551,43 @@
   Seobeo_Web_Server_Stop();
 }
 
-void Seobeo_Render_Html(
-    char *final_body,
-    char *template,
-    Dowa_Arena *arena
-)
+static Seobeo_Request_Entry *html_render_error(Dowa_Arena *arena, const char *msg)
 {
-  size_t current_offset = 0;
-  char *cursor = template;
-
-  int32 token_len = 2;
-
-  while (1)
-  {
-    char *start_tag = strstr(cursor, "{{");
-    if (!start_tag) break;
-
-    char *end_tag = strstr(start_tag, "}}");
-    if (!end_tag) break;
-
-    Seobeo_Log(SEOBEO_INFO, "[Curr] Life\n");
-
-    size_t leading_len = start_tag - cursor;
-    memcpy(final_body + current_offset, cursor, leading_len);
-    current_offset += leading_len;
-
-    size_t name_len = end_tag - (start_tag + token_len);
-    char *include_name = Dowa_Arena_Allocate(arena, name_len + 1);
-    memcpy(include_name, start_tag + token_len, name_len);
-    include_name[name_len] = '\0';
-
-    size_t sub_file_size = 0;
-    char *sub_content = Seobeo_Web_LoadFile(include_name, &sub_file_size);
-    Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] Loading include: '%s' -> %s (size=%zu)\n",
-              include_name, sub_content ? "OK" : "FAILED", sub_file_size);
-    if (sub_content)
-    {
-      memcpy(final_body + current_offset, sub_content, sub_file_size);
-      current_offset += sub_file_size;
-      free(sub_content);
-    }
-
-    cursor = end_tag + 2;
-  }
-  strcpy(final_body + current_offset, cursor);
+  Seobeo_Request_Entry *resp = NULL;
+  Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+  Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain; charset=utf-8", arena);
+  Dowa_HashMap_Push_Arena(resp, "body", (char *)msg, arena);
+  return resp;
 }
 
-void Seobeo_Render_Html_FilePath(
-    char *final_body,
-    char *path,
-    Dowa_Arena *arena
-) {
-  Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] Loading main template: '%s'\n", path);
-  size_t html_size = 0;
-  char *template = Seobeo_Web_LoadFile(path, &html_size);
-  Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] Main template loaded: %s (size=%zu)\n", template ? "OK" : "FAILED", html_size);
-  if (!template) return;
-  Seobeo_Render_Html(final_body, template, arena);
-}
+#define HTML_PAGE_CAP (128 * 1024)
 
 Seobeo_Request_Entry* GetHomePage(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
-  Seobeo_Request_Entry *resp = NULL; 
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  Seobeo_Render_Html_FilePath(final_body, "/index.html", arena);
+  Seobeo_Request_Entry *resp = NULL;
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/index.html", arena))
+    return html_render_error(arena, "Internal Server Error");
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
   return resp;
 }
 
 Seobeo_Request_Entry* GetResume(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
-  Seobeo_Request_Entry *resp = NULL; 
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  Seobeo_Render_Html_FilePath(final_body, "/resume/index.html", arena);
+  Seobeo_Request_Entry *resp = NULL;
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/resume/index.html", arena))
+    return html_render_error(arena, "Internal Server Error");
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
   return resp;
 }
 
 Seobeo_Request_Entry* GetTools(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
-  Seobeo_Request_Entry *resp = NULL; 
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  Seobeo_Render_Html_FilePath(final_body, "/tools/index.html", arena);
+  Seobeo_Request_Entry *resp = NULL;
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/tools/index.html", arena))
+    return html_render_error(arena, "Internal Server Error");
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
   return resp;
 }
@@ -287,8 +596,9 @@
 Seobeo_Request_Entry* GetMDToHTML(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
   Seobeo_Request_Entry *resp = NULL;
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  Seobeo_Render_Html_FilePath(final_body, "/tools/markdown_to_html/index.html", arena);
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/tools/markdown_to_html/index.html", arena))
+    return html_render_error(arena, "Internal Server Error");
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
   return resp;
 }
@@ -296,8 +606,9 @@
 Seobeo_Request_Entry* GetFileConverter(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
   Seobeo_Request_Entry *resp = NULL;
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  Seobeo_Render_Html_FilePath(final_body, "/tools/file_converter/index.html", arena);
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/tools/file_converter/index.html", arena))
+    return html_render_error(arena, "Internal Server Error");
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
   return resp;
 }
@@ -305,8 +616,9 @@
 Seobeo_Request_Entry* GetHlsPlayer(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
   Seobeo_Request_Entry *resp = NULL;
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  Seobeo_Render_Html_FilePath(final_body, "/tools/hls_player/index.html", arena);
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/tools/hls_player/index.html", arena))
+    return html_render_error(arena, "Internal Server Error");
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
   return resp;
 }
@@ -314,12 +626,14 @@
 Seobeo_Request_Entry* GetLatexEditor(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
   Seobeo_Request_Entry *resp = NULL;
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  Seobeo_Render_Html_FilePath(final_body, "/tools/latex_editor/index.html", arena);
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/tools/latex_editor/index.html", arena))
+    return html_render_error(arena, "Internal Server Error");
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
   return resp;
 }
 
+
 static Seobeo_Request_Entry *LatexErrorResponse(
     Dowa_Arena *arena,
     int status,
@@ -831,8 +1145,9 @@
 Seobeo_Request_Entry *RenderBlogList(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
   Seobeo_Request_Entry *resp = NULL;
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  Seobeo_Render_Html_FilePath(final_body, "/blog/index.html", arena);
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/blog/index.html", arena))
+    return html_render_error(arena, "Internal Server Error");
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
   return resp;
 }
@@ -847,8 +1162,15 @@
   char *blog_id = ((Seobeo_Request_Entry*)blog_id_kv)->value;
   snprintf(file_path, 1024, "/blog/%s/index.html", blog_id);
 
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  Seobeo_Render_Html_FilePath(final_body, file_path, arena);
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, file_path, arena))
+  {
+    Seobeo_Request_Entry *err = NULL;
+    Dowa_HashMap_Push_Arena(err, "status", "404", arena);
+    Dowa_HashMap_Push_Arena(err, "content-type", "text/plain; charset=utf-8", arena);
+    Dowa_HashMap_Push_Arena(err, "body", "Not found", arena);
+    return err;
+  }
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
   return resp;
 }
@@ -870,8 +1192,9 @@
 Seobeo_Request_Entry *GetTalk(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
   Seobeo_Request_Entry *resp = NULL;
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  Seobeo_Render_Html_FilePath(final_body, "/talk/index.html", arena);
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/talk/index.html", arena))
+    return html_render_error(arena, "Internal Server Error");
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
   return resp;
 }
@@ -879,17 +1202,46 @@
 Seobeo_Request_Entry *GetJrpg(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
   Seobeo_Request_Entry *resp = NULL;
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  Seobeo_Render_Html_FilePath(final_body, "/jrpg/index.html", arena);
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/jrpg/index.html", arena))
+    return html_render_error(arena, "Internal Server Error");
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
+  Dowa_HashMap_Push_Arena(resp, "referrer-policy", "no-referrer", arena);
+  return resp;
+}
+
+Seobeo_Request_Entry *GetLogin(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  (void)req;
+  Seobeo_Request_Entry *resp = NULL;
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/login/index.html", arena))
+  {
+    Seobeo_Request_Entry *err = NULL;
+    Dowa_HashMap_Push_Arena(err, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(err, "content-type", "text/plain; charset=utf-8", arena);
+    Dowa_HashMap_Push_Arena(err, "cache-control", "no-store", arena);
+    Dowa_HashMap_Push_Arena(err, "body", "Internal Server Error", arena);
+    return err;
+  }
+  Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
+  Dowa_HashMap_Push_Arena(
+      resp, "content-type", "text/html; charset=utf-8", arena);
+  Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", arena);
+  Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", arena);
+  Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", arena);
+  Dowa_HashMap_Push_Arena(resp, "referrer-policy", "no-referrer", arena);
+  Dowa_HashMap_Push_Arena(
+      resp, "content-security-policy", "frame-ancestors 'none'", arena);
   return resp;
 }
 
 Seobeo_Request_Entry *GetNotesLogin(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
   Seobeo_Request_Entry *resp = NULL;
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  Seobeo_Render_Html_FilePath(final_body, "/notes/login.html", arena);
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/notes/login.html", arena))
+    return html_render_error(arena, "Internal Server Error");
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
   return resp;
 }
@@ -897,8 +1249,9 @@
 Seobeo_Request_Entry *GetNotes(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
   Seobeo_Request_Entry *resp = NULL;
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  Seobeo_Render_Html_FilePath(final_body, "/notes/index.html", arena);
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/notes/index.html", arena))
+    return html_render_error(arena, "Internal Server Error");
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
   return resp;
 }
@@ -906,9 +1259,9 @@
 Seobeo_Request_Entry *GetNoteById(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
   Seobeo_Request_Entry *resp = NULL;
-  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
-  // Same template - JavaScript handles the note_id from URL
-  Seobeo_Render_Html_FilePath(final_body, "/notes/index.html", arena);
+  char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP);
+  if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/notes/index.html", arena))
+    return html_render_error(arena, "Internal Server Error");
   Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
   return resp;
 }
@@ -2003,52 +2356,19 @@
   signal(SIGINT, handle_sigint);
   signal(SIGTERM, handle_sigint);
 
-  // Load server config
+  // Load the ignored runtime config when present; environment overrides follow.
   load_config("mrjunejune/.config");
-  const char *database_override = getenv("MRJUNEJUNE_DB_PATH");
-  if (database_override && database_override[0] != '\0')
-  {
-    snprintf(g_db_path, sizeof(g_db_path), "%s", database_override);
-  }
-
-  // Load S3 credentials from .env
-  FILE *env_file = fopen(".env", "r");
-  static char s3_access_key[128] = {0};
-  static char s3_secret_key[128] = {0};
 
-  if (env_file)
-  {
-    char line[512];
-    while (fgets(line, sizeof(line), env_file))
-    {
-      if (strncmp(line, "AWS_MRJUNEJUNE_ACCESS_KEY=", 26) == 0)
-      {
-        char *val = line + 26;
-        size_t len = strlen(val);
-        while (len > 0 && (val[len-1] == '\n' || val[len-1] == '\r')) val[--len] = '\0';
-        strncpy(s3_access_key, val, sizeof(s3_access_key) - 1);
-      }
-      else if (strncmp(line, "AWS_MRJUNEJUNE_SECRET_ACCESS_KEY=", 33) == 0)
-      {
-        char *val = line + 33;
-        size_t len = strlen(val);
-        while (len > 0 && (val[len-1] == '\n' || val[len-1] == '\r')) val[--len] = '\0';
-        strncpy(s3_secret_key, val, sizeof(s3_secret_key) - 1);
-      }
-    }
-    fclose(env_file);
-  }
-
-  // Initialize S3 config
-  g_s3_config.access_key_id = s3_access_key;
-  g_s3_config.secret_access_key = s3_secret_key;
+  // Initialize S3 config using global credentials populated by load_config
+  g_s3_config.access_key_id = g_s3_access_key;
+  g_s3_config.secret_access_key = g_s3_secret_key;
   g_s3_config.region = g_s3_region;
   g_s3_config.bucket = g_s3_bucket;
   g_s3_config.endpoint = NULL;
   g_s3_config.use_path_style = FALSE;
 
   printf("[S3] Configured: region=%s, bucket=%s, key=%s...\n",
-         g_s3_region, g_s3_bucket, s3_access_key[0] ? "***" : "(missing)");
+         g_s3_region, g_s3_bucket, g_s3_access_key[0] ? "***" : "(missing)");
 
   // Show current working directory
   char cwd[1024];
@@ -2060,8 +2380,188 @@
 
   // Initialize database
   init_database();
-  if (!Conversation_API_Init(g_db_path))
-    Seobeo_Log(SEOBEO_ERROR, "[CONVERSATION] Store unavailable\n");
+  {
+    /* Validate per-request token cap against daily limit now that both
+     * are finalised (env overrides run inside load_config). */
+    if (g_guest_request_output_tokens > g_guest_daily_output_tokens)
+      g_guest_request_output_tokens = g_guest_daily_output_tokens;
+
+    Conversation_API_Guest_Policy policy = {
+      .guest_inference_enabled = g_guest_inference_enabled,
+      .daily_turns             = g_guest_daily_turns,
+      .daily_output_tokens     = g_guest_daily_output_tokens,
+      .request_output_tokens   = g_guest_request_output_tokens,
+    };
+    if (!Conversation_API_Init(g_db_path, &policy))
+      Seobeo_Log(SEOBEO_ERROR, "[CONVERSATION] Store unavailable\n");
+  }
+
+  /* Validate and decode AUTH_COOKIE_SECRET: hex string → raw bytes. */
+  uint8  cookie_secret_bytes[AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES];
+  size_t cookie_secret_byte_len = 0;
+  {
+    size_t hex_len = strlen(g_auth_cookie_secret);
+    boolean bad_len = (
+        hex_len < (size_t)(AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES * 2) ||
+        hex_len > (size_t)(AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES * 2) ||
+        hex_len % 2 != 0);
+    if (bad_len)
+    {
+      OPENSSL_cleanse(g_auth_cookie_secret, sizeof(g_auth_cookie_secret));
+      fprintf(stderr,
+              "[AUTH] AUTH_COOKIE_SECRET must be %d–%d hex chars "
+              "(%d–%d random bytes). "
+              "Generate with: openssl rand -hex 32\n",
+              AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES * 2,
+              AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES * 2,
+              AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES,
+              AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES);
+      exit(1);
+    }
+    cookie_secret_byte_len = config__hex_decode(
+        g_auth_cookie_secret, cookie_secret_bytes, sizeof(cookie_secret_bytes));
+    OPENSSL_cleanse(g_auth_cookie_secret, sizeof(g_auth_cookie_secret));
+    if (cookie_secret_byte_len < (size_t)AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES)
+    {
+      OPENSSL_cleanse(cookie_secret_bytes, sizeof(cookie_secret_bytes));
+      fprintf(stderr,
+              "[AUTH] AUTH_COOKIE_SECRET contains non-hex characters or "
+              "is too short.\n");
+      exit(1);
+    }
+  }
+
+  /* Bootstrap pairing: both username+hash must be set, or both absent. */
+  {
+    boolean has_user = g_auth_bootstrap_username[0] != '\0';
+    boolean has_hash = g_auth_bootstrap_password_hash[0] != '\0';
+    if (has_user != has_hash)
+    {
+      OPENSSL_cleanse(cookie_secret_bytes, sizeof(cookie_secret_bytes));
+      fprintf(stderr,
+              "[AUTH] AUTH_BOOTSTRAP_USERNAME and AUTH_BOOTSTRAP_PASSWORD_HASH "
+              "must both be set or both absent.\n");
+      exit(1);
+    }
+    if (has_hash &&
+        Auth_Crypto_Password_Hash_Validate(g_auth_bootstrap_password_hash) != AUTH_CRYPTO_OK)
+    {
+      OPENSSL_cleanse(cookie_secret_bytes, sizeof(cookie_secret_bytes));
+      fprintf(stderr,
+              "[AUTH] AUTH_BOOTSTRAP_PASSWORD_HASH has unrecognized format "
+              "(expected zenbu-scrypt$v=1$...).\n");
+      exit(1);
+    }
+  }
+
+  /* TTL validation: all positive, idle <= abs, all within 1 min – 1 year. */
+#define CONFIG_TTL_MIN_SECS 60
+#define CONFIG_TTL_MAX_SECS 31536000
+  {
+    int64 idle  = g_auth_session_idle_ttl;
+    int64 abst  = g_auth_session_abs_ttl;
+    int64 guest = g_auth_guest_ttl;
+    if (idle <= 0 || abst <= 0 || guest <= 0 ||
+        idle < CONFIG_TTL_MIN_SECS || idle > CONFIG_TTL_MAX_SECS ||
+        abst < CONFIG_TTL_MIN_SECS || abst > CONFIG_TTL_MAX_SECS ||
+        guest < CONFIG_TTL_MIN_SECS || guest > CONFIG_TTL_MAX_SECS)
+    {
+      OPENSSL_cleanse(cookie_secret_bytes, sizeof(cookie_secret_bytes));
+      fprintf(stderr,
+              "[AUTH] TTL values must be %d–%d seconds.\n",
+              CONFIG_TTL_MIN_SECS, CONFIG_TTL_MAX_SECS);
+      exit(1);
+    }
+    if (idle > abst)
+    {
+      OPENSSL_cleanse(cookie_secret_bytes, sizeof(cookie_secret_bytes));
+      fprintf(stderr,
+              "[AUTH] AUTH_SESSION_IDLE_TTL must not exceed "
+              "AUTH_SESSION_ABS_TTL.\n");
+      exit(1);
+    }
+  }
+
+  /* Trusted proxy: validate and canonicalize via inet_pton/inet_ntop. */
+  if (g_auth_trusted_proxy[0] != '\0')
+  {
+    struct in_addr  addr4;
+    struct in6_addr addr6;
+    char canonical[AUTH_CRYPTO_IP_MAX_BYTES];
+    canonical[0] = '\0';
+    if (inet_pton(AF_INET, g_auth_trusted_proxy, &addr4) == 1)
+    {
+      if (!inet_ntop(AF_INET, &addr4, canonical, sizeof(canonical)))
+      {
+        OPENSSL_cleanse(cookie_secret_bytes, sizeof(cookie_secret_bytes));
+        fprintf(stderr,
+                "[AUTH] AUTH_TRUSTED_PROXY: IPv4 canonicalization failed.\n");
+        exit(1);
+      }
+    }
+    else if (inet_pton(AF_INET6, g_auth_trusted_proxy, &addr6) == 1)
+    {
+      if (!inet_ntop(AF_INET6, &addr6, canonical, sizeof(canonical)))
+      {
+        OPENSSL_cleanse(cookie_secret_bytes, sizeof(cookie_secret_bytes));
+        fprintf(stderr,
+                "[AUTH] AUTH_TRUSTED_PROXY: IPv6 canonicalization failed.\n");
+        exit(1);
+      }
+    }
+    else
+    {
+      OPENSSL_cleanse(cookie_secret_bytes, sizeof(cookie_secret_bytes));
+      fprintf(stderr,
+              "[AUTH] AUTH_TRUSTED_PROXY must be a valid IPv4 or IPv6 "
+              "address (e.g. 192.168.1.1 or ::1).\n");
+      exit(1);
+    }
+    strncpy(g_auth_trusted_proxy, canonical, sizeof(g_auth_trusted_proxy) - 1);
+    g_auth_trusted_proxy[sizeof(g_auth_trusted_proxy) - 1] = '\0';
+  }
+
+  /*
+   * Loopback enforcement: AUTH_DEV_INSECURE_COOKIE=true is only permitted
+   * when SERVER_HOST is explicitly a loopback address.  Production/edge
+   * deployments must use Secure cookies.
+   */
+  {
+    boolean is_loopback = (
+        strcmp(g_server_host, "127.0.0.1") == 0 ||
+        strcmp(g_server_host, "::1")        == 0 ||
+        strcmp(g_server_host, "localhost")   == 0);
+    if (g_auth_dev_insecure && !is_loopback)
+    {
+      OPENSSL_cleanse(cookie_secret_bytes, sizeof(cookie_secret_bytes));
+      fprintf(stderr,
+              "[AUTH] AUTH_DEV_INSECURE_COOKIE=true requires SERVER_HOST "
+              "to be a loopback address (127.0.0.1, ::1, or localhost). "
+              "Set SERVER_HOST=127.0.0.1 for local development.\n");
+      exit(1);
+    }
+  }
+
+  /* Initialize auth module — fail closed: no auth means no server. */
+  {
+    if (!Auth_API_Init(
+            g_db_path,
+            cookie_secret_bytes, cookie_secret_byte_len,
+            g_auth_bootstrap_username[0] ? g_auth_bootstrap_username : NULL,
+            g_auth_bootstrap_password_hash[0] ? g_auth_bootstrap_password_hash : NULL,
+            g_auth_trusted_proxy[0] ? g_auth_trusted_proxy : NULL,
+            g_auth_session_idle_ttl,
+            g_auth_session_abs_ttl,
+            g_auth_guest_ttl,
+            g_auth_dev_insecure))
+    {
+      OPENSSL_cleanse(cookie_secret_bytes, sizeof(cookie_secret_bytes));
+      fprintf(stderr,
+              "[AUTH] Auth init failed — refusing to start server.\n");
+      exit(1);
+    }
+    OPENSSL_cleanse(cookie_secret_bytes, sizeof(cookie_secret_bytes));
+  }
   const char *sidecar_path = getenv("MRJUNEJUNE_INFERENCE_SIDECAR_PATH");
   const char *copilot_cli_path = getenv("MRJUNEJUNE_COPILOT_CLI_PATH");
   if (sidecar_path && copilot_cli_path)
@@ -2085,6 +2585,8 @@
   }
 
   Seobeo_Router_Init();
+  Auth_API_Register_Routes();
+  Admin_API_Register_Routes();
   Conversation_API_Register_Routes();
 
   Seobeo_Router_Register("GET", "/", GetHomePage);
@@ -2137,6 +2639,9 @@
   Seobeo_Router_Register("GET", "/jrpg", GetJrpg);
   Seobeo_Router_Register("GET", "/jrpg/index.html", GetRedirectJrpg);
 
+  // -- Login --/
+  Seobeo_Router_Register("GET", "/login", GetLogin);
+
   // -- Notes --/
   Seobeo_Router_Register("GET", "/notes", GetNotes);
   Seobeo_Router_Register("GET", "/notes/", GetNotes);
@@ -2152,8 +2657,19 @@
   const char *server_port = getenv("MRJUNEJUNE_PORT");
   if (!server_port || server_port[0] == '\0')
     server_port = "6969";
-  Seobeo_Web_Server_Start("mrjunejune/src", server_port, SEOBEO_MODE_EDGE, 4);
+  const char *server_bind = g_server_host[0] ? g_server_host : "0.0.0.0";
+  Mjj_Template_Renderer_Init("mrjunejune/src");
+  int server_result = Seobeo_Web_Server_Start_On(
+      server_bind, "mrjunejune/src", server_port, SEOBEO_MODE_EDGE, 4);
   Seobeo_Worker_Pool_Destroy(g_media_worker_pool);
   g_media_worker_pool = NULL;
   Conversation_API_Destroy();
+  Auth_API_Destroy();
+  if (server_result != 0)
+  {
+    fprintf(stderr, "[STARTUP] Server bind/listen failed (host=%s port=%s)\n",
+            server_bind, server_port);
+    return 1;
+  }
+  return 0;
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/account/password.html	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,237 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  {{/parts/base_head.html}}
+  <title>Change password — MrJuneJune</title>
+  <style>
+    main {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      min-height: 60vh;
+      padding: var(--zenbu-sys-padding-lg, 2rem) var(--zenbu-sys-padding-md, 1rem);
+    }
+
+    .password-card {
+      width: 100%;
+      max-width: 400px;
+    }
+
+    .password-card h2 {
+      margin: 0 0 var(--zenbu-sys-padding-md, 1.25rem);
+      font-family: "More", sans-serif;
+      font-size: 1.4rem;
+      font-weight: 700;
+      text-align: center;
+    }
+
+    .password-form {
+      display: flex;
+      flex-direction: column;
+      gap: var(--zenbu-sys-padding-sm, 0.75rem);
+    }
+
+    .password-actions {
+      display: flex;
+      justify-content: flex-end;
+      margin-top: var(--zenbu-sys-padding-xs, 0.5rem);
+    }
+
+    #passwordError {
+      display: none;
+      padding: 0.6rem 0.75rem;
+      border-radius: 6px;
+      background: color-mix(in srgb, var(--zenbu-sys-color-error, #c0392b) 12%, transparent);
+      color: var(--zenbu-sys-color-error, #c0392b);
+      font-size: 0.875rem;
+    }
+
+    #passwordError[aria-hidden="false"] {
+      display: block;
+    }
+
+    #passwordSuccess {
+      display: none;
+      padding: 0.6rem 0.75rem;
+      border-radius: 6px;
+      background: color-mix(in srgb, var(--zenbu-sys-color-success, #27ae60) 12%, transparent);
+      color: var(--zenbu-sys-color-success, #27ae60);
+      font-size: 0.875rem;
+    }
+
+    #passwordSuccess[aria-hidden="false"] {
+      display: block;
+    }
+  </style>
+</head>
+<body>
+  {{/parts/header.html}}
+
+  <main>
+    <div class="password-card">
+      <zen-heading size="xl">
+        <h2>Change password</h2>
+      </zen-heading>
+
+      <div id="passwordError" role="alert" aria-live="assertive" aria-hidden="true"></div>
+      <div id="passwordSuccess" role="status" aria-live="polite" aria-hidden="true"></div>
+
+      <form class="password-form" id="passwordForm" novalidate>
+        <zen-field size="md">
+          <label for="currentPassword">Current password</label>
+          <input
+            id="currentPassword"
+            name="currentPassword"
+            type="password"
+            autocomplete="current-password"
+            required
+          >
+        </zen-field>
+
+        <zen-field size="md">
+          <label for="newPassword">New password</label>
+          <input
+            id="newPassword"
+            name="newPassword"
+            type="password"
+            autocomplete="new-password"
+            required
+            minlength="12"
+            maxlength="1024"
+          >
+        </zen-field>
+
+        <zen-field size="md">
+          <label for="confirmPassword">Confirm new password</label>
+          <input
+            id="confirmPassword"
+            name="confirmPassword"
+            type="password"
+            autocomplete="new-password"
+            required
+            minlength="12"
+            maxlength="1024"
+          >
+        </zen-field>
+
+        <div class="password-actions">
+          <zen-button size="md">
+            <button type="submit" id="passwordSubmit">Change password</button>
+          </zen-button>
+        </div>
+      </form>
+    </div>
+  </main>
+
+  <script>
+    (function () {
+      'use strict';
+
+      const form        = document.getElementById('passwordForm');
+      const errorEl     = document.getElementById('passwordError');
+      const successEl   = document.getElementById('passwordSuccess');
+      const submitBtn   = document.getElementById('passwordSubmit');
+
+      function showError(message) {
+        errorEl.textContent = message;
+        errorEl.setAttribute('aria-hidden', 'false');
+        successEl.setAttribute('aria-hidden', 'true');
+      }
+
+      function showSuccess(message) {
+        successEl.textContent = message;
+        successEl.setAttribute('aria-hidden', 'false');
+        errorEl.setAttribute('aria-hidden', 'true');
+      }
+
+      function hideMessages() {
+        errorEl.textContent = '';
+        errorEl.setAttribute('aria-hidden', 'true');
+        successEl.textContent = '';
+        successEl.setAttribute('aria-hidden', 'true');
+      }
+
+      async function fetchSession() {
+        const resp = await fetch('/api/auth/session', {
+          credentials: 'same-origin',
+        });
+        if (!resp.ok) throw new Error('session unavailable');
+        return resp.json();
+      }
+
+      form.addEventListener('submit', async function (evt) {
+        evt.preventDefault();
+        hideMessages();
+
+        const currentPassword = form.currentPassword.value;
+        const newPassword     = form.newPassword.value;
+        const confirmPassword = form.confirmPassword.value;
+
+        if (!currentPassword) {
+          showError('Please enter your current password.');
+          return;
+        }
+        if (!newPassword || newPassword.length < 12) {
+          showError('New password must be at least 12 characters.');
+          return;
+        }
+        if (newPassword !== confirmPassword) {
+          showError('New passwords do not match.');
+          return;
+        }
+
+        submitBtn.disabled = true;
+
+        try {
+          const session = await fetchSession();
+          if (session.kind !== 'user') {
+            showError('You must be signed in to change your password.');
+            return;
+          }
+
+          const csrfToken = session.csrfToken;
+
+          const resp = await fetch('/api/auth/password', {
+            method: 'POST',
+            headers: {
+              'Content-Type': 'application/json',
+              'Origin': window.location.origin,
+            },
+            credentials: 'same-origin',
+            body: JSON.stringify({ currentPassword, newPassword, csrfToken }),
+          });
+
+          const data = await resp.json();
+
+          if (!resp.ok) {
+            if (resp.status === 401) {
+              showError('Current password is incorrect.');
+            } else if (resp.status === 400 && data.error &&
+                       data.error.code === 'password_policy') {
+              showError(data.error.message ||
+                        'New password must be at least 12 characters.');
+            } else {
+              showError('Password change failed. Please try again.');
+            }
+            return;
+          }
+
+          showSuccess('Password changed successfully.');
+          form.reset();
+
+          /* Redirect to home after a brief delay */
+          setTimeout(function () {
+            window.location.href = '/jrpg';
+          }, 1500);
+
+        } catch (_) {
+          showError('Password change failed. Please try again.');
+        } finally {
+          submitBtn.disabled = false;
+        }
+      });
+    })();
+  </script>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/admin/users/index.html	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,527 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  {{/parts/base_head.html}}
+  <title>Admin — Users — MrJuneJune</title>
+  <style>
+    main {
+      padding: var(--zenbu-sys-padding-lg, 2rem) var(--zenbu-sys-padding-md, 1rem);
+      max-width: 960px;
+      margin: 0 auto;
+    }
+
+    .admin-header {
+      display: flex;
+      align-items: baseline;
+      justify-content: space-between;
+      gap: var(--zenbu-sys-padding-md, 1rem);
+      flex-wrap: wrap;
+      margin-bottom: var(--zenbu-sys-padding-lg, 2rem);
+    }
+
+    #statusMessage {
+      display: none;
+      padding: 0.6rem 0.75rem;
+      border-radius: 6px;
+      font-size: 0.875rem;
+      margin-bottom: var(--zenbu-sys-padding-md, 1rem);
+    }
+
+    #statusMessage[data-kind="error"] {
+      background: color-mix(in srgb, var(--zenbu-sys-color-error, #c0392b) 12%, transparent);
+      color: var(--zenbu-sys-color-error, #c0392b);
+    }
+
+    #statusMessage[data-kind="success"] {
+      background: color-mix(in srgb, var(--zenbu-sys-color-success, #27ae60) 12%, transparent);
+      color: var(--zenbu-sys-color-success, #27ae60);
+    }
+
+    #statusMessage[aria-hidden="false"] {
+      display: block;
+    }
+
+    table {
+      width: 100%;
+      border-collapse: collapse;
+      font-size: 0.9rem;
+    }
+
+    th, td {
+      text-align: left;
+      padding: 0.5rem 0.75rem;
+      border-bottom: 1px solid var(--zenbu-sys-color-outline-variant, #e0ddd5);
+    }
+
+    th {
+      font-weight: 600;
+      color: var(--zenbu-sys-color-on-surface-variant, #5c5a53);
+    }
+
+    .row-actions {
+      display: flex;
+      gap: 0.5rem;
+      flex-wrap: wrap;
+    }
+
+    details summary {
+      cursor: pointer;
+      list-style: none;
+    }
+
+    details summary::-webkit-details-marker {
+      display: none;
+    }
+
+    .create-form {
+      margin-top: var(--zenbu-sys-padding-lg, 2rem);
+      padding-top: var(--zenbu-sys-padding-md, 1rem);
+      border-top: 1px solid var(--zenbu-sys-color-outline-variant, #e0ddd5);
+    }
+
+    .create-form-fields {
+      display: flex;
+      flex-direction: column;
+      gap: var(--zenbu-sys-padding-sm, 0.75rem);
+      max-width: 400px;
+      margin-top: var(--zenbu-sys-padding-sm, 0.75rem);
+    }
+
+    .create-form-actions {
+      display: flex;
+      gap: 0.5rem;
+      margin-top: var(--zenbu-sys-padding-sm, 0.75rem);
+    }
+
+    dialog {
+      border: none;
+      border-radius: 8px;
+      padding: var(--zenbu-sys-padding-lg, 2rem);
+      max-width: 400px;
+      width: 100%;
+      box-shadow: 0 4px 24px color-mix(in srgb, var(--zenbu-sys-color-on-surface, #1a1a18) 12%, transparent);
+    }
+
+    dialog::backdrop {
+      background: color-mix(in srgb, var(--zenbu-sys-color-on-surface, #1a1a18) 40%, transparent);
+    }
+
+    .dialog-title {
+      margin: 0 0 var(--zenbu-sys-padding-md, 1rem);
+      font-size: 1.1rem;
+      font-weight: 600;
+    }
+
+    .dialog-fields {
+      display: flex;
+      flex-direction: column;
+      gap: var(--zenbu-sys-padding-sm, 0.75rem);
+    }
+
+    .dialog-actions {
+      display: flex;
+      gap: 0.5rem;
+      justify-content: flex-end;
+      margin-top: var(--zenbu-sys-padding-md, 1rem);
+    }
+  </style>
+</head>
+<body>
+  {{/parts/header.html}}
+
+  <main>
+    <div class="admin-header">
+      <zen-heading size="xl">
+        <h1>Users</h1>
+      </zen-heading>
+      <zen-button appearance="plain" size="sm">
+        <a href="/jrpg">← Back to Shiba Quest</a>
+      </zen-button>
+    </div>
+
+    <div id="statusMessage" role="status" aria-live="polite" aria-hidden="true"></div>
+
+    <div id="userTableContainer">
+      <zen-text size="sm"><p aria-live="polite" id="loadingText">Loading…</p></zen-text>
+    </div>
+
+    <!-- Create user section -->
+    <section class="create-form" aria-labelledby="createHeading">
+      <zen-heading size="lg">
+        <h2 id="createHeading">Create user</h2>
+      </zen-heading>
+      <form id="createForm" class="create-form-fields" novalidate>
+        <zen-field size="md">
+          <label for="newUsername">Username</label>
+          <input
+            id="newUsername"
+            name="username"
+            type="text"
+            autocomplete="off"
+            minlength="3"
+            maxlength="32"
+            required
+          >
+        </zen-field>
+        <zen-field size="md">
+          <label for="newPassword">Temporary password</label>
+          <input
+            id="newPassword"
+            name="temporaryPassword"
+            type="password"
+            autocomplete="new-password"
+            minlength="12"
+            maxlength="1024"
+            required
+          >
+        </zen-field>
+        <zen-field size="md">
+          <label for="newRole">Role</label>
+          <select id="newRole" name="role">
+            <option value="member">member</option>
+            <option value="admin">admin</option>
+          </select>
+        </zen-field>
+        <div class="create-form-actions">
+          <zen-button size="md">
+            <button type="submit" id="createSubmit">Create user</button>
+          </zen-button>
+        </div>
+      </form>
+    </section>
+  </main>
+
+  <!-- Temp reset password dialog -->
+  <dialog id="resetDialog" aria-labelledby="resetDialogTitle" aria-modal="true">
+    <p class="dialog-title" id="resetDialogTitle">Reset temporary password</p>
+    <form id="resetForm" class="dialog-fields" novalidate>
+      <zen-field size="md">
+        <label for="resetPassword">New temporary password</label>
+        <input
+          id="resetPassword"
+          name="temporaryPassword"
+          type="password"
+          autocomplete="new-password"
+          minlength="12"
+          maxlength="1024"
+          required
+        >
+      </zen-field>
+      <div class="dialog-actions">
+        <zen-button appearance="plain" size="md">
+          <button type="button" id="resetCancel">Cancel</button>
+        </zen-button>
+        <zen-button size="md">
+          <button type="submit" id="resetSubmit">Reset</button>
+        </zen-button>
+      </div>
+    </form>
+  </dialog>
+
+  <script>
+    (function () {
+      'use strict';
+
+      let csrfToken = '';
+      /* userList holds user records; IDs stay in JS state, not rendered in DOM text. */
+      let userList = [];
+
+      const statusEl       = document.getElementById('statusMessage');
+      const tableContainer = document.getElementById('userTableContainer');
+      const createForm     = document.getElementById('createForm');
+      const createSubmit   = document.getElementById('createSubmit');
+      const resetDialog    = document.getElementById('resetDialog');
+      const resetForm      = document.getElementById('resetForm');
+      const resetCancel    = document.getElementById('resetCancel');
+      const resetSubmit    = document.getElementById('resetSubmit');
+      let   pendingResetIndex = -1;
+
+      function showStatus(message, kind) {
+        statusEl.textContent = message;
+        statusEl.setAttribute('data-kind', kind);
+        statusEl.setAttribute('aria-hidden', 'false');
+      }
+
+      function clearStatus() {
+        statusEl.setAttribute('aria-hidden', 'true');
+        statusEl.textContent = '';
+      }
+
+      function escapeHtml(str) {
+        return String(str)
+          .replace(/&/g, '&amp;')
+          .replace(/</g, '&lt;')
+          .replace(/>/g, '&gt;')
+          .replace(/"/g, '&quot;')
+          .replace(/'/g, '&#39;');
+      }
+
+      async function fetchSession() {
+        const resp = await fetch('/api/auth/session', { credentials: 'same-origin' });
+        if (!resp.ok) throw new Error('session unavailable');
+        return resp.json();
+      }
+
+      async function fetchUsers() {
+        const resp = await fetch('/api/admin/users', { credentials: 'same-origin' });
+        if (!resp.ok) throw new Error('Failed to load users');
+        return resp.json();
+      }
+
+      function renderTable() {
+        if (userList.length === 0) {
+          tableContainer.innerHTML =
+            '<zen-text size="sm"><p>No users found.</p></zen-text>';
+          return;
+        }
+
+        /* IDs are stored only in userList (JS state), not in rendered DOM attributes. */
+        let rows = userList.map((u, idx) => {
+          const statusLabel = u.status === 'active' ? 'active' : 'disabled';
+          const forcedLabel = u.mustChangePassword ? ' (must change password)' : '';
+          return `<tr>
+            <td>${escapeHtml(u.username)}</td>
+            <td>${escapeHtml(u.role)}</td>
+            <td>${escapeHtml(statusLabel)}${escapeHtml(forcedLabel)}</td>
+            <td>
+              <div class="row-actions" data-idx="${idx}">
+                ${u.status === 'active'
+                  ? `<zen-button size="sm" appearance="plain">
+                       <button type="button" class="action-disable" data-idx="${idx}">Disable</button>
+                     </zen-button>`
+                  : `<zen-button size="sm" appearance="plain">
+                       <button type="button" class="action-enable" data-idx="${idx}">Enable</button>
+                     </zen-button>`
+                }
+                ${u.role === 'member'
+                  ? `<zen-button size="sm" appearance="plain">
+                       <button type="button" class="action-promote" data-idx="${idx}">Make admin</button>
+                     </zen-button>`
+                  : `<zen-button size="sm" appearance="plain">
+                       <button type="button" class="action-demote" data-idx="${idx}">Make member</button>
+                     </zen-button>`
+                }
+                <zen-button size="sm" appearance="plain">
+                  <button type="button" class="action-reset" data-idx="${idx}">Reset password</button>
+                </zen-button>
+                <zen-button size="sm" appearance="plain">
+                  <button type="button" class="action-revoke" data-idx="${idx}">Revoke sessions</button>
+                </zen-button>
+              </div>
+            </td>
+          </tr>`;
+        }).join('');
+
+        tableContainer.innerHTML = `
+          <table>
+            <thead>
+              <tr>
+                <th scope="col">Username</th>
+                <th scope="col">Role</th>
+                <th scope="col">Status</th>
+                <th scope="col"><span class="visually-hidden">Actions</span></th>
+              </tr>
+            </thead>
+            <tbody>${rows}</tbody>
+          </table>`;
+
+        tableContainer.querySelectorAll('.action-disable').forEach(btn => {
+          btn.addEventListener('click', () => handleStatusChange(+btn.dataset.idx, 'disable'));
+        });
+        tableContainer.querySelectorAll('.action-enable').forEach(btn => {
+          btn.addEventListener('click', () => handleStatusChange(+btn.dataset.idx, 'enable'));
+        });
+        tableContainer.querySelectorAll('.action-promote').forEach(btn => {
+          btn.addEventListener('click', () => handleRoleChange(+btn.dataset.idx, 'admin'));
+        });
+        tableContainer.querySelectorAll('.action-demote').forEach(btn => {
+          btn.addEventListener('click', () => handleRoleChange(+btn.dataset.idx, 'member'));
+        });
+        tableContainer.querySelectorAll('.action-reset').forEach(btn => {
+          btn.addEventListener('click', () => openResetDialog(+btn.dataset.idx));
+        });
+        tableContainer.querySelectorAll('.action-revoke').forEach(btn => {
+          btn.addEventListener('click', () => handleRevokeSessions(+btn.dataset.idx));
+        });
+      }
+
+      async function patchUser(idx, payload) {
+        const user = userList[idx];
+        if (!user) return;
+        const resp = await fetch('/api/admin/users/' + encodeURIComponent(user.id), {
+          method: 'PATCH',
+          credentials: 'same-origin',
+          headers: {
+            'Content-Type': 'application/json',
+            'Origin': window.location.origin,
+            'X-CSRF-Token': csrfToken,
+          },
+          body: JSON.stringify(payload),
+        });
+        if (!resp.ok) {
+          const data = await resp.json().catch(() => ({}));
+          throw new Error((data.error && data.error.message) || 'Request failed');
+        }
+        return resp.json();
+      }
+
+      async function handleStatusChange(idx, op) {
+        clearStatus();
+        try {
+          await patchUser(idx, { op });
+          const data = await fetchUsers();
+          userList = data.users || [];
+          renderTable();
+          showStatus('User updated.', 'success');
+        } catch (e) {
+          showStatus(e.message || 'Update failed.', 'error');
+        }
+      }
+
+      async function handleRoleChange(idx, role) {
+        clearStatus();
+        try {
+          await patchUser(idx, { op: 'set_role', role });
+          const data = await fetchUsers();
+          userList = data.users || [];
+          renderTable();
+          showStatus('Role updated.', 'success');
+        } catch (e) {
+          showStatus(e.message || 'Update failed.', 'error');
+        }
+      }
+
+      function openResetDialog(idx) {
+        pendingResetIndex = idx;
+        resetForm.reset();
+        resetDialog.showModal();
+        document.getElementById('resetPassword').focus();
+      }
+
+      resetCancel.addEventListener('click', () => {
+        resetDialog.close();
+        pendingResetIndex = -1;
+      });
+
+      resetDialog.addEventListener('close', () => {
+        resetForm.reset();
+        pendingResetIndex = -1;
+      });
+
+      resetForm.addEventListener('submit', async (evt) => {
+        evt.preventDefault();
+        clearStatus();
+        const pw = document.getElementById('resetPassword').value;
+        if (!pw || pw.length < 12) {
+          showStatus('Password must be at least 12 characters.', 'error');
+          return;
+        }
+        const idx = pendingResetIndex;
+        resetSubmit.disabled = true;
+        try {
+          await patchUser(idx, { op: 'temp_reset', temporaryPassword: pw });
+          resetDialog.close();
+          showStatus('Password reset. User must change password on next login.', 'success');
+        } catch (e) {
+          showStatus(e.message || 'Reset failed.', 'error');
+        } finally {
+          resetSubmit.disabled = false;
+          /* Never retain the password value. */
+          resetForm.reset();
+        }
+      });
+
+      async function handleRevokeSessions(idx) {
+        clearStatus();
+        const user = userList[idx];
+        if (!user) return;
+        try {
+          const resp = await fetch(
+            '/api/admin/users/' + encodeURIComponent(user.id) + '/sessions',
+            {
+              method: 'DELETE',
+              credentials: 'same-origin',
+              headers: {
+                'Origin': window.location.origin,
+                'X-CSRF-Token': csrfToken,
+              },
+            }
+          );
+          if (!resp.ok) {
+            const data = await resp.json().catch(() => ({}));
+            throw new Error((data.error && data.error.message) || 'Revoke failed');
+          }
+          showStatus('Sessions revoked.', 'success');
+        } catch (e) {
+          showStatus(e.message || 'Revoke failed.', 'error');
+        }
+      }
+
+      createForm.addEventListener('submit', async (evt) => {
+        evt.preventDefault();
+        clearStatus();
+        const username = document.getElementById('newUsername').value.trim();
+        const password = document.getElementById('newPassword').value;
+        const role     = document.getElementById('newRole').value;
+        if (!username) { showStatus('Username required.', 'error'); return; }
+        if (!password || password.length < 12) {
+          showStatus('Password must be at least 12 characters.', 'error');
+          return;
+        }
+        createSubmit.disabled = true;
+        try {
+          const resp = await fetch('/api/admin/users', {
+            method: 'POST',
+            credentials: 'same-origin',
+            headers: {
+              'Content-Type': 'application/json',
+              'Origin': window.location.origin,
+              'X-CSRF-Token': csrfToken,
+            },
+            body: JSON.stringify({ username, temporaryPassword: password, role }),
+          });
+          const data = await resp.json().catch(() => ({}));
+          if (resp.status === 409) {
+            showStatus('Username already exists.', 'error');
+            return;
+          }
+          if (!resp.ok) {
+            showStatus((data.error && data.error.message) || 'Create failed.', 'error');
+            return;
+          }
+          createForm.reset();
+          const listData = await fetchUsers();
+          userList = listData.users || [];
+          renderTable();
+          showStatus('User created.', 'success');
+        } catch (e) {
+          showStatus(e.message || 'Create failed.', 'error');
+        } finally {
+          createSubmit.disabled = false;
+          /* Ensure password field is cleared even on error. */
+          document.getElementById('newPassword').value = '';
+        }
+      });
+
+      async function init() {
+        try {
+          const session = await fetchSession();
+          if (session.kind !== 'user' || session.role !== 'admin') {
+            window.location.href = '/login';
+            return;
+          }
+          csrfToken = session.csrfToken || '';
+          const data = await fetchUsers();
+          userList = data.users || [];
+          document.getElementById('loadingText').textContent = '';
+          renderTable();
+        } catch (e) {
+          showStatus('Failed to load admin page. Please refresh.', 'error');
+        }
+      }
+
+      init();
+    })();
+  </script>
+</body>
+</html>
--- a/mrjunejune/src/jrpg/index.html	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/src/jrpg/index.html	Fri Aug 07 07:34:12 2026 -0700
@@ -3,7 +3,8 @@
   <head>
     <title>Shiba Quest | MrJuneJune</title>
     {{/parts/base_head.html}}
-    <link rel="preload" href="/public/jrpg/background-frame-2.webp" as="image">
+    <link rel="preload" href="/public/jrpg/background-frame-2.webp" as="image" media="not ((max-width: 52rem) and (orientation: portrait))">
+    <link rel="preload" href="/public/jrpg/background-frame-mobile.webp" as="image" media="(max-width: 52rem) and (orientation: portrait)">
     <link rel="preload" href="/public/jrpg/bar-ink.webp" as="image">
     <link rel="stylesheet" href="/jrpg/jrpg.css">
     <script type="module" src="/jrpg/jrpg.js"></script>
@@ -46,9 +47,20 @@
               </a>
             </zen-button>
           </nav>
+          <zen-button class="jrpg-mobile-menu-btn" appearance="plain" size="xs">
+            <button
+              type="button"
+              data-mobile-menu-toggle
+              aria-label="Toggle destination menu"
+              aria-expanded="true"
+              aria-controls="jrpg-destination-menu"
+            >
+              <zen-icon name="menu"></zen-icon>
+            </button>
+          </zen-button>
           <footer class="jrpg-frame-telemetry" aria-label="System telemetry">
             <span aria-label="Temperature unavailable">N/A</span>
-            <span aria-label="User June">JUNE</span>
+            <span data-frame-account aria-live="polite"><a href="/login?next=/jrpg">LOGIN</a></span>
             <span data-frame-network aria-label="Status online">ONLINE</span>
             <time data-frame-uptime aria-label="Uptime">00:00:00</time>
           </footer>
@@ -103,11 +115,12 @@
           <section class="jrpg-utility" aria-label="Selected destination">
             <mjj-jrpg-preview data-selection="resume">
               <article class="jrpg-preview-panel" aria-live="polite">
-                <p class="jrpg-preview-kicker" data-preview-kicker>CHARACTER RECORD</p>
                 <zen-heading size="xl">
                   <h2 data-preview-title>Resume</h2>
                 </zen-heading>
-                <p data-preview-copy>Experience, projects, and the systems I have helped build.</p>
+                <p data-preview-copy>
+                  Member of Technical Staff and engineering leader with 10+ years building AI agent platforms and production systems.
+                </p>
                 <ul class="jrpg-work-showcase" data-work-showcase>
                   <li>
                     <a href="https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/">
@@ -193,6 +206,77 @@
                 </zen-dialog>
               </article>
             </mjj-jrpg-preview>
+
+            <mjj-conversation-archive hidden aria-label="Conversations">
+              <div class="jrpg-archive-heading">
+                <h2>Archive</h2>
+                <zen-button appearance="plain" size="xs">
+                  <button type="button" data-archive-close aria-label="Close conversations">
+                    <zen-icon name="close"></zen-icon>
+                  </button>
+                </zen-button>
+              </div>
+              <div class="jrpg-archive-actions">
+                <zen-button appearance="plain" size="sm">
+                  <button type="button" data-archive-new>
+                    <zen-icon name="plus"></zen-icon>
+                    New
+                  </button>
+                </zen-button>
+              </div>
+              <ol class="jrpg-archive-list" data-archive-list aria-label="Conversations">
+              </ol>
+              <zen-button appearance="plain" size="sm" data-archive-load-more-owner hidden>
+                <button type="button" data-archive-load-more>Load more</button>
+              </zen-button>
+              <p class="jrpg-archive-status" data-archive-status aria-live="polite">Loading conversations...</p>
+              <dialog
+                class="jrpg-archive-dialog"
+                data-archive-delete-dialog
+                aria-labelledby="archive-delete-title"
+                aria-modal="true"
+              >
+                <p id="archive-delete-title" class="jrpg-archive-dialog-title">Delete this conversation?</p>
+                <p data-archive-delete-name class="jrpg-archive-dialog-subtitle"></p>
+                <div class="jrpg-archive-dialog-actions">
+                  <zen-button appearance="plain" size="md">
+                    <button type="button" data-archive-delete-cancel>Cancel</button>
+                  </zen-button>
+                  <zen-button appearance="plain" size="md">
+                    <button type="button" data-archive-delete-confirm>Delete</button>
+                  </zen-button>
+                </div>
+              </dialog>
+              <dialog
+                class="jrpg-archive-dialog"
+                data-archive-rename-dialog
+                aria-labelledby="archive-rename-title"
+                aria-modal="true"
+              >
+                <p id="archive-rename-title" class="jrpg-archive-dialog-title">Rename conversation</p>
+                <form data-archive-rename-form novalidate>
+                  <zen-field appearance="plain" size="md">
+                    <label for="archive-rename-input">New title</label>
+                    <input
+                      id="archive-rename-input"
+                      data-archive-rename-input
+                      type="text"
+                      maxlength="200"
+                      required
+                      autocomplete="off"
+                    >
+                  </zen-field>
+                  <div class="jrpg-archive-dialog-actions">
+                    <zen-button appearance="plain" size="md">
+                      <button type="button" data-archive-rename-cancel>Cancel</button>
+                    </zen-button>
+                    <zen-button appearance="plain" size="md">
+                      <button type="submit">Save</button>
+                    </zen-button>
+                  </div>
+                </form>
+              </dialog>
+            </mjj-conversation-archive>
           </section>
 
           <mjj-jrpg-composer>
@@ -222,11 +306,12 @@
                   </button>
                 </zen-button>
               </div>
+              <p class="jrpg-composer-quota" data-quota aria-live="polite" hidden></p>
               <p class="jrpg-composer-hint">Enter to send / Shift + Enter for a new line</p>
             </form>
           </mjj-jrpg-composer>
 
-          <mjj-jrpg-menu>
+          <mjj-jrpg-menu id="jrpg-destination-menu">
             <nav aria-label="Shiba Quest destinations">
               <p class="jrpg-menu-label">Menu</p>
               <ul>
@@ -254,9 +339,74 @@
                     </button>
                   </zen-button>
                 </li>
+                <li>
+                  <zen-button appearance="plain" size="sm">
+                    <button type="button" data-preview="conversations" aria-pressed="false">
+                      <zen-icon name="chevron-right"></zen-icon>
+                      Conversations
+                    </button>
+                  </zen-button>
+                </li>
               </ul>
             </nav>
           </mjj-jrpg-menu>
+
+          <zen-dialog class="jrpg-login-dialog-owner" data-login-dialog-owner>
+            <zen-button appearance="plain" size="md" hidden>
+              <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button>
+            </zen-button>
+            <dialog
+              class="jrpg-login-dialog"
+              data-login-dialog
+              aria-labelledby="jrpg-login-title"
+              aria-modal="true"
+            >
+              <div class="jrpg-login-dialog-header">
+                <h2 id="jrpg-login-title">SIGN IN</h2>
+                <zen-button appearance="plain" size="md">
+                  <button type="button" data-zen-close aria-label="Close sign-in dialog">
+                    <zen-icon name="close"></zen-icon>
+                  </button>
+                </zen-button>
+              </div>
+              <div role="alert" data-login-error aria-live="assertive" hidden></div>
+              <form data-login-form novalidate>
+                <zen-field appearance="plain" size="md">
+                  <label for="jrpg-login-username">Username</label>
+                  <input
+                    id="jrpg-login-username"
+                    name="username"
+                    type="text"
+                    autocomplete="username"
+                    autocapitalize="none"
+                    spellcheck="false"
+                    required
+                    maxlength="32"
+                  >
+                </zen-field>
+                <zen-field appearance="plain" size="md">
+                  <label for="jrpg-login-password">Password</label>
+                  <input
+                    id="jrpg-login-password"
+                    name="password"
+                    type="password"
+                    autocomplete="current-password"
+                    required
+                    minlength="12"
+                    maxlength="1024"
+                  >
+                </zen-field>
+                <div class="jrpg-login-dialog-actions">
+                  <zen-button appearance="plain" size="md">
+                    <button type="button" data-login-cancel data-zen-close>Cancel</button>
+                  </zen-button>
+                  <zen-button appearance="plain" size="md">
+                    <button type="submit" data-login-submit>Sign in</button>
+                  </zen-button>
+                </div>
+              </form>
+            </dialog>
+          </zen-dialog>
         </div>
       </mjj-jrpg-shell>
     </main>
--- a/mrjunejune/src/jrpg/jrpg.css	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/src/jrpg/jrpg.css	Fri Aug 07 07:34:12 2026 -0700
@@ -105,8 +105,8 @@
   --mjj-frame-user-value-inline: 32.5%;
   --mjj-frame-status-value-inline: 60.5%;
   --mjj-frame-uptime-value-inline: 88%;
-  --mjj-frame-dog-bar-block-end: 4%;
-  --mjj-frame-dog-bar-height: 31%;
+  --mjj-frame-dog-bar-block-end: 0%;
+  --mjj-frame-dog-bar-height: 100%;
   box-sizing: border-box;
   display: grid;
   place-items: center;
@@ -354,7 +354,7 @@
   background-image: var(--mjj-jrpg-art);
   background-position: center;
   background-repeat: no-repeat;
-  background-size: 100% 100%;
+  background-size: cover;
   content: "";
   image-rendering: pixelated;
   pointer-events: none;
@@ -416,7 +416,6 @@
   border-bottom: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
 }
 
-.jrpg-preview-kicker,
 .jrpg-menu-label,
 .jrpg-brand > p,
 .jrpg-dialog-heading p {
@@ -559,6 +558,18 @@
   gap: 0;
   padding: var(--zenbu-sys-padding-md);
   background: var(--mjj-jrpg-surface);
+  overflow: hidden;
+}
+
+.jrpg-utility > mjj-jrpg-preview,
+.jrpg-utility > mjj-conversation-archive {
+  box-sizing: border-box;
+  grid-column: 1;
+  grid-row: 1;
+  width: 100%;
+  height: 100%;
+  min-height: 0;
+  min-width: 0;
 }
 
 .jrpg-brand {
@@ -613,7 +624,7 @@
 
 .jrpg-preview-panel {
   display: grid;
-  grid-template-rows: auto auto auto minmax(0, 1fr) auto;
+  grid-template-rows: auto auto minmax(0, 1fr) auto;
   gap: var(--zenbu-sys-space-group);
   height: 100%;
   padding: var(--zenbu-sys-padding-lg);
@@ -637,7 +648,7 @@
   color: var(--mjj-jrpg-primary);
 }
 
-.jrpg-preview-panel > p:not(.jrpg-preview-kicker) {
+.jrpg-preview-panel > p {
   color: var(--mjj-jrpg-text);
 }
 
@@ -1507,7 +1518,8 @@
   mjj-jrpg-menu
 ),
 .jrpg-scene > mjj-jrpg-chat,
-.jrpg-utility > mjj-jrpg-preview > .jrpg-preview-panel {
+.jrpg-utility > mjj-jrpg-preview > .jrpg-preview-panel,
+.jrpg-utility > mjj-conversation-archive {
   border: 0;
   background-color: transparent;
   box-shadow: none;
@@ -1642,6 +1654,7 @@
 
   .jrpg-utility {
     min-height: 27rem;
+    overflow: hidden;
   }
 
   mjj-jrpg-menu nav {
@@ -1653,7 +1666,7 @@
   }
 
   mjj-jrpg-menu ul {
-    grid-template-columns: repeat(3, minmax(0, 1fr));
+    grid-template-columns: repeat(2, minmax(0, 1fr));
   }
 }
 
@@ -1784,7 +1797,7 @@
   }
 
   mjj-jrpg-menu ul {
-    grid-template-columns: 1fr;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
   }
 
   .jrpg-preview-dialog dialog {
@@ -1830,6 +1843,361 @@
   display: none;
 }
 
+/* ================================================================
+   Archive component — placed in utility panel (top-right)
+   ================================================================ */
+
+mjj-jrpg-chat {
+  grid-column: 2;
+  grid-row: 1;
+}
+
+mjj-conversation-archive {
+  position: relative;
+  z-index: 1;
+  display: grid;
+  grid-template-rows: auto auto minmax(0, 1fr) auto auto;
+  align-self: stretch;
+  min-height: 0;
+  padding: var(--zenbu-sys-padding-md);
+  overflow: hidden;
+  background: var(--mjj-jrpg-panel-strong);
+  box-shadow: inset 0 0 0 var(--zenbu-sys-stroke-width) var(--mjj-jrpg-info);
+}
+
+mjj-conversation-archive[hidden] {
+  display: none;
+}
+
+.jrpg-utility > mjj-conversation-archive {
+  border: 0;
+  box-shadow: inset 0 0 0 var(--zenbu-sys-stroke-width) var(--mjj-jrpg-info);
+}
+
+.jrpg-archive-heading {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: var(--zenbu-sys-space-group);
+  padding-bottom: var(--zenbu-sys-padding-sm);
+  border-bottom: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+}
+
+.jrpg-archive-heading h2 {
+  margin: 0;
+  color: var(--mjj-jrpg-text);
+  font-size: var(--zenbu-sys-font-size-lg);
+}
+
+.jrpg-archive-heading zen-button {
+  flex-shrink: 0;
+}
+
+.jrpg-archive-heading zen-button > button {
+  border: 0;
+  border-radius: 0;
+  background: transparent;
+  color: var(--mjj-jrpg-text-muted);
+  cursor: pointer;
+}
+
+.jrpg-archive-actions {
+  display: flex;
+  gap: var(--zenbu-sys-space-control);
+  padding-block: var(--zenbu-sys-padding-sm);
+  border-bottom: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+}
+
+.jrpg-archive-actions zen-button,
+.jrpg-archive-actions button {
+  width: 100%;
+}
+
+.jrpg-archive-actions button {
+  border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+  border-radius: 0;
+  background: var(--mjj-jrpg-panel);
+  color: var(--mjj-jrpg-primary);
+  cursor: pointer;
+}
+
+.jrpg-archive-actions button:hover {
+  border-color: var(--mjj-jrpg-accent);
+  background: var(--mjj-jrpg-surface-raised);
+}
+
+.jrpg-archive-list {
+  display: grid;
+  align-content: start;
+  gap: var(--zenbu-sys-space-icon-label);
+  min-height: 0;
+  margin: 0;
+  padding: var(--zenbu-sys-padding-sm) 0;
+  overflow-y: auto;
+  list-style: none;
+  scrollbar-color: var(--mjj-jrpg-frame) transparent;
+}
+
+.jrpg-archive-list [data-conv-item] {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  align-items: center;
+  gap: var(--zenbu-sys-space-icon-label);
+  padding: var(--zenbu-sys-padding-sm);
+  border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+  background: var(--mjj-jrpg-panel);
+}
+
+.jrpg-archive-list [data-conv-open] {
+  display: grid;
+  gap: 0.1em;
+  min-width: 0;
+  border: 0;
+  border-radius: 0;
+  background: transparent;
+  color: var(--mjj-jrpg-primary);
+  cursor: pointer;
+  text-align: left;
+}
+
+.jrpg-archive-list [data-conv-open][aria-current="true"] {
+  color: var(--mjj-jrpg-highlight);
+}
+
+.jrpg-archive-list [data-conv-item]:has([data-conv-open][aria-current="true"]) {
+  border-color: var(--mjj-jrpg-accent);
+  background: var(--mjj-jrpg-surface-raised);
+}
+
+.jrpg-archive-list [data-conv-item]:hover {
+  border-color: var(--mjj-jrpg-frame);
+  background: var(--mjj-jrpg-surface-raised);
+}
+
+.jrpg-archive-list [data-conv-title] {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  font-size: var(--zenbu-sys-font-size-sm);
+}
+
+.jrpg-archive-list [data-conv-meta] {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  color: var(--mjj-jrpg-text-muted);
+  font-size: var(--zenbu-sys-font-size-xs);
+}
+
+.jrpg-archive-item-actions {
+  display: flex;
+  gap: var(--zenbu-sys-space-icon-label);
+  flex-shrink: 0;
+}
+
+.jrpg-archive-item-actions button {
+  border: 0;
+  border-radius: 0;
+  background: transparent;
+  color: var(--mjj-jrpg-text-muted);
+  cursor: pointer;
+  font-size: var(--zenbu-sys-font-size-xs);
+}
+
+.jrpg-archive-item-actions button:hover {
+  color: var(--mjj-jrpg-accent);
+}
+
+[data-archive-load-more-owner] {
+  padding-block: var(--zenbu-sys-padding-sm);
+}
+
+[data-archive-load-more-owner][hidden] {
+  display: none;
+}
+
+[data-archive-load-more-owner] > button {
+  width: 100%;
+  border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+  border-radius: 0;
+  background: var(--mjj-jrpg-panel);
+  color: var(--mjj-jrpg-text-muted);
+  cursor: pointer;
+}
+
+[data-archive-load-more-owner] > button:hover {
+  border-color: var(--mjj-jrpg-frame);
+  color: var(--mjj-jrpg-primary);
+}
+
+.jrpg-archive-status {
+  margin: 0;
+  padding: var(--zenbu-sys-padding-sm) 0;
+  color: var(--mjj-jrpg-text-muted);
+  font-size: var(--zenbu-sys-font-size-xs);
+}
+
+.jrpg-archive-status[hidden] {
+  display: none;
+}
+
+.jrpg-archive-status[data-state="error"] {
+  color: var(--mjj-jrpg-accent);
+}
+
+.jrpg-archive-status button {
+  border: 0;
+  border-radius: 0;
+  background: transparent;
+  color: var(--mjj-jrpg-info);
+  cursor: pointer;
+  font-size: inherit;
+  text-decoration: underline;
+}
+
+/* Archive dialogs */
+
+.jrpg-archive-dialog {
+  box-sizing: border-box;
+  min-width: min(22rem, calc(100vw - var(--zenbu-sys-space-content)));
+  max-width: min(26rem, calc(100vw - var(--zenbu-sys-space-content)));
+  padding: var(--zenbu-sys-padding-lg);
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+  border-radius: 0;
+  background: var(--mjj-jrpg-surface-raised);
+  color: var(--mjj-jrpg-text);
+  box-shadow:
+    inset 0 0 0 var(--zenbu-sys-stroke-width) var(--mjj-jrpg-accent),
+    var(--zenbu-sys-space-control) var(--zenbu-sys-space-control) 0 var(--mjj-jrpg-accent);
+}
+
+.jrpg-archive-dialog::backdrop {
+  background: color-mix(in srgb, var(--mjj-jrpg-canvas) 84%, transparent);
+}
+
+.jrpg-archive-dialog[open] {
+  display: grid;
+  grid-template-rows: auto auto auto;
+  gap: var(--zenbu-sys-space-group);
+  animation: mjj-jrpg-dialog-open var(--zenbu-sys-motion-duration-layout)
+    var(--zenbu-sys-motion-ease-enter);
+}
+
+.jrpg-archive-dialog-title {
+  margin: 0;
+  color: var(--mjj-jrpg-primary);
+  font-size: var(--zenbu-sys-font-size-md);
+}
+
+.jrpg-archive-dialog-subtitle {
+  margin: 0;
+  color: var(--mjj-jrpg-text-muted);
+  font-size: var(--zenbu-sys-font-size-xs);
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.jrpg-archive-dialog-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: var(--zenbu-sys-space-control);
+}
+
+.jrpg-archive-dialog zen-button > button {
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+  border-radius: 0;
+  background: var(--mjj-jrpg-surface);
+  color: var(--mjj-jrpg-text);
+  cursor: pointer;
+}
+
+.jrpg-archive-dialog zen-button > button:hover {
+  border-color: var(--mjj-jrpg-accent);
+  background: var(--mjj-jrpg-surface-raised);
+}
+
+.jrpg-archive-dialog zen-field[appearance="plain"] {
+  display: grid;
+  gap: var(--zenbu-sys-space-icon-label);
+}
+
+.jrpg-archive-dialog input {
+  box-sizing: border-box;
+  width: 100%;
+  min-height: var(--zenbu-control-height);
+  padding:
+    var(--zenbu-control-padding-block)
+    var(--zenbu-control-padding-inline);
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+  border-radius: 0;
+  background: var(--mjj-jrpg-surface-sunken);
+  color: var(--mjj-jrpg-text);
+  font: inherit;
+}
+
+/* Account slot — telemetry child 2 */
+
+[data-frame-account] a,
+[data-frame-account] button,
+[data-frame-account] span[data-account-user] {
+  color: var(--mjj-jrpg-primary);
+  font-size: inherit;
+}
+
+[data-frame-account] a {
+  text-decoration: none;
+}
+
+[data-frame-account] a:hover {
+  text-decoration: underline;
+  text-decoration-color: var(--mjj-jrpg-accent);
+}
+
+[data-frame-account] button {
+  border: 0;
+  background: transparent;
+  cursor: pointer;
+  padding: 0;
+  font: inherit;
+}
+
+[data-frame-account] button:hover {
+  color: var(--mjj-jrpg-accent);
+}
+
+/* Quota display in composer */
+
+.jrpg-composer-quota {
+  grid-column: 1 / -1;
+  margin: 0;
+  color: var(--mjj-jrpg-text-muted);
+  font-size: var(--zenbu-sys-font-size-xs);
+}
+
+.jrpg-composer-quota[data-state="warning"] {
+  color: var(--mjj-jrpg-highlight);
+}
+
+.jrpg-composer-quota[data-state="exhausted"] {
+  color: var(--mjj-jrpg-accent);
+}
+
+.jrpg-composer-quota[hidden] {
+  display: none;
+}
+
+/* Archive + chat grid in mobile single-column layout */
+
+@media (max-width: 34rem) {
+  mjj-jrpg-chat,
+  mjj-conversation-archive {
+    grid-column: 1;
+    grid-row: 2;
+  }
+}
+
+
 @media (forced-colors: active) {
   .jrpg-workspace,
   .jrpg-scene,
@@ -1837,3 +2205,576 @@
     background-image: none;
   }
 }
+
+/* ================================================================
+   Mobile hamburger button — hidden on desktop, used in portrait
+   ================================================================ */
+
+.jrpg-mobile-menu-btn {
+  display: none;
+}
+
+/* ================================================================
+   Mobile portrait frame layout (≤52rem + portrait)
+   Overrides the generic stacked grid from the 52rem breakpoint.
+   All geometry custom properties are centralised in .jrpg-shell.
+   ================================================================ */
+
+@media (max-width: 52rem) and (orientation: portrait) {
+  .jrpg-shell {
+    /* ── Aperture geometry: (inline=left%, block=top%, width%, height%) ── */
+
+    /* Main conversation */
+    --mjj-mobile-scene-inline:  4.5%;
+    --mjj-mobile-scene-block:   7.5%;
+    --mjj-mobile-scene-width:  91%;
+    --mjj-mobile-scene-height: 45.5%;
+
+    /* Preview / utility */
+    --mjj-mobile-utility-inline:  4.5%;
+    --mjj-mobile-utility-block:  54.7%;
+    --mjj-mobile-utility-width:  91%;
+    --mjj-mobile-utility-height: 17.1%;
+
+    /* Composer */
+    --mjj-mobile-composer-inline:  4.5%;
+    --mjj-mobile-composer-block:  72.8%;
+    --mjj-mobile-composer-width:  60.3%;
+    --mjj-mobile-composer-height: 19.2%;
+
+    /* Menu */
+    --mjj-mobile-menu-inline:  66.5%;
+    --mjj-mobile-menu-block:   72.8%;
+    --mjj-mobile-menu-width:   29%;
+    --mjj-mobile-menu-height:  19.2%;
+
+    /* Brand / SYS-01 cover */
+    --mjj-mobile-brand-inline:  11%;
+    --mjj-mobile-brand-block:    2.4%;
+    --mjj-mobile-brand-width:   10%;
+    --mjj-mobile-brand-height:   3.5%;
+
+    /* Baked hamburger overlay */
+    --mjj-mobile-hamburger-inline: 89.5%;
+    --mjj-mobile-hamburger-block:   2%;
+    --mjj-mobile-hamburger-width:   7%;
+    --mjj-mobile-hamburger-height:  4%;
+
+    /* Telemetry value absolute positions (x = left-center, y = top-center) */
+    --mjj-mobile-telemetry-temp-x:   22%;
+    --mjj-mobile-telemetry-temp-y:   94.7%;
+    --mjj-mobile-telemetry-user-x:   45%;
+    --mjj-mobile-telemetry-user-y:   94.7%;
+    --mjj-mobile-telemetry-status-x: 73%;
+    --mjj-mobile-telemetry-status-y: 94.7%;
+    --mjj-mobile-telemetry-uptime-x: 22%;
+    --mjj-mobile-telemetry-uptime-y: 97.5%;
+
+    height: 100dvh;
+    min-height: 0;
+  }
+
+  .jrpg-page {
+    overflow: hidden;
+  }
+
+  .jrpg-main {
+    height: 100dvh;
+    min-height: 0;
+    padding: 0;
+  }
+
+  /* Workspace: switch from stacked grid back to absolute-positioned frame */
+  .jrpg-workspace {
+    display: block;
+    position: relative;
+    width: 100%;
+    height: 100%;
+    min-height: 0;
+    overflow: hidden;
+    background-image: url("/public/jrpg/background-frame-mobile.webp");
+    background-position: top left;
+    background-size: 100% 100%;
+    background-repeat: no-repeat;
+  }
+
+  /* Desktop window controls are irrelevant on mobile portrait */
+  .jrpg-workspace > .jrpg-frame-controls {
+    display: none;
+  }
+
+  /* Restore absolute positioning for all aperture elements */
+  .jrpg-workspace > .jrpg-scene,
+  .jrpg-workspace > .jrpg-utility,
+  .jrpg-workspace > mjj-jrpg-composer,
+  .jrpg-workspace > mjj-jrpg-menu,
+  .jrpg-workspace > .jrpg-brand {
+    position: absolute;
+    width: auto;
+    height: auto;
+  }
+
+  /* Scene aperture */
+  .jrpg-workspace > .jrpg-scene {
+    inset:
+      var(--mjj-mobile-scene-block)
+      auto
+      auto
+      var(--mjj-mobile-scene-inline);
+    width: var(--mjj-mobile-scene-width);
+    height: var(--mjj-mobile-scene-height);
+    min-height: 0;
+    font-size: clamp(
+      var(--zenbu-sys-font-size-xs),
+      2.8vw,
+      var(--zenbu-sys-font-size-sm)
+    );
+  }
+
+  /* Utility / preview aperture */
+  .jrpg-workspace > .jrpg-utility {
+    inset:
+      var(--mjj-mobile-utility-block)
+      auto
+      auto
+      var(--mjj-mobile-utility-inline);
+    width: var(--mjj-mobile-utility-width);
+    height: var(--mjj-mobile-utility-height);
+    min-height: 0;
+    overflow: hidden;
+    font-size: clamp(
+      var(--zenbu-sys-font-size-xs),
+      2.8vw,
+      var(--zenbu-sys-font-size-sm)
+    );
+  }
+
+  /* Composer aperture */
+  .jrpg-workspace > mjj-jrpg-composer {
+    inset:
+      var(--mjj-mobile-composer-block)
+      auto
+      auto
+      var(--mjj-mobile-composer-inline);
+    width: var(--mjj-mobile-composer-width);
+    height: var(--mjj-mobile-composer-height);
+    font-size: clamp(
+      var(--zenbu-sys-font-size-xs),
+      2.8vw,
+      var(--zenbu-sys-font-size-sm)
+    );
+  }
+
+  /* Menu aperture */
+  .jrpg-workspace > mjj-jrpg-menu {
+    inset:
+      var(--mjj-mobile-menu-block)
+      auto
+      auto
+      var(--mjj-mobile-menu-inline);
+    width: var(--mjj-mobile-menu-width);
+    height: var(--mjj-mobile-menu-height);
+    overflow: hidden;
+    font-size: clamp(
+      var(--zenbu-sys-font-size-xs),
+      2.5vw,
+      var(--zenbu-sys-font-size-sm)
+    );
+  }
+
+  /* Brand cover (covers baked SYS-01 label) */
+  .jrpg-workspace > .jrpg-brand {
+    inset:
+      var(--mjj-mobile-brand-block)
+      auto
+      auto
+      var(--mjj-mobile-brand-inline);
+    width: var(--mjj-mobile-brand-width);
+    height: var(--mjj-mobile-brand-height);
+    background: var(--mjj-jrpg-canvas);
+    padding: 0;
+    box-shadow: none;
+  }
+
+  /* Hamburger button: transparent overlay over baked art */
+  .jrpg-workspace > .jrpg-mobile-menu-btn {
+    display: grid;
+    place-items: center;
+    position: absolute;
+    z-index: 5;
+    inset:
+      var(--mjj-mobile-hamburger-block)
+      auto
+      auto
+      var(--mjj-mobile-hamburger-inline);
+    width: var(--mjj-mobile-hamburger-width);
+    height: var(--mjj-mobile-hamburger-height);
+  }
+
+  .jrpg-mobile-menu-btn > button {
+    width: 100%;
+    height: 100%;
+    padding: 0;
+    border: 0;
+    border-radius: 0;
+    background: transparent;
+    color: transparent;
+    cursor: pointer;
+  }
+
+  .jrpg-mobile-menu-btn zen-icon {
+    opacity: 0;
+  }
+
+  .jrpg-mobile-menu-btn > button:focus-visible {
+    outline-color: var(--mjj-jrpg-focus);
+    outline-style: solid;
+    outline-width: 2px;
+    outline-offset: 2px;
+    color: var(--mjj-jrpg-primary);
+  }
+
+  .jrpg-mobile-menu-btn > button:focus-visible zen-icon {
+    opacity: 0.5;
+  }
+
+  /* Telemetry: full-workspace container so children use workspace % coords */
+  .jrpg-workspace > .jrpg-frame-telemetry {
+    position: absolute;
+    inset: 0;
+    display: block;
+    width: 100%;
+    height: 100%;
+    background: transparent;
+    pointer-events: none;
+    font-size: clamp(
+      var(--zenbu-sys-font-size-xs),
+      2.5vw,
+      var(--zenbu-sys-font-size-sm)
+    );
+  }
+
+  .jrpg-frame-telemetry > :nth-child(1) {
+    position: absolute;
+    left: var(--mjj-mobile-telemetry-temp-x);
+    top: var(--mjj-mobile-telemetry-temp-y);
+    transform: translate(-50%, -50%);
+    background: transparent;
+    padding-inline: 0;
+    pointer-events: auto;
+    white-space: nowrap;
+  }
+
+  .jrpg-frame-telemetry > :nth-child(2) {
+    position: absolute;
+    left: var(--mjj-mobile-telemetry-user-x);
+    top: var(--mjj-mobile-telemetry-user-y);
+    transform: translate(-50%, -50%);
+    background: transparent;
+    padding-inline: 0;
+    pointer-events: auto;
+    white-space: nowrap;
+  }
+
+  .jrpg-frame-telemetry > :nth-child(3) {
+    position: absolute;
+    left: var(--mjj-mobile-telemetry-status-x);
+    top: var(--mjj-mobile-telemetry-status-y);
+    transform: translate(-50%, -50%);
+    background: transparent;
+    padding-inline: 0;
+    pointer-events: auto;
+    white-space: nowrap;
+  }
+
+  .jrpg-frame-telemetry > :nth-child(4) {
+    position: absolute;
+    left: var(--mjj-mobile-telemetry-uptime-x);
+    top: var(--mjj-mobile-telemetry-uptime-y);
+    transform: translate(-50%, -50%);
+    background: transparent;
+    padding-inline: 0;
+    pointer-events: auto;
+    white-space: nowrap;
+  }
+
+  /* Compact the brand header text for the narrow cover area */
+  .jrpg-workspace > .jrpg-brand header h1 {
+    font-size: clamp(
+      var(--zenbu-sys-font-size-xs),
+      2vw,
+      var(--zenbu-sys-font-size-xs)
+    );
+  }
+
+  /* Compact scene internals for mobile aperture */
+  .jrpg-scene {
+    grid-template-columns: minmax(0, 1fr);
+    grid-template-rows: minmax(0, 1fr);
+    align-items: stretch;
+    gap: var(--zenbu-sys-space-icon-label);
+    padding: var(--zenbu-sys-padding-sm);
+  }
+
+  .jrpg-scene > mjj-jrpg-character {
+    display: none;
+  }
+
+  mjj-jrpg-chat {
+    grid-column: 1;
+    grid-row: 1;
+    width: 100%;
+    height: 100%;
+    max-height: 100%;
+    overflow: hidden;
+    padding: var(--zenbu-sys-padding-sm);
+  }
+
+  .jrpg-utility > mjj-conversation-archive {
+    width: 100%;
+    height: 100%;
+    max-height: 100%;
+    overflow: auto;
+  }
+
+  /* Utility compact padding */
+  .jrpg-utility {
+    padding: var(--zenbu-sys-padding-sm);
+  }
+
+  /* Composer: compact single-row within aperture */
+  .jrpg-composer {
+    gap: var(--zenbu-sys-space-icon-label);
+    padding: var(--zenbu-sys-padding-sm);
+  }
+
+  .jrpg-composer-hint {
+    display: none;
+  }
+
+  /* Menu compact: single column stacking */
+  .jrpg-workspace > mjj-jrpg-menu {
+    padding: var(--zenbu-sys-padding-xs, var(--zenbu-sys-padding-sm));
+    overflow-y: auto;
+    scrollbar-width: thin;
+  }
+
+  mjj-jrpg-menu nav {
+    height: 100%;
+    grid-template-columns: 1fr;
+  }
+
+  mjj-jrpg-menu .jrpg-menu-label {
+    display: none;
+  }
+
+  mjj-jrpg-menu ul {
+    grid-template-columns: minmax(0, 1fr);
+    gap: 0;
+  }
+
+  mjj-jrpg-menu button {
+    min-height: var(--zenbu-control-height);
+    padding-block: 0;
+    padding-inline: var(--zenbu-control-padding-inline);
+    font-size: var(--zenbu-sys-font-size-xs);
+  }
+
+  mjj-jrpg-menu[data-mobile-menu-collapsed] {
+    overflow: hidden;
+  }
+
+  mjj-jrpg-menu[data-mobile-menu-collapsed] nav {
+    display: grid;
+    place-items: center;
+  }
+
+  mjj-jrpg-menu[data-mobile-menu-collapsed] .jrpg-menu-label {
+    display: block;
+    writing-mode: initial;
+  }
+
+  mjj-jrpg-menu[data-mobile-menu-collapsed] ul {
+    display: none;
+  }
+
+  /* Preview panel compact for narrow utility aperture */
+  .jrpg-preview-panel {
+    gap: var(--zenbu-sys-space-icon-label);
+    padding: var(--zenbu-sys-padding-sm);
+    overflow-y: auto;
+  }
+
+  .jrpg-preview-panel h2 {
+    font-size: var(--zenbu-sys-font-size-sm);
+  }
+
+  .jrpg-preview-panel > p {
+    font-size: var(--zenbu-sys-font-size-xs);
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  .jrpg-work-showcase {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: var(--zenbu-sys-space-icon-label);
+  }
+
+  .jrpg-work-showcase a {
+    padding: var(--zenbu-sys-padding-xs, var(--zenbu-sys-padding-sm));
+    font-size: var(--zenbu-sys-font-size-xs);
+  }
+
+  /* Archive fits within scene aperture */
+  mjj-conversation-archive {
+    padding: var(--zenbu-sys-padding-sm);
+  }
+
+  /* Scrollable message viewport fills aperture */
+  zen-message-scroller > .jrpg-message-viewport {
+    scrollbar-width: thin;
+  }
+}
+
+/* ================================================================
+   Login modal dialog
+   ================================================================ */
+
+.jrpg-login-dialog-owner {
+  display: contents;
+}
+
+.jrpg-login-dialog {
+  box-sizing: border-box;
+  width: min(24rem, calc(100vw - var(--zenbu-sys-space-content)));
+  padding: var(--zenbu-sys-padding-xl);
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+  border-radius: 0;
+  background: var(--mjj-jrpg-surface-raised);
+  color: var(--mjj-jrpg-text);
+  box-shadow:
+    inset 0 0 0 var(--zenbu-sys-stroke-width) var(--mjj-jrpg-accent),
+    var(--zenbu-sys-space-control) var(--zenbu-sys-space-control) 0
+      var(--mjj-jrpg-accent);
+}
+
+.jrpg-login-dialog::backdrop {
+  background: color-mix(in srgb, var(--mjj-jrpg-canvas) 84%, transparent);
+}
+
+.jrpg-login-dialog[open] {
+  display: grid;
+  grid-template-rows: auto auto minmax(0, 1fr);
+  gap: var(--zenbu-sys-space-group);
+  animation: mjj-jrpg-dialog-open var(--zenbu-sys-motion-duration-layout)
+    var(--zenbu-sys-motion-ease-enter);
+}
+
+.jrpg-login-dialog-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--zenbu-sys-space-group);
+  padding-bottom: var(--zenbu-sys-padding-md);
+  border-bottom: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+}
+
+.jrpg-login-dialog-header h2 {
+  margin: 0;
+  color: var(--mjj-jrpg-primary);
+  font-size: var(--zenbu-sys-font-size-lg);
+  letter-spacing: 0.08em;
+}
+
+.jrpg-login-dialog-header zen-button > button {
+  width: var(--zenbu-control-height);
+  padding: 0;
+  border: 0;
+  border-radius: 0;
+  background: transparent;
+  color: var(--mjj-jrpg-text-muted);
+  cursor: pointer;
+}
+
+.jrpg-login-dialog [data-login-error] {
+  padding: var(--zenbu-sys-padding-sm);
+  border-left: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-accent);
+  background: color-mix(in srgb, var(--mjj-jrpg-accent) 10%, transparent);
+  color: var(--mjj-jrpg-accent);
+  font-size: var(--zenbu-sys-font-size-sm);
+}
+
+.jrpg-login-dialog [data-login-error][hidden] {
+  display: none;
+}
+
+.jrpg-login-dialog form {
+  display: grid;
+  gap: var(--zenbu-sys-space-group);
+}
+
+.jrpg-login-dialog zen-field[appearance="plain"] {
+  display: grid;
+  gap: var(--zenbu-sys-space-icon-label);
+}
+
+.jrpg-login-dialog input {
+  box-sizing: border-box;
+  width: 100%;
+  min-height: var(--zenbu-control-height);
+  padding:
+    var(--zenbu-control-padding-block)
+    var(--zenbu-control-padding-inline);
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+  border-radius: 0;
+  background: var(--mjj-jrpg-surface-sunken);
+  color: var(--mjj-jrpg-text);
+  font: inherit;
+}
+
+.jrpg-login-dialog input::placeholder {
+  color: var(--mjj-jrpg-text-muted);
+}
+
+.jrpg-login-dialog-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: var(--zenbu-sys-space-control);
+}
+
+.jrpg-login-dialog zen-button > button {
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+  border-radius: 0;
+  background: var(--mjj-jrpg-surface);
+  color: var(--mjj-jrpg-text);
+  cursor: pointer;
+}
+
+.jrpg-login-dialog zen-button > button:hover {
+  border-color: var(--mjj-jrpg-accent);
+  background: var(--mjj-jrpg-surface-raised);
+}
+
+.jrpg-login-dialog [data-login-submit] {
+  border-color: var(--mjj-jrpg-primary);
+  background: var(--mjj-jrpg-primary);
+  color: var(--mjj-jrpg-primary-foreground);
+}
+
+.jrpg-login-dialog [data-login-submit]:hover {
+  border-color: var(--mjj-jrpg-primary-hover);
+  background: var(--mjj-jrpg-primary-hover);
+}
+
+.jrpg-login-dialog zen-button > button:disabled {
+  opacity: var(--zenbu-sys-opacity-disabled);
+  cursor: not-allowed;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .jrpg-login-dialog[open] {
+    animation: none;
+  }
+}
--- a/mrjunejune/src/jrpg/jrpg.js	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/src/jrpg/jrpg.js	Fri Aug 07 07:34:12 2026 -0700
@@ -15,8 +15,7 @@
 
 const PREVIEWS = Object.freeze({
   resume: {
-    copy: "Experience, projects, and the systems I have helped build.",
-    kicker: "CHARACTER RECORD",
+    copy: "Member of Technical Staff and engineering leader with 10+ years building AI agent platforms and production systems across Microsoft, Meta, Google, and growth-stage companies.",
     title: "Resume",
     url: "/resume",
     works: [
@@ -27,27 +26,22 @@
         url: "/resume",
       },
       {
-        detail: "Agentic execution",
+        detail: "Led engineering",
         label: "Copilot Tasks",
         url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/",
       },
       {
-        detail: "AI platform",
-        label: "Copilot SuperApp",
+        detail: "Foundational platform",
+        label: "AIX Harness / Copilot",
         url: "https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html",
       },
       {
-        detail: "Build 2026",
-        label: "Code",
+        detail: "Build 2026 products",
+        label: "Code & Autopilot",
         url: "https://news.microsoft.com/build-2026/",
       },
       {
-        detail: "Personal agent",
-        label: "Autopilot",
-        url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/",
-      },
-      {
-        detail: "Ads systems",
+        detail: "Full-stack ads systems",
         label: "Meta",
         url: "https://www.meta.com/",
       },
@@ -56,11 +50,20 @@
         label: "Google",
         url: "https://www.google.com/",
       },
+      {
+        detail: "Technical lead",
+        label: "Warner Music Group",
+        url: "https://www.wmg.com/",
+      },
+      {
+        detail: "Personal agent launch",
+        label: "Microsoft Scout",
+        url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/",
+      },
     ],
   },
   tools: {
-    copy: "Small, focused utilities for writing, media, and experimentation.",
-    kicker: "ITEM INVENTORY",
+    copy: "Useful browser tools backed by first-party C, WASM, media, and document-processing systems.",
     title: "Tools",
     url: "/tools",
     works: [
@@ -70,8 +73,7 @@
     ],
   },
   blog: {
-    copy: "Notes from building systems, games, web tools, and curious prototypes.",
-    kicker: "QUEST ARCHIVE",
+    copy: "Technical writing about networking, rendering, performance, developer tooling, and experiments.",
     title: "Blogs",
     url: "/blog",
     works: [
@@ -81,8 +83,12 @@
 });
 
 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id";
+const CONVERSATION_LEGACY_KEY = "mjj-jrpg-legacy-claim";
+const ARCHIVE_PAGE_SIZE = 20;
 const TYPING_INTERVAL_MS = 18;
 const SCRIPT_LOADS = new Map();
+const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const VALID_PANELS = Object.freeze(["resume", "tools", "blog", "conversations"]);
 const TOOL_ICON_NAMES = Object.freeze({
   "/notes": "repository",
   "/tools/file_converter": "retry",
@@ -598,7 +604,6 @@
     this.cleanupTool();
     const preview = PREVIEWS[selection] || PREVIEWS.resume;
     this.dataset.selection = selection;
-    this.querySelector("[data-preview-kicker]").textContent = preview.kicker;
     this.querySelector("[data-preview-title]").textContent = preview.title;
     this.querySelector("[data-preview-copy]").textContent = preview.copy;
     this.querySelector("[data-dialog-title]").textContent = preview.title;
@@ -1401,9 +1406,384 @@
   }
 }
 
-class MjjJrpgShell extends HTMLElement {}
+/* =====================================================================
+   MjjConversationArchive — archive panel within the scene
+   ===================================================================== */
+
+class MjjConversationArchive extends HTMLElement {
+  connectedCallback() {
+    this._list = this.querySelector("[data-archive-list]");
+    this._status = this.querySelector("[data-archive-status]");
+    this._loadMoreOwner = this.querySelector("[data-archive-load-more-owner]");
+    this._loadMoreButton = this.querySelector("[data-archive-load-more]");
+    this._newButton = this.querySelector("[data-archive-new]");
+    this._closeButton = this.querySelector("[data-archive-close]");
+    this._deleteDialog = this.querySelector("[data-archive-delete-dialog]");
+    this._deleteNameEl = this.querySelector("[data-archive-delete-name]");
+    this._deleteCancelButton = this.querySelector("[data-archive-delete-cancel]");
+    this._deleteConfirmButton = this.querySelector("[data-archive-delete-confirm]");
+    this._renameDialog = this.querySelector("[data-archive-rename-dialog]");
+    this._renameInput = this.querySelector("[data-archive-rename-input]");
+    this._renameForm = this.querySelector("[data-archive-rename-form]");
+    this._renameCancelButton = this.querySelector("[data-archive-rename-cancel]");
+    this._pendingDeleteId = null;
+    this._pendingDeleteTrigger = null;
+    this._pendingRenameId = null;
+    this._pendingRenameTrigger = null;
+
+    this._closeButton?.addEventListener("click", () => {
+      this.dispatchEvent(new CustomEvent("mjj-archive-toggle", { bubbles: true }));
+    });
+
+    this._newButton?.addEventListener("click", () => {
+      this.dispatchEvent(new CustomEvent("mjj-archive-new", { bubbles: true }));
+    });
+
+    this._loadMoreButton?.addEventListener("click", () => {
+      this.dispatchEvent(new CustomEvent("mjj-archive-load-more", { bubbles: true }));
+    });
+
+    this._deleteCancelButton?.addEventListener("click", () => {
+      this._deleteDialog?.close();
+      const t = this._pendingDeleteTrigger;
+      this._pendingDeleteId = null;
+      this._pendingDeleteTrigger = null;
+      t?.focus();
+    });
+
+    this._deleteConfirmButton?.addEventListener("click", () => {
+      const id = this._pendingDeleteId;
+      const trigger = this._pendingDeleteTrigger;
+      this._deleteDialog?.close();
+      this._pendingDeleteId = null;
+      this._pendingDeleteTrigger = null;
+      if (id) {
+        this.dispatchEvent(new CustomEvent("mjj-archive-delete", {
+          bubbles: true,
+          detail: { id, trigger },
+        }));
+      }
+    });
+
+    this._deleteDialog?.addEventListener("close", () => {
+      const t = this._pendingDeleteTrigger;
+      this._pendingDeleteId = null;
+      this._pendingDeleteTrigger = null;
+      t?.focus();
+    });
+
+    this._renameCancelButton?.addEventListener("click", () => {
+      this._renameDialog?.close();
+      const t = this._pendingRenameTrigger;
+      this._pendingRenameId = null;
+      this._pendingRenameTrigger = null;
+      t?.focus();
+    });
+
+    this._renameForm?.addEventListener("submit", event => {
+      event.preventDefault();
+      const id = this._pendingRenameId;
+      const trigger = this._pendingRenameTrigger;
+      const newTitle = this._renameInput?.value.trim();
+      if (!id || !newTitle) return;
+      this._renameDialog?.close();
+      this._pendingRenameId = null;
+      this._pendingRenameTrigger = null;
+      this.dispatchEvent(new CustomEvent("mjj-archive-rename", {
+        bubbles: true,
+        detail: { id, newTitle, trigger },
+      }));
+    });
+
+    this._renameDialog?.addEventListener("close", () => {
+      const t = this._pendingRenameTrigger;
+      this._pendingRenameId = null;
+      this._pendingRenameTrigger = null;
+      t?.focus();
+    });
+
+    this._onListClick = event => {
+      const openButton = event.target.closest("[data-conv-open]");
+      if (openButton) {
+        const id = openButton.closest("[data-conv-item]")?._convId;
+        if (id) {
+          this.dispatchEvent(new CustomEvent("mjj-archive-open", {
+            bubbles: true,
+            detail: { id },
+          }));
+        }
+        return;
+      }
+      const renameButton = event.target.closest("[data-conv-rename]");
+      if (renameButton) {
+        const item = renameButton.closest("[data-conv-item]");
+        if (item?._convId) {
+          this._pendingRenameId = item._convId;
+          this._pendingRenameTrigger = renameButton;
+          if (this._renameInput) this._renameInput.value = item._convTitle || "";
+          this._renameDialog?.showModal();
+          requestAnimationFrame(() => { this._renameInput?.select(); });
+        }
+        return;
+      }
+      const deleteButton = event.target.closest("[data-conv-delete]");
+      if (deleteButton) {
+        const item = deleteButton.closest("[data-conv-item]");
+        if (item?._convId) {
+          this._pendingDeleteId = item._convId;
+          this._pendingDeleteTrigger = deleteButton;
+          if (this._deleteNameEl) this._deleteNameEl.textContent = item._convTitle || "";
+          this._deleteDialog?.showModal();
+          requestAnimationFrame(() => { this._deleteConfirmButton?.focus(); });
+        }
+        return;
+      }
+    };
+    this._list?.addEventListener("click", this._onListClick);
+
+    this._onListKeydown = event => {
+      if (!["ArrowUp", "ArrowDown"].includes(event.key)) return;
+      const items = [...(this._list?.querySelectorAll("[data-conv-open]") || [])];
+      const index = items.indexOf(document.activeElement);
+      if (index < 0) return;
+      event.preventDefault();
+      const next = event.key === "ArrowDown"
+        ? items[Math.min(index + 1, items.length - 1)]
+        : items[Math.max(index - 1, 0)];
+      next?.focus();
+    };
+    this._list?.addEventListener("keydown", this._onListKeydown);
+  }
+
+  disconnectedCallback() {
+    this._list?.removeEventListener("click", this._onListClick);
+    this._list?.removeEventListener("keydown", this._onListKeydown);
+  }
+
+  setConversations(conversations, cursor, append = false) {
+    if (!this._list) return;
+    if (!append) this._list.replaceChildren();
+    for (const conv of conversations) this._list.append(this._createItem(conv));
+    if (this._loadMoreOwner) this._loadMoreOwner.hidden = !cursor;
+    if (!conversations.length && !append) {
+      this._showStatus("No conversations yet. Start a new one!");
+    } else {
+      this._hideStatus();
+    }
+  }
+
+  setCurrentId(id) {
+    for (const button of (this._list?.querySelectorAll("[data-conv-open]") || [])) {
+      const isCurrent = id && button.closest("[data-conv-item]")?._convId === id;
+      button.setAttribute("aria-current", String(Boolean(isCurrent)));
+    }
+  }
+
+  addConversation(conv) {
+    if (!this._list) return;
+    this._list.prepend(this._createItem(conv));
+    this._hideStatus();
+  }
+
+  updateConversation(conv) {
+    if (!this._list) return;
+    for (const item of this._list.querySelectorAll("[data-conv-item]")) {
+      if (item._convId === conv.id) {
+        const titleEl = item.querySelector("[data-conv-title]");
+        const metaEl = item.querySelector("[data-conv-meta]");
+        if (titleEl) titleEl.textContent = conv.title;
+        if (metaEl) metaEl.textContent = this._formatMeta(conv);
+        item._convTitle = conv.title;
+        return;
+      }
+    }
+    this.addConversation(conv);
+  }
+
+  removeConversation(id) {
+    for (const item of (this._list?.querySelectorAll("[data-conv-item]") || [])) {
+      if (item._convId === id) {
+        item.remove();
+        break;
+      }
+    }
+    if (!this._list?.querySelector("[data-conv-item]")) {
+      this._showStatus("No conversations yet. Start a new one!");
+    }
+  }
+
+  setLoading(loading) {
+    if (loading) {
+      this._showStatus("Loading conversations...");
+    } else if (!this._list?.querySelector("[data-conv-item]")) {
+      this._showStatus("No conversations yet. Start a new one!");
+    } else {
+      this._hideStatus();
+    }
+  }
+
+  setError(message) {
+    this._showStatus(message);
+    if (this._status) this._status.dataset.state = "error";
+  }
+
+  setLoadMoreDisabled(disabled) {
+    if (this._loadMoreButton) this._loadMoreButton.disabled = disabled;
+  }
+
+  /* Disable/enable destructive archive controls while a stream is active. */
+  setStreamActive(active) {
+    const controls = this._list?.querySelectorAll(
+      "[data-conv-delete], [data-conv-rename]",
+    ) || [];
+    for (const el of controls) el.disabled = active;
+    if (this._newButton) this._newButton.disabled = active;
+    if (this._deleteConfirmButton) this._deleteConfirmButton.disabled = active;
+    this.dataset.streamActive = active ? "true" : "";
+    if (!active) delete this.dataset.streamActive;
+  }
+
+  /* Return the open-button of the adjacent surviving item, or New button. */
+  adjacentItemOrNewButton(id) {
+    const items = [...(this._list?.querySelectorAll("[data-conv-item]") || [])];
+    const idx = items.findIndex(el => el._convId === id);
+    if (idx < 0) return this._newButton;
+    const sibling = items[idx + 1] || items[idx - 1];
+    return sibling?.querySelector("[data-conv-open]") || this._newButton;
+  }
+
+  showLegacyClaim(statusMessage = null, showButton = true) {
+    if (!this._status) return;
+    this._status.hidden = false;
+    delete this._status.dataset.state;
+    this._status.textContent = "";
+    const msg = document.createElement("span");
+    msg.textContent = statusMessage ?? "You have a prior conversation. ";
+    this._status.append(msg);
+    if (showButton) {
+      const btn = document.createElement("button");
+      btn.type = "button";
+      btn.textContent = "Claim it";
+      btn.dataset.archiveClaim = "";
+      btn.addEventListener("click", () => {
+        this.dispatchEvent(new CustomEvent("mjj-archive-claim", { bubbles: true }));
+      });
+      this._status.append(btn);
+    }
+  }
+
+  _showStatus(text) {
+    if (this._status) {
+      this._status.textContent = text;
+      this._status.hidden = false;
+      delete this._status.dataset.state;
+    }
+  }
+
+  _hideStatus() {
+    if (this._status) this._status.hidden = true;
+  }
+
+  _createItem(conv) {
+    const item = document.createElement("li");
+    item.dataset.convItem = "";
+    item._convId = conv.id;
+    item._convTitle = conv.title;
+
+    const openButton = document.createElement("button");
+    openButton.type = "button";
+    openButton.dataset.convOpen = "";
+    openButton.setAttribute("aria-current", "false");
+
+    const titleEl = document.createElement("span");
+    titleEl.dataset.convTitle = "";
+    titleEl.textContent = conv.title;
+
+    const metaEl = document.createElement("small");
+    metaEl.dataset.convMeta = "";
+    metaEl.textContent = this._formatMeta(conv);
+
+    openButton.append(titleEl, metaEl);
+
+    const actions = document.createElement("div");
+    actions.className = "jrpg-archive-item-actions";
+
+    const renameButton = document.createElement("button");
+    renameButton.type = "button";
+    renameButton.dataset.convRename = "";
+    renameButton.setAttribute("aria-label", `Rename "${conv.title}"`);
+    renameButton.textContent = "Rename";
+
+    const deleteButton = document.createElement("button");
+    deleteButton.type = "button";
+    deleteButton.dataset.convDelete = "";
+    deleteButton.setAttribute("aria-label", `Delete "${conv.title}"`);
+    deleteButton.textContent = "Delete";
+
+    actions.append(renameButton, deleteButton);
+    item.append(openButton, actions);
+    return item;
+  }
+
+  _formatMeta(conv) {
+    const count = conv.turn_count ?? 0;
+    const turns = count === 1 ? "1 turn" : `${count} turns`;
+    if (conv.last_message_preview) {
+      return `${turns} · ${conv.last_message_preview.slice(0, 28)}`;
+    }
+    return turns;
+  }
+}
+
+class MjjJrpgShell extends HTMLElement {
+  connectedCallback() {
+    this._mobileMenuQuery = window.matchMedia(
+      "(max-width: 52rem) and (orientation: portrait)",
+    );
+    this._syncMobileMenu = expanded => {
+      const button = this.querySelector("[data-mobile-menu-toggle]");
+      const menu = this.querySelector("mjj-jrpg-menu");
+      if (!button || !menu) return;
+      if (!this._mobileMenuQuery.matches) {
+        button.setAttribute("aria-expanded", "true");
+        menu.removeAttribute("data-mobile-menu-collapsed");
+        return;
+      }
+      button.setAttribute("aria-expanded", String(expanded));
+      menu.toggleAttribute("data-mobile-menu-collapsed", !expanded);
+    };
+    this._onMenuToggle = event => {
+      const button = event.target.closest("[data-mobile-menu-toggle]");
+      if (!button || !this.contains(button)) return;
+      const expanded = button.getAttribute("aria-expanded") === "true";
+      this._syncMobileMenu(!expanded);
+      if (!expanded) {
+        const firstItem = this.querySelector(
+          "mjj-jrpg-menu button[data-preview]",
+        );
+        if (firstItem) firstItem.focus();
+      }
+    };
+    this._onMobileMenuMediaChange = () => this._syncMobileMenu(true);
+    this.addEventListener("click", this._onMenuToggle);
+    this._mobileMenuQuery.addEventListener(
+      "change",
+      this._onMobileMenuMediaChange,
+    );
+    this._syncMobileMenu(true);
+  }
+
+  disconnectedCallback() {
+    this.removeEventListener("click", this._onMenuToggle);
+    this._mobileMenuQuery?.removeEventListener(
+      "change",
+      this._onMobileMenuMediaChange,
+    );
+  }
+}
 
 for (const [name, constructor] of Object.entries({
+  "mjj-conversation-archive": MjjConversationArchive,
   "mjj-jrpg-character": MjjJrpgCharacter,
   "mjj-jrpg-chat": MjjJrpgChat,
   "mjj-jrpg-composer": MjjJrpgComposer,
@@ -1417,6 +1797,7 @@
 async function requestJson(url, options = {}) {
   const response = await fetch(url, {
     ...options,
+    credentials: "same-origin",
     headers: {
       "Content-Type": "application/json",
       ...options.headers,
@@ -1435,6 +1816,102 @@
   return response.status === 204 ? null : response.json();
 }
 
+/* Session state for CSRF: bootstrapped once, refreshed at most once on 401/403 */
+let _csrfToken = null;
+let _sessionData = null;
+let _sessionRefreshInFlight = null; /* single-flight guard */
+
+function _principalFingerprint(data) {
+  if (!data) return null;
+  if (data.kind === "user") return `user:${data.username || ""}:${data.role || ""}`;
+  if (data.kind === "guest") return `guest:${data.csrfToken || ""}`;
+  return null;
+}
+
+async function _fetchSession() {
+  try {
+    const response = await fetch("/api/auth/session", {
+      credentials: "same-origin",
+    });
+    if (!response.ok) return null;
+    const data = await response.json().catch(() => null);
+    if (!data) return null;
+    _sessionData = data;
+    return data.csrfToken || null;
+  } catch {
+    return null;
+  }
+}
+
+/* Single-flight session refresh: dedupe concurrent callers. */
+async function _refreshSessionOnce() {
+  if (_sessionRefreshInFlight) return _sessionRefreshInFlight;
+  _sessionRefreshInFlight = (async () => {
+    const prev = _principalFingerprint(_sessionData);
+    const token = await _fetchSession();
+    const next = _principalFingerprint(_sessionData);
+    return { token, prev, next };
+  })();
+  try {
+    return await _sessionRefreshInFlight;
+  } finally {
+    _sessionRefreshInFlight = null;
+  }
+}
+
+/*
+ * Perform a state-changing (mutating) fetch with CSRF token and same-origin
+ * credentials.  On 401 or CSRF-specific 403 (csrf_invalid), refreshes the
+ * session once and retries — but only if the refreshed principal matches the
+ * original.  If the user becomes a guest or a different account, rejects and
+ * triggers the session-changed callback.
+ */
+let _sessionRefreshCallback = null;
+
+async function mutatingFetch(url, options = {}) {
+  const buildHeaders = () => ({
+    ...(options.headers || {}),
+    Origin: window.location.origin,
+    ...(_csrfToken ? { "X-CSRF-Token": _csrfToken } : {}),
+  });
+
+  const response = await fetch(url, {
+    ...options,
+    credentials: "same-origin",
+    headers: buildHeaders(),
+  });
+
+  const shouldRefresh = response.status === 401 ||
+    (response.status === 403 && await (async () => {
+      try {
+        const clone = response.clone();
+        const body = await clone.json();
+        return body?.error?.code === "csrf_invalid";
+      } catch { return false; }
+    })());
+
+  if (shouldRefresh) {
+    const prevFingerprint = _principalFingerprint(_sessionData);
+    const { token, next } = await _refreshSessionOnce();
+    _csrfToken = token;
+    if (next !== prevFingerprint) {
+      if (_sessionRefreshCallback) _sessionRefreshCallback();
+      const err = Object.assign(new Error("Session changed"), { status: 401 });
+      throw err;
+    }
+    const retried = await fetch(url, {
+      ...options,
+      credentials: "same-origin",
+      headers: buildHeaders(),
+    });
+    if (retried.status === 401 && _sessionRefreshCallback) {
+      _sessionRefreshCallback();
+    }
+    return retried;
+  }
+  return response;
+}
+
 async function consumeSse(response, onEvent) {
   if (!response.body) throw new Error("Streaming response body is unavailable");
   const reader = response.body.getReader();
@@ -1467,6 +1944,132 @@
   if (buffer.trim()) dispatch(buffer);
 }
 
+/* =====================================================================
+   Account slot rendering
+   ===================================================================== */
+
+function renderAccountSlot(slotEl, sessionData) {
+  if (!slotEl) return;
+  slotEl.textContent = "";
+  if (!sessionData || sessionData.kind === "guest") {
+    const owner = document.createElement("zen-button");
+    const btn = document.createElement("button");
+    owner.setAttribute("appearance", "plain");
+    owner.setAttribute("size", "xs");
+    btn.type = "button";
+    btn.dataset.loginOpen = "";
+    btn.textContent = "LOGIN";
+    owner.append(btn);
+    slotEl.setAttribute("aria-label", "Not signed in");
+    slotEl.append(owner);
+    return;
+  }
+  const username = (sessionData.username || "").slice(0, 8).toUpperCase();
+  if (sessionData.mustChangePassword) {
+    const link = document.createElement("a");
+    link.href = "/account/password";
+    link.textContent = username ? `${username}\u26A0` : "PASSWD";
+    link.title = "Password change required";
+    slotEl.setAttribute("aria-label", `${sessionData.username}: password change required`);
+    slotEl.append(link);
+    return;
+  }
+  const nameSpan = document.createElement("span");
+  nameSpan.dataset.accountUser = "";
+  nameSpan.textContent = username;
+  slotEl.append(nameSpan);
+  if (sessionData.role === "admin") {
+    const adminLink = document.createElement("a");
+    adminLink.href = "/admin/users";
+    adminLink.textContent = "\u00A0ADM";
+    slotEl.append(adminLink);
+  }
+  const logoutBtn = document.createElement("button");
+  logoutBtn.type = "button";
+  logoutBtn.textContent = "\u00A0LGOUT";
+  logoutBtn.addEventListener("click", () => {
+    logoutBtn.disabled = true;
+    mutatingFetch("/api/auth/logout", { method: "POST" }).then(response => {
+      if (!response.ok) throw new Error(`Logout failed (${response.status})`);
+      window.dispatchEvent(new CustomEvent("mjj-logout"));
+    }).catch(() => {
+      logoutBtn.disabled = false;
+    });
+  });
+  slotEl.append(logoutBtn);
+  slotEl.setAttribute("aria-label", `Signed in as ${sessionData.username}`);
+}
+
+/* =====================================================================
+   Quota display helpers
+   ===================================================================== */
+
+function formatQuotaText(quota) {
+  if (!quota) return null;
+  const turns = quota.turnsRemaining ?? 0;
+  const tokens = quota.outputTokensRemaining ?? 0;
+  if (turns <= 0) {
+    const resetAt = quota.resetsAt ? new Date(quota.resetsAt * 1000) : null;
+    const resetStr = resetAt
+      ? ` Resets ${resetAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
+      : "";
+    return { text: `Quota exhausted.${resetStr}`, state: "exhausted" };
+  }
+  if (turns <= 2) {
+    return {
+      text: `${turns} turn${turns === 1 ? "" : "s"} remaining (${tokens.toLocaleString()} tokens)`,
+      state: "warning",
+    };
+  }
+  return {
+    text: `${turns} turns remaining (${tokens.toLocaleString()} tokens)`,
+    state: "ok",
+  };
+}
+
+function updateQuotaDisplay(quotaEl, quota) {
+  if (!quotaEl) return;
+  if (!quota) {
+    quotaEl.hidden = true;
+    return;
+  }
+  const result = formatQuotaText(quota);
+  /* Only show the bar for low or exhausted quota; hide when turns are plentiful */
+  if (!result || result.state === "ok") {
+    quotaEl.hidden = true;
+    return;
+  }
+  quotaEl.textContent = result.text;
+  if (result.state === "exhausted") {
+    quotaEl.dataset.state = "exhausted";
+  } else if (result.state === "warning") {
+    quotaEl.dataset.state = "warning";
+  } else {
+    delete quotaEl.dataset.state;
+  }
+  quotaEl.hidden = false;
+}
+
+/* =====================================================================
+   Conversation archive helpers
+   ===================================================================== */
+
+async function fetchConversationList(cursor) {
+  const url = cursor
+    ? `/api/conversations?limit=${ARCHIVE_PAGE_SIZE}&cursor=${encodeURIComponent(cursor)}`
+    : `/api/conversations?limit=${ARCHIVE_PAGE_SIZE}`;
+  const response = await fetch(url, { credentials: "same-origin" });
+  if (!response.ok) {
+    let message = `Failed to load conversations (${response.status})`;
+    try {
+      const body = await response.json();
+      message = body.error?.message || message;
+    } catch { /* keep status message */ }
+    throw new Error(message);
+  }
+  return response.json();
+}
+
 async function initializeJrpgPage() {
   const colorProbe = document.createElement("span");
   colorProbe.style.background = "var(--zenbu-sys-color-surface-page)";
@@ -1477,19 +2080,46 @@
     meta.setAttribute("content", themeColor);
   }
 
+  /* Bootstrap session to obtain CSRF token and session data */
+  _csrfToken = await _fetchSession();
+
   const shell = document.querySelector("mjj-jrpg-shell");
   const character = shell?.querySelector("mjj-jrpg-character");
   const chat = shell?.querySelector("mjj-jrpg-chat");
   const composer = shell?.querySelector("mjj-jrpg-composer");
   const preview = shell?.querySelector("mjj-jrpg-preview");
+  const archive = shell?.querySelector("mjj-conversation-archive");
+  const accountSlot = shell?.querySelector("[data-frame-account]");
+  const quotaEl = shell?.querySelector("[data-quota]");
   const frameNetwork = shell?.querySelector("[data-frame-network]");
   const frameUptime = shell?.querySelector("[data-frame-uptime]");
   const minimizeButton = shell?.querySelector("[data-frame-minimize]");
   const fullscreenButton = shell?.querySelector("[data-frame-fullscreen]");
+
   let characterTimer = 0;
-  let conversationId = sessionStorage.getItem(CONVERSATION_STORAGE_KEY);
+  let currentConversationId = null;
+  let currentPanel = "resume";
+  let archiveCursor = null;
   let activeController = null;
+  let archiveLoadInFlight = false;  /* serialize pagination requests */
+  let openSeq = 0;                  /* sequence guard for conversation open */
+  let popSeq = 0;                   /* sequence guard for popstate */
+  let _pendingPopState = null;      /* queued popstate received during active stream */
+  let _claimInFlight = false;       /* guard against duplicate concurrent claims */
+  let legacyClaimId = sessionStorage.getItem(CONVERSATION_LEGACY_KEY);
+  let principalEpoch = 0;           /* rejects responses from a prior owner */
+  /* Dedupe conversation IDs: set of IDs currently rendered in archive */
+  const _renderedIds = new Set();
+  const invalidatePrincipalRequests = () => {
+    principalEpoch += 1;
+    openSeq += 1;
+    popSeq += 1;
+    archiveCursor = null;
+  };
+
+  /* Disable composer until fully initialized */
   composer?.setDisabled(true);
+
   const setFrameStatus = (label, state, title = "") => {
     if (frameNetwork) {
       frameNetwork.textContent = label;
@@ -1498,7 +2128,16 @@
       frameNetwork.title = title;
     }
   };
+
+  /* Bootstrap failure: keep controls disabled and expose retry */
+  if (!_csrfToken) {
+    setFrameStatus("OFFLINE", "offline", "Bootstrap failed");
+    archive?.setError("Connection failed. Reload to retry.");
+    return;
+  }
+
   setFrameStatus("ONLINE", "online");
+
   const startedAt = Date.now();
   const updateUptime = () => {
     const elapsed = Math.floor((Date.now() - startedAt) / 1000);
@@ -1536,21 +2175,705 @@
     );
   });
 
-  if (conversationId) {
+  /* Render account slot */
+  renderAccountSlot(accountSlot, _sessionData);
+
+  /* ---- Login modal ---- */
+  const loginDialogOwner = shell?.querySelector("[data-login-dialog-owner]");
+  const loginDialog = loginDialogOwner?.querySelector("[data-login-dialog]");
+  const loginForm = loginDialog?.querySelector("[data-login-form]");
+  const loginSubmitBtn = loginDialog?.querySelector("[data-login-submit]");
+  const loginCancelBtn = loginDialog?.querySelector("[data-login-cancel]");
+  const loginErrorEl = loginDialog?.querySelector("[data-login-error]");
+  const loginUsernameInput = loginDialog?.querySelector("#jrpg-login-username");
+  const loginPasswordInput = loginDialog?.querySelector("#jrpg-login-password");
+  let _loginTrigger = null;
+  const setAccountActionsDisabled = disabled => {
+    for (const control of accountSlot?.querySelectorAll("button") || []) {
+      control.disabled = disabled;
+    }
+  };
+
+  const _clearLoginError = () => {
+    if (loginErrorEl) {
+      loginErrorEl.textContent = "";
+      loginErrorEl.hidden = true;
+    }
+  };
+
+  const _showLoginError = (message) => {
+    if (!loginErrorEl) return;
+    loginErrorEl.textContent = message;
+    loginErrorEl.hidden = false;
+  };
+
+  const openLoginModal = (triggerEl) => {
+    if (!loginDialogOwner || !loginDialog) return;
+    _loginTrigger = triggerEl || null;
+    _clearLoginError();
+    loginForm?.reset();
+    loginDialogOwner.open();
+    requestAnimationFrame(() => { loginUsernameInput?.focus(); });
+  };
+
+  /* Delegate clicks on data-login-open in the account slot */
+  accountSlot?.addEventListener("click", event => {
+    const btn = event.target.closest("[data-login-open]");
+    if (!btn || activeController) return;
+    openLoginModal(btn);
+  });
+
+  /* Cancel button */
+  loginCancelBtn?.addEventListener("click", () => { loginDialog?.close(); });
+
+  /* Cleanup on any close (Escape, X button, cancel) */
+  loginDialog?.addEventListener("close", () => {
+    if (loginPasswordInput) loginPasswordInput.value = "";
+    _clearLoginError();
+    const trigger = _loginTrigger;
+    _loginTrigger = null;
+    requestAnimationFrame(() => { trigger?.focus(); });
+  });
+
+  /* Login form submission */
+  loginForm?.addEventListener("submit", async event => {
+    event.preventDefault();
+    if (loginSubmitBtn?.disabled) return;
+    const username = loginUsernameInput?.value.trim() || "";
+    const password = loginPasswordInput?.value || "";
+    if (!username || !password) {
+      _showLoginError("Please enter your username and password.");
+      return;
+    }
+    if (loginSubmitBtn) loginSubmitBtn.disabled = true;
+    _clearLoginError();
     try {
-      const conversation = await requestJson(
-        `/api/conversations/${encodeURIComponent(conversationId)}`,
-        { headers: {} },
+      const response = await fetch("/api/auth/login", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+          "Origin": window.location.origin,
+        },
+        credentials: "same-origin",
+        body: JSON.stringify({ username, password, csrfToken: _csrfToken }),
+      });
+      if (loginPasswordInput) loginPasswordInput.value = "";
+      if (response.status === 429) {
+        _showLoginError("Too many attempts. Please try again later.");
+        return;
+      }
+      if (!response.ok) {
+        _showLoginError("Invalid username or password.");
+        return;
+      }
+      let data = await response.json().catch(() => null);
+      if (!data) {
+        _csrfToken = await _fetchSession();
+        if (_sessionData?.kind !== "user") {
+          loginDialog?.close();
+          window.location.reload();
+          return;
+        }
+        data = _sessionData;
+      }
+      invalidatePrincipalRequests();
+      loginDialog?.close();
+      _sessionData = {
+        kind: "user",
+        username: data.username || username,
+        role: data.role || "member",
+        mustChangePassword: Boolean(data.mustChangePassword),
+        csrfToken: data.csrfToken,
+        quota: null,
+      };
+      _csrfToken = data.csrfToken || null;
+      if (data.mustChangePassword) {
+        window.location.href = "/account/password";
+        return;
+      }
+      /* Commit the returned principal before optional UI refresh work. */
+      renderAccountSlot(accountSlot, _sessionData);
+      updateQuotaDisplay(quotaEl, _sessionData?.quota || null);
+      composer?.setDisabled(false);
+      /* Clear conversation state — guest convs may transfer to user */
+      currentConversationId = null;
+      sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+      sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
+      legacyClaimId = null;
+      _replaceUrlState(currentPanel, null);
+      chat?.replaceMessages([]);
+      try {
+        await refreshArchiveFromScratch();
+      } catch (error) {
+        archive?.setError(`Signed in, but archive refresh failed: ${error.message}`);
+      }
+      requestAnimationFrame(() => {
+        accountSlot?.querySelector("button, a")?.focus();
+      });
+    } catch {
+      if (loginPasswordInput) loginPasswordInput.value = "";
+      _showLoginError("Sign-in failed. Please try again.");
+    } finally {
+      if (loginSubmitBtn) loginSubmitBtn.disabled = false;
+    }
+  });
+
+  /* Handle logout */
+  _sessionRefreshCallback = () => {
+    /* Account changed or session lost — clear active state */
+    invalidatePrincipalRequests();
+    if (activeController) {
+      activeController.abort();
+      activeController = null;
+    }
+    currentConversationId = null;
+    sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+    sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
+    legacyClaimId = null;
+    _replaceUrlState(currentPanel, null);
+    chat?.replaceMessages([]);
+    _renderedIds.clear();
+    archiveCursor = null;
+    archive?.setConversations([], null);
+    renderAccountSlot(accountSlot, _sessionData);
+    archive?.setError("Session changed. Loading conversations...");
+    void refreshArchiveFromScratch();
+  };
+  window.addEventListener("mjj-logout", () => {
+    invalidatePrincipalRequests();
+    if (activeController) {
+      activeController.abort();
+      activeController = null;
+    }
+    archive?.setStreamActive(false);
+    currentConversationId = null;
+    sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+    sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
+    legacyClaimId = null;
+    _replaceUrlState(currentPanel, null);
+    chat?.replaceMessages([]);
+    _renderedIds.clear();
+    archiveCursor = null;
+    archive?.setConversations([], null);
+    void refreshArchiveFromScratch();
+    void (async () => {
+      _csrfToken = await _fetchSession();
+      renderAccountSlot(accountSlot, _sessionData);
+      updateQuotaDisplay(quotaEl, _sessionData?.quota || null);
+      if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) {
+        composer?.setDisabled(true);
+      } else {
+        composer?.setDisabled(false);
+      }
+    })();
+  });
+
+  /* Session quota display */
+  if (_sessionData?.kind === "guest") {
+    updateQuotaDisplay(quotaEl, _sessionData.quota || null);
+  }
+
+  /* Disable composer for forced-password-change */
+  const isForced = _sessionData?.kind === "user" && _sessionData?.mustChangePassword;
+
+  /* ---- URL state helpers ---- */
+  const _buildStateUrl = (panel, convId) => {
+    const url = new URL(window.location.href);
+    if (panel && VALID_PANELS.includes(panel)) {
+      url.searchParams.set("panel", panel);
+    } else {
+      url.searchParams.delete("panel");
+    }
+    if (convId && UUID_REGEX.test(convId)) {
+      url.searchParams.set("conversation", convId);
+    } else {
+      url.searchParams.delete("conversation");
+    }
+    return url;
+  };
+
+  const _pushUrlState = (panel, convId) => {
+    history.pushState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId));
+  };
+
+  const _replaceUrlState = (panel, convId) => {
+    history.replaceState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId));
+  };
+
+  /* ---- Panel selection: shows archive or preview in utility ---- */
+  const utilityEl = shell?.querySelector(".jrpg-utility");
+
+  const selectPanel = (panel, pushHistory = true) => {
+    const validPanel = VALID_PANELS.includes(panel) ? panel : "resume";
+    currentPanel = validPanel;
+    for (const btn of (shell?.querySelectorAll("button[data-preview]") || [])) {
+      btn.setAttribute("aria-pressed", String(btn.dataset.preview === validPanel));
+    }
+    if (validPanel === "conversations") {
+      if (archive) archive.hidden = false;
+      if (preview) preview.hidden = true;
+      if (utilityEl) utilityEl.setAttribute("aria-label", "Conversations");
+    } else {
+      if (archive) archive.hidden = true;
+      if (preview) preview.hidden = false;
+      preview?.show(validPanel);
+      if (utilityEl) utilityEl.setAttribute("aria-label", "Selected destination");
+    }
+    if (pushHistory) _pushUrlState(validPanel, currentConversationId);
+  };
+
+  /* ---- Archive close button: navigate back to resume panel ---- */
+  shell?.addEventListener("mjj-archive-toggle", () => {
+    selectPanel("resume");
+  });
+
+  shell?.addEventListener("mjj-archive-new", () => {
+    if (activeController) return;
+    currentConversationId = null;
+    sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+    _pushUrlState(currentPanel, null);
+    chat?.replaceMessages([]);
+    archive?.setCurrentId(null);
+    composer?.querySelector("textarea")?.focus();
+  });
+
+  /* ---- Popstate: re-apply panel and conversation on back/forward ---- */
+  /* _applyPopState always increments openSeq so any pending archive-open
+     fetch (including a transition to no conversation) cannot overwrite the
+     state the user navigated to (Fix 2). */
+  const _applyPopState = async (seq, popPanel, popConvId) => {
+    const localSeq = ++openSeq;
+    const epoch = principalEpoch;
+
+    selectPanel(popPanel, false);
+
+    if (popConvId && popConvId !== currentConversationId) {
+      try {
+        const conversation = await requestJson(`/api/conversations/${encodeURIComponent(popConvId)}`);
+        if (seq !== popSeq || localSeq !== openSeq ||
+            epoch !== principalEpoch) return;
+        currentConversationId = popConvId;
+        sessionStorage.setItem(CONVERSATION_STORAGE_KEY, popConvId);
+        chat?.replaceMessages(conversation.turns);
+        archive?.setCurrentId(popConvId);
+      } catch {
+        if (seq !== popSeq || localSeq !== openSeq ||
+            epoch !== principalEpoch) return;
+        currentConversationId = null;
+        sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+        chat?.replaceMessages([]);
+        archive?.setCurrentId(null);
+        history.replaceState(
+          { panel: popPanel, conversation: null },
+          "",
+          _buildStateUrl(popPanel, null),
+        );
+      }
+    } else if (!popConvId && currentConversationId) {
+      currentConversationId = null;
+      sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+      chat?.replaceMessages([]);
+      archive?.setCurrentId(null);
+    }
+  };
+
+  /* When popstate fires while a stream is active the URL has already changed.
+     Queue the parsed destination, abort the stream, and apply exactly once in
+     the stream's finally block so URL and UI always converge (Fix 1). */
+  window.addEventListener("popstate", async event => {
+    const seq = ++popSeq;
+    ++openSeq;
+    const state = event.state || {};
+    const popPanel = VALID_PANELS.includes(state.panel) ? state.panel : "resume";
+    const popConvId = (state.conversation && UUID_REGEX.test(state.conversation))
+      ? state.conversation : null;
+
+    if (activeController) {
+      _pendingPopState = { seq, popPanel, popConvId };
+      activeController.abort();
+      return;
+    }
+
+    await _applyPopState(seq, popPanel, popConvId);
+  });
+
+  shell?.addEventListener("mjj-archive-rename", async event => {
+    const { id, newTitle, trigger } = event.detail;
+    if (activeController) { trigger?.focus(); return; }
+    try {
+      const response = await mutatingFetch(
+        `/api/conversations/${encodeURIComponent(id)}`,
+        {
+          method: "PATCH",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ title: newTitle }),
+        },
       );
+      if (!response.ok) {
+        const body = await response.json().catch(() => ({}));
+        throw new Error(body.error?.message || `Rename failed (${response.status})`);
+      }
+      archive?.updateConversation({ id, title: newTitle, turn_count: null });
+    } catch (error) {
+      archive?.setError(`Rename failed: ${error.message}`);
+    } finally {
+      trigger?.focus();
+    }
+  });
+
+  shell?.addEventListener("mjj-archive-open", async event => {
+    const { id } = event.detail;
+    if (id === currentConversationId) return;
+    if (activeController) return;
+    const seq = ++openSeq;
+    const epoch = principalEpoch;
+    try {
+      const conversation = await requestJson(`/api/conversations/${encodeURIComponent(id)}`);
+      if (seq !== openSeq || epoch !== principalEpoch) return;
+      currentConversationId = id;
+      sessionStorage.setItem(CONVERSATION_STORAGE_KEY, id);
+      _pushUrlState(currentPanel, id);
       chat?.replaceMessages(conversation.turns);
+      archive?.setCurrentId(id);
+    } catch (error) {
+      if (seq !== openSeq || epoch !== principalEpoch) return;
+      archive?.setError(`Unable to open: ${error.message}`);
+    }
+  });
+
+  shell?.addEventListener("mjj-archive-delete", async event => {
+    const { id, trigger } = event.detail;
+    if (activeController) { trigger?.focus(); return; } /* block while streaming */
+    try {
+      const response = await mutatingFetch(
+        `/api/conversations/${encodeURIComponent(id)}`,
+        { method: "DELETE" },
+      );
+      if (response.ok || response.status === 404) {
+        /* Focus adjacent archive item or New button before removing the node */
+        const focusTarget = archive?.adjacentItemOrNewButton(id);
+        _renderedIds.delete(id);
+        archive?.removeConversation(id);
+        if (id === currentConversationId) {
+          currentConversationId = null;
+          sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+          _replaceUrlState(currentPanel, null);
+          chat?.replaceMessages([]);
+        }
+        /* Focus surviving element — never the detached trigger */
+        requestAnimationFrame(() => {
+          (focusTarget || archive?.querySelector("[data-archive-new]"))?.focus();
+        });
+      } else {
+        const body = await response.json().catch(() => ({}));
+        archive?.setError(`Delete failed: ${body.error?.message || response.status}`);
+      }
+    } catch (error) {
+      archive?.setError(`Delete failed: ${error.message}`);
+      /* On error, the trigger is still attached — focus it */
+      trigger?.focus();
+    }
+  });
+
+  shell?.addEventListener("mjj-archive-load-more", async () => {
+    if (!archiveCursor || archiveLoadInFlight) return;
+    archiveLoadInFlight = true;
+    const epoch = principalEpoch;
+    archive?.setLoadMoreDisabled(true);
+    try {
+      const data = await fetchConversationList(archiveCursor);
+      if (epoch !== principalEpoch) return;
+      archiveCursor = data.cursor || null;
+      const newConvs = (data.conversations || []).filter(c => !_renderedIds.has(c.id));
+      newConvs.forEach(c => _renderedIds.add(c.id));
+      archive?.setConversations(newConvs, archiveCursor, true);
+    } catch (error) {
+      archive?.setError(`Load failed: ${error.message}`);
+    } finally {
+      archiveLoadInFlight = false;
+      archive?.setLoadMoreDisabled(false);
+    }
+  });
+
+  shell?.addEventListener("mjj-archive-claim", async () => {
+    const legacyId =
+      legacyClaimId || sessionStorage.getItem(CONVERSATION_LEGACY_KEY);
+    if (!legacyId || _sessionData?.kind !== "user" || _claimInFlight) return;
+    _claimInFlight = true;
+    archive?.showLegacyClaim("Claiming prior conversation...", false);
+    let _claimOutcome = null;
+    try {
+      const response = await mutatingFetch(
+        "/api/conversations/claim",
+        {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ conversationId: legacyId }),
+        },
+      );
+      if (response.ok) {
+        sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
+        legacyClaimId = null;
+        _claimOutcome = "success";
+      } else if (response.status === 409) {
+        /* Definitive non-legacy conflict: clear key, no retry needed */
+        sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
+        legacyClaimId = null;
+        _claimOutcome = "conflict";
+      } else {
+        /* Retryable (401, 403, 429, 5xx): retain key */
+        sessionStorage.setItem(CONVERSATION_LEGACY_KEY, legacyId);
+        legacyClaimId = legacyId;
+        _claimOutcome = "retry";
+      }
     } catch {
-      sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
-      conversationId = null;
+      /* Network error: retain key */
+      sessionStorage.setItem(CONVERSATION_LEGACY_KEY, legacyId);
+      legacyClaimId = legacyId;
+      _claimOutcome = "network-error";
+    } finally {
+      /* Reset guard BEFORE re-rendering so the retry button is immediately usable */
+      _claimInFlight = false;
+      if (_claimOutcome === "success") {
+        void refreshArchiveFromScratch();
+      } else if (_claimOutcome === "conflict") {
+        archive?.showLegacyClaim("Prior conversation already claimed.", false);
+      } else if (_claimOutcome === "retry") {
+        selectPanel("conversations", false);
+        archive?.showLegacyClaim("Claim failed. Try again. ");
+      } else if (_claimOutcome === "network-error") {
+        selectPanel("conversations", false);
+        archive?.showLegacyClaim("Claim failed. Check connection. ");
+      }
+    }
+  });
+
+  async function refreshArchiveFromScratch() {
+    if (!archive) return;
+    const epoch = principalEpoch;
+    archive.setLoading(true);
+    _renderedIds.clear();
+    try {
+      const data = await fetchConversationList(null);
+      if (epoch !== principalEpoch) return;
+      archiveCursor = data.cursor || null;
+      const convs = data.conversations || [];
+      convs.forEach(c => _renderedIds.add(c.id));
+      archive.setConversations(convs, archiveCursor);
+      archive.setCurrentId(currentConversationId);
+    } catch (error) {
+      archive.setError(`Archive error: ${error.message}`);
     }
   }
 
+  async function refreshArchiveHead() {
+    if (!archive) return;
+    const epoch = principalEpoch;
+    try {
+      const data = await fetchConversationList(null);
+      if (epoch !== principalEpoch) return;
+      const fresh = data.conversations || [];
+      for (const conv of fresh) archive.updateConversation(conv);
+      if (fresh.length > 0) archive.setCurrentId(currentConversationId);
+    } catch { /* silent refresh failure */ }
+  }
+
+  /* ---- Parse and validate URL params at startup ---- */
+  /* Track whether ?conversation was explicitly present (even if malformed)
+     so we never migrate sessionStorage when the param was given but invalid. */
+  let _convParamWasExplicit = false;
+  {
+    const initParams = new URLSearchParams(window.location.search);
+    const rawPanel = initParams.get("panel");
+    const rawConvId = initParams.get("conversation");
+    _convParamWasExplicit = rawConvId !== null;
+    const validPanel = VALID_PANELS.includes(rawPanel) ? rawPanel : null;
+    const validConvId = (rawConvId && UUID_REGEX.test(rawConvId)) ? rawConvId : null;
+    /* Remove invalid params with replaceState */
+    if ((rawPanel && !validPanel) || (rawConvId && !validConvId)) {
+      const normalUrl = new URL(window.location.href);
+      if (!validPanel) normalUrl.searchParams.delete("panel");
+      if (!validConvId) normalUrl.searchParams.delete("conversation");
+      history.replaceState(
+        { panel: validPanel, conversation: validConvId },
+        "",
+        normalUrl,
+      );
+    }
+    currentPanel = validPanel || "resume";
+  }
+
+  /* ---- Load archive and restore conversation from URL or sessionStorage ---- */
+  const startupEpoch = principalEpoch;
+  const ensureStartupPrincipal = () => {
+    if (startupEpoch !== principalEpoch) {
+      const error = new Error("Principal changed during startup");
+      error.name = "StalePrincipalError";
+      throw error;
+    }
+  };
+  archive?.setLoading(true);
+  try {
+    const data = await fetchConversationList(null);
+    ensureStartupPrincipal();
+    archiveCursor = data.cursor || null;
+    const conversations = data.conversations || [];
+    conversations.forEach(c => _renderedIds.add(c.id));
+    archive?.setConversations(conversations, archiveCursor);
+
+    /* Determine target conversation: URL param is canonical; migrate sessionStorage once */
+    const initParams2 = new URLSearchParams(window.location.search);
+    const urlConvId = initParams2.get("conversation");
+    const urlConvValid = urlConvId && UUID_REGEX.test(urlConvId) ? urlConvId : null;
+
+    let resolvedConvId = urlConvValid;
+    let migratedFromStorage = false;
+    if (!resolvedConvId && !_convParamWasExplicit) {
+      const stored = sessionStorage.getItem(CONVERSATION_STORAGE_KEY);
+      if (stored && UUID_REGEX.test(stored)) {
+        resolvedConvId = stored;
+        migratedFromStorage = true;
+      }
+    }
+
+    if (resolvedConvId) {
+      const inArchive = conversations.find(c => c.id === resolvedConvId);
+      if (inArchive) {
+        try {
+          const conversation = await requestJson(
+            `/api/conversations/${encodeURIComponent(resolvedConvId)}`,
+          );
+          ensureStartupPrincipal();
+          currentConversationId = resolvedConvId;
+          sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId);
+          chat?.replaceMessages(conversation.turns);
+          archive?.setCurrentId(resolvedConvId);
+        } catch (error) {
+          if (error.name === "StalePrincipalError" ||
+              startupEpoch !== principalEpoch) throw error;
+          currentConversationId = null;
+          sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+        }
+      } else {
+        /* Not on first page — fetch directly to verify ownership */
+        let ownedConversation = null;
+        try {
+          ownedConversation = await requestJson(
+            `/api/conversations/${encodeURIComponent(resolvedConvId)}`,
+          );
+          ensureStartupPrincipal();
+        } catch (error) {
+          if (error.name === "StalePrincipalError" ||
+              startupEpoch !== principalEpoch) throw error;
+          ownedConversation = null;
+        }
+        if (ownedConversation) {
+          currentConversationId = resolvedConvId;
+          sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId);
+          chat?.replaceMessages(ownedConversation.turns);
+          if (!_renderedIds.has(resolvedConvId)) {
+            _renderedIds.add(resolvedConvId);
+            archive?.addConversation({
+              id: resolvedConvId,
+              title: ownedConversation.title || "Conversation",
+              turn_count: ownedConversation.turns?.length || 0,
+            });
+          }
+          archive?.setCurrentId(resolvedConvId);
+        } else if (_sessionData?.kind === "user") {
+          /* Authenticated and 404 — offer legacy claim for sessionStorage migrations */
+          if (migratedFromStorage) {
+            sessionStorage.setItem(CONVERSATION_LEGACY_KEY, resolvedConvId);
+            legacyClaimId = resolvedConvId;
+            sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+            archive?.showLegacyClaim();
+            if (conversations.length > 0) {
+              currentConversationId = conversations[0].id;
+              sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId);
+              try {
+                const conv = await requestJson(
+                  `/api/conversations/${encodeURIComponent(currentConversationId)}`,
+                );
+                ensureStartupPrincipal();
+                chat?.replaceMessages(conv.turns);
+                archive?.setCurrentId(currentConversationId);
+              } catch (error) {
+                if (error.name === "StalePrincipalError" ||
+                    startupEpoch !== principalEpoch) throw error;
+                currentConversationId = null;
+              }
+            }
+          } else {
+            /* URL-specified conv returned 404 — clear it */
+            currentConversationId = null;
+            sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+          }
+        } else {
+          /* Guest — not owned, clear stale ID */
+          currentConversationId = null;
+          if (migratedFromStorage) sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+        }
+      }
+    } else if (conversations.length > 0) {
+      /* No target conversation — open the most recent */
+      const most_recent = conversations[0];
+      try {
+        const conversation = await requestJson(
+          `/api/conversations/${encodeURIComponent(most_recent.id)}`,
+        );
+        ensureStartupPrincipal();
+        currentConversationId = most_recent.id;
+        sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId);
+        chat?.replaceMessages(conversation.turns);
+        archive?.setCurrentId(most_recent.id);
+      } catch (error) {
+        if (error.name === "StalePrincipalError" ||
+            startupEpoch !== principalEpoch) throw error;
+        currentConversationId = null;
+      }
+    }
+  } catch (error) {
+    if (error.name !== "StalePrincipalError" &&
+        startupEpoch === principalEpoch) {
+      archive?.setError(`Archive error: ${error.message}`);
+    }
+    /* Fall back to direct fetch of URL/stored ID */
+    const initParams3 = new URLSearchParams(window.location.search);
+    const fallbackId = (
+      initParams3.get("conversation") ||
+      (!_convParamWasExplicit
+        ? sessionStorage.getItem(CONVERSATION_STORAGE_KEY)
+        : null)
+    ) || null;
+    if (startupEpoch === principalEpoch &&
+        fallbackId && UUID_REGEX.test(fallbackId)) {
+      try {
+        const conversation = await requestJson(
+          `/api/conversations/${encodeURIComponent(fallbackId)}`,
+        );
+        ensureStartupPrincipal();
+        currentConversationId = fallbackId;
+        chat?.replaceMessages(conversation.turns);
+      } catch (fallbackError) {
+        if (fallbackError.name === "StalePrincipalError" ||
+            startupEpoch !== principalEpoch) {
+          // The new principal owns subsequent rendering.
+        } else {
+        sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+        currentConversationId = null;
+        }
+      }
+    }
+  }
+
+  /* Apply startup panel and normalize URL (replaceState — not a user navigation) */
+  if (startupEpoch === principalEpoch) {
+    selectPanel(currentPanel, false);
+    _replaceUrlState(currentPanel, currentConversationId);
+  }
+
   shell?.addEventListener("mjj-jrpg-preview-change", event => {
-    preview?.show(event.detail.selection);
+    selectPanel(event.detail.selection);
     character?.setAttribute("state", "thinking");
     window.clearTimeout(characterTimer);
     characterTimer = window.setTimeout(() => {
@@ -1564,6 +2887,10 @@
 
   shell?.addEventListener("mjj-jrpg-submit", async event => {
     if (activeController) return;
+    /* Block sends when password change is required */
+    if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) {
+      return;
+    }
     chat?.appendMessage("June", event.detail.message);
     composer?.setBusy(true);
     character?.setAttribute("state", "thinking");
@@ -1579,51 +2906,130 @@
       assistantText.cancel();
     }, { once: true });
 
+    /* Disable archive/account mutations while stream is active (finding #7) */
+    archive?.setStreamActive(true);
+    setAccountActionsDisabled(true);
+
     try {
       const createConversation = async () => {
-        const conversation = await requestJson("/api/conversations", {
+        const resp = await mutatingFetch("/api/conversations", {
           method: "POST",
+          headers: { "Content-Type": "application/json" },
           body: JSON.stringify({ title: "Shiba Quest" }),
         });
-        conversationId = conversation.id;
-        sessionStorage.setItem(CONVERSATION_STORAGE_KEY, conversationId);
+        if (!resp.ok) {
+          let message = `Create failed (${resp.status})`;
+          try {
+            const body = await resp.json();
+            message = body.error?.message || message;
+          } catch { /* keep */ }
+          throw new Error(message);
+        }
+        const conversation = await resp.json();
+        currentConversationId = conversation.id;
+        sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId);
+        _pushUrlState(currentPanel, currentConversationId);
+        if (!_renderedIds.has(currentConversationId)) {
+          _renderedIds.add(currentConversationId);
+          archive?.addConversation({
+            id: currentConversationId,
+            title: "Shiba Quest",
+            turn_count: 0,
+            last_message_preview: event.detail.message.slice(0, 32),
+          });
+        }
+        archive?.setCurrentId(currentConversationId);
       };
-      if (!conversationId) {
+
+      if (!currentConversationId) {
         await createConversation();
       }
 
-      const postTurn = () => fetch(
-        `/api/conversations/${encodeURIComponent(conversationId)}/turns`, {
+      const postTurn = () => mutatingFetch(
+        `/api/conversations/${encodeURIComponent(currentConversationId)}/turns`,
+        {
           method: "POST",
           headers: { "Content-Type": "application/json" },
           body: JSON.stringify({ prompt: event.detail.message }),
           signal: activeController.signal,
         },
       );
+
       let response = await postTurn();
+
+      /* Handle 404: conversation may have expired — recreate it */
       if (response.status === 404) {
         sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
-        conversationId = null;
+        currentConversationId = null;
         await createConversation();
         response = await postTurn();
       }
+
+      /* Handle 429: distinguish permanent quota from temporary capacity */
+      if (response.status === 429) {
+        let errorBody = null;
+        try { errorBody = await response.json(); } catch { /* keep */ }
+        const code = errorBody?.error?.code || "";
+        const isQuotaExhausted =
+          code === "guest_quota_turns_exhausted" ||
+          code === "guest_quota_tokens_exhausted" ||
+          code === "guest_turn_quota_exhausted" ||
+          code === "guest_token_quota_exhausted";
+
+        if (isQuotaExhausted) {
+          const quotaState = errorBody?.error?.quota || null;
+          updateQuotaDisplay(quotaEl, quotaState);
+          composer?.setDisabled(true);
+          chat?.setMessageStreaming(assistantItem, false);
+          chat?.setMessageText(
+            assistantItem,
+            quotaState
+              ? `Quota exhausted. ${formatQuotaText(quotaState)?.text || ""}`
+              : "Quota exhausted. Try again later.",
+          );
+          character?.setAttribute("state", "sad");
+          setFrameStatus("QUOTA", "offline");
+          /* Re-enable when quota resets */
+          const resetsAt = quotaState?.resetsAt;
+          if (resetsAt) {
+            const delay = Math.max(0, resetsAt * 1000 - Date.now());
+            window.setTimeout(() => {
+              if (_sessionData?.kind !== "user" || !_sessionData?.mustChangePassword) {
+                composer?.setDisabled(false);
+                setFrameStatus("ONLINE", "online");
+              }
+            }, Math.min(delay + 1000, 24 * 3600 * 1000));
+          }
+        } else {
+          /* Temporary capacity 429 — use Retry-After or default 5 s */
+          const retryAfter = parseInt(response.headers.get("Retry-After") || "5", 10);
+          chat?.setMessageStreaming(assistantItem, false);
+          chat?.setMessageText(assistantItem, "Server busy. Retrying shortly…");
+          character?.setAttribute("state", "sad");
+          setFrameStatus("BUSY", "offline");
+          window.setTimeout(() => {
+            if (!activeController?.signal.aborted) {
+              setFrameStatus("ONLINE", "online");
+              composer?.setDisabled(false);
+            }
+          }, retryAfter * 1000);
+        }
+        return;
+      }
+
       if (!response.ok) {
         let message = `Turn failed (${response.status})`;
         try {
           const body = await response.json();
           message = body.error?.message || message;
-        } catch {
-          // Preserve the status-derived message.
-        }
+        } catch { /* keep */ }
         throw new Error(message);
       }
+
       await consumeSse(response, (eventName, data) => {
         shell?.dispatchEvent(new CustomEvent("mjj-jrpg-stream-event", {
           bubbles: true,
-          detail: {
-            data,
-            type: eventName,
-          },
+          detail: { data, type: eventName },
         }));
         if (eventName === "assistant.delta") {
           assistantText.append(data.delta || "");
@@ -1643,6 +3049,7 @@
           }
         }
       });
+
       if (!turnFinished) throw new Error("Inference stream ended unexpectedly");
       await assistantText.waitForIdle();
       if (activeController.signal.aborted) {
@@ -1651,6 +3058,13 @@
       chat?.setMessageStreaming(assistantItem, false);
       character?.setAttribute("state", "default");
       setFrameStatus("ONLINE", "online");
+
+      /* Refresh archive and quota after successful turn */
+      void refreshArchiveHead();
+      if (_sessionData?.kind === "guest") {
+        _csrfToken = await _fetchSession();
+        updateQuotaDisplay(quotaEl, _sessionData?.quota || null);
+      }
     } catch (error) {
       const aborted = error instanceof DOMException && error.name === "AbortError";
       assistantText.cancel();
@@ -1665,9 +3079,24 @@
       composer?.setBusy(false);
       composer?.querySelector("textarea")?.focus();
       activeController = null;
+      /* Re-enable archive/account mutations exactly once */
+      archive?.setStreamActive(false);
+      setAccountActionsDisabled(false);
+      /* Apply popstate that was queued during the stream (Fix 1) */
+      if (_pendingPopState) {
+        const pending = _pendingPopState;
+        _pendingPopState = null;
+        if (pending.seq === popSeq) {
+          await _applyPopState(pending.seq, pending.popPanel, pending.popConvId);
+        }
+      }
     }
   });
-  composer?.setDisabled(false);
+
+  /* Enable composer unless forced password change */
+  if (!isForced) {
+    composer?.setDisabled(false);
+  }
 }
 
 if (document.readyState === "loading") {
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/login/index.html	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,206 @@
+<!DOCTYPE html>
+<html lang="en" data-zen-theme="cyberpunk">
+<head>
+  {{/parts/base_head.html}}
+  <title>Sign in — MrJuneJune</title>
+  <style>
+    .login-page main {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      min-height: 60vh;
+      padding: var(--zenbu-sys-padding-lg) var(--zenbu-sys-padding-md);
+    }
+
+    .login-card {
+      width: 100%;
+      max-width: 360px;
+    }
+
+    .login-card h2 {
+      margin: 0 0 var(--zenbu-sys-padding-md);
+      font-size: 1.4rem;
+      font-weight: 700;
+      text-align: center;
+      letter-spacing: 0.08em;
+    }
+
+    .login-form {
+      display: flex;
+      flex-direction: column;
+      gap: var(--zenbu-sys-padding-sm);
+    }
+
+    .login-actions {
+      display: flex;
+      justify-content: flex-end;
+      gap: var(--zenbu-sys-space-control);
+      margin-top: var(--zenbu-sys-padding-xs, 0.5rem);
+    }
+
+    #loginError {
+      display: none;
+      padding: 0.6rem 0.75rem;
+      border-left: var(--zenbu-sys-stroke-width-emphasis, 2px) solid var(--zenbu-sys-color-danger-foreground);
+      background: color-mix(in srgb, var(--zenbu-sys-color-danger-foreground) 10%, transparent);
+      color: var(--zenbu-sys-color-danger-foreground);
+      font-size: 0.875rem;
+    }
+
+    #loginError[aria-hidden="false"] {
+      display: block;
+    }
+  </style>
+</head>
+<body class="login-page">
+  {{/parts/header.html}}
+
+  <main>
+    <div class="login-card">
+      <zen-heading size="xl">
+        <h2>Sign in</h2>
+      </zen-heading>
+
+      <div id="loginError" role="alert" aria-live="assertive" aria-hidden="true"></div>
+
+      <form class="login-form" id="loginForm" novalidate>
+        <zen-field appearance="plain" size="md">
+          <label for="username">Username</label>
+          <input
+            id="username"
+            name="username"
+            type="text"
+            autocomplete="username"
+            autocapitalize="none"
+            spellcheck="false"
+            required
+            maxlength="32"
+          >
+        </zen-field>
+
+        <zen-field appearance="plain" size="md">
+          <label for="password">Password</label>
+          <input
+            id="password"
+            name="password"
+            type="password"
+            autocomplete="current-password"
+            required
+            minlength="12"
+            maxlength="1024"
+          >
+        </zen-field>
+
+        <div class="login-actions">
+          <zen-button appearance="plain" size="md">
+            <button type="submit" id="loginSubmit">Sign in</button>
+          </zen-button>
+        </div>
+      </form>
+    </div>
+  </main>
+
+  <script>
+    (function () {
+      'use strict';
+
+      const form     = document.getElementById('loginForm');
+      const errorEl  = document.getElementById('loginError');
+      const submitBtn = document.getElementById('loginSubmit');
+
+      function showError(message) {
+        errorEl.textContent = message;
+        errorEl.setAttribute('aria-hidden', 'false');
+      }
+
+      function hideError() {
+        errorEl.textContent = '';
+        errorEl.setAttribute('aria-hidden', 'true');
+      }
+
+      async function fetchSession() {
+        const resp = await fetch('/api/auth/session', {
+          credentials: 'same-origin',
+        });
+        if (!resp.ok) throw new Error('session unavailable');
+        return resp.json();
+      }
+
+      form.addEventListener('submit', async function (evt) {
+        evt.preventDefault();
+        hideError();
+
+        const username = form.username.value.trim();
+        const password = form.password.value;
+
+        if (!username || !password) {
+          showError('Please enter your username and password.');
+          return;
+        }
+
+        submitBtn.disabled = true;
+
+        try {
+          const session = await fetchSession();
+          const csrfToken = session.csrfToken;
+
+          const resp = await fetch('/api/auth/login', {
+            method: 'POST',
+            headers: {
+              'Content-Type': 'application/json',
+              'Origin': window.location.origin,
+            },
+            credentials: 'same-origin',
+            body: JSON.stringify({ username, password, csrfToken }),
+          });
+
+          form.password.value = '';
+
+          let data = await resp.json().catch(function () { return null; });
+
+          if (!resp.ok) {
+            showError(resp.status === 429
+              ? 'Too many attempts. Please try again later.'
+              : 'Invalid username or password.');
+            return;
+          }
+
+          if (!data) {
+            data = await fetchSession();
+            if (data.kind !== 'user') {
+              window.location.reload();
+              return;
+            }
+          }
+
+          if (data.mustChangePassword) {
+            window.location.href = '/account/password';
+            return;
+          }
+
+          function safeNext(n) {
+            if (!n || typeof n !== 'string') return null;
+            if (!n.startsWith('/') || n.startsWith('//') || n.startsWith('/\\')) return null;
+            if (n.includes('\\')) return null;
+            try {
+              const url = new URL(n, window.location.origin);
+              if (url.origin !== window.location.origin) return null;
+              return url.pathname + url.search + url.hash;
+            } catch (_) {
+              return null;
+            }
+          }
+          const next = safeNext(new URLSearchParams(window.location.search).get('next'));
+          window.location.href = next || '/jrpg';
+        } catch (_) {
+          form.password.value = '';
+          showError('Sign-in failed. Please try again.');
+        } finally {
+          submitBtn.disabled = false;
+        }
+      });
+    })();
+  </script>
+</body>
+</html>
Binary file mrjunejune/src/public/jrpg/background-frame-mobile.webp has changed
--- a/mrjunejune/src/sw.js	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/src/sw.js	Fri Aug 07 07:34:12 2026 -0700
@@ -1,5 +1,5 @@
 // Root-scoped Service Worker for MrJuneJune PWA
-const CACHE_VERSION = 'v30-card-driven-details';
+const CACHE_VERSION = 'v34-login-modal';
 const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`;
 
 // Files to cache immediately on install
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/template_renderer.c	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,175 @@
+/*
+ * template_renderer.c — site-owned HTML template renderer for mrjunejune.
+ *
+ * Expands one level of {{/path}} include tokens found in HTML templates.
+ * File I/O is done with standard C fopen/fread so that tests can point
+ * the renderer at any root without depending on Seobeo server state.
+ */
+
+#include "mrjunejune/template_renderer.h"
+
+#include "dowa/dowa.h"
+#include "seobeo/seobeo.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* Default matches the path Seobeo_Web_Server_Start_On receives in main.c. */
+static char g_src_root[512] = "mrjunejune/src";
+
+void Mjj_Template_Renderer_Init(const char *src_root)
+{
+  if (!src_root || src_root[0] == '\0')
+    return;
+  strncpy(g_src_root, src_root, sizeof(g_src_root) - 1);
+  g_src_root[sizeof(g_src_root) - 1] = '\0';
+}
+
+/* Load a file using the renderer's own root; caller must free() result. */
+static char *renderer_load_file(const char *rel_path, size_t *p_size)
+{
+  if (!rel_path)
+    return NULL;
+
+  char full_path[1024];
+  /* rel_path already starts with '/' when produced by canonical markers
+     (e.g. "/parts/base_head.html"), so concatenate directly. */
+  if (rel_path[0] == '/')
+    snprintf(full_path, sizeof(full_path), "%s%s", g_src_root, rel_path);
+  else
+    snprintf(full_path, sizeof(full_path), "%s/%s", g_src_root, rel_path);
+
+  FILE *f = fopen(full_path, "rb");
+  if (!f)
+    return NULL;
+
+  fseek(f, 0, SEEK_END);
+  long sz = ftell(f);
+  fseek(f, 0, SEEK_SET);
+  if (sz < 0)
+  {
+    fclose(f);
+    return NULL;
+  }
+
+  char *buf = (char *)malloc((size_t)sz + 1);
+  if (!buf)
+  {
+    fclose(f);
+    return NULL;
+  }
+
+  size_t n = fread(buf, 1, (size_t)sz, f);
+  fclose(f);
+  buf[n] = '\0';
+
+  if (p_size)
+    *p_size = n;
+  return buf;
+}
+
+/* Write `len` bytes of `src` to out starting at *p_offset, respecting cap.
+   Returns FALSE and does a best-effort NUL-terminate on overflow. */
+static boolean safe_append(
+    char *out, size_t cap, size_t *p_offset,
+    const char *src, size_t len)
+{
+  if (len == 0)
+    return TRUE;
+  if (*p_offset + len >= cap)
+  {
+    /* Partial write then NUL-terminate. */
+    size_t avail = (cap > *p_offset + 1) ? (cap - *p_offset - 1) : 0;
+    if (avail > 0)
+      memcpy(out + *p_offset, src, avail);
+    out[cap - 1] = '\0';
+    return FALSE;
+  }
+  memcpy(out + *p_offset, src, len);
+  *p_offset += len;
+  return TRUE;
+}
+
+boolean Mjj_Template_Render(
+    char *out, size_t cap, const char *tmpl, Dowa_Arena *arena)
+{
+  if (!out || cap == 0)
+    return FALSE;
+  out[0] = '\0';
+  if (!tmpl)
+    return FALSE;
+
+  size_t offset = 0;
+  const char *cursor = tmpl;
+
+  while (1)
+  {
+    const char *open = strstr(cursor, "{{");
+    if (!open)
+      break;
+
+    const char *close = strstr(open + 2, "}}");
+    if (!close)
+      break;
+
+    /* Copy text before the token. */
+    size_t lead = (size_t)(open - cursor);
+    if (!safe_append(out, cap, &offset, cursor, lead))
+      return FALSE;
+
+    /* Extract include path between {{ and }}. */
+    size_t name_len = (size_t)(close - (open + 2));
+    char *include_name = (char *)Dowa_Arena_Allocate(arena, name_len + 1);
+    if (!include_name)
+      return FALSE;
+    memcpy(include_name, open + 2, name_len);
+    include_name[name_len] = '\0';
+
+    /* Load and inline the included file; skip silently if missing. */
+    size_t inc_size = 0;
+    char *inc = renderer_load_file(include_name, &inc_size);
+    Seobeo_Log(SEOBEO_DEBUG,
+        "[TEMPLATE] include '%s' -> %s (size=%zu)\n",
+        include_name, inc ? "OK" : "MISSING", inc_size);
+    if (inc)
+    {
+      boolean ok = safe_append(out, cap, &offset, inc, inc_size);
+      free(inc);
+      if (!ok)
+        return FALSE;
+    }
+
+    cursor = close + 2;
+  }
+
+  /* Copy the tail (after the last token, or the whole string if no tokens). */
+  size_t tail = strlen(cursor);
+  if (!safe_append(out, cap, &offset, cursor, tail))
+    return FALSE;
+
+  out[offset] = '\0';
+  return TRUE;
+}
+
+boolean Mjj_Template_Render_File(
+    char *out, size_t cap, const char *path, Dowa_Arena *arena)
+{
+  if (!out || cap == 0)
+    return FALSE;
+  out[0] = '\0';
+
+  Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] loading file '%s'\n", path);
+
+  size_t file_size = 0;
+  char *tmpl = renderer_load_file(path, &file_size);
+  if (!tmpl)
+  {
+    Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] file '%s' not found\n", path);
+    return FALSE;
+  }
+
+  boolean ok = Mjj_Template_Render(out, cap, tmpl, arena);
+  free(tmpl);
+  return ok;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/template_renderer.h	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,45 @@
+#ifndef MJJ_TEMPLATE_RENDERER_H
+#define MJJ_TEMPLATE_RENDERER_H
+
+/*
+ * template_renderer.h — site-owned HTML template renderer for mrjunejune.
+ *
+ * Expands {{/path}} include tokens by loading files relative to the
+ * configured document root.  Provides overflow-safe output into a
+ * caller-supplied buffer allocated from a Dowa_Arena.
+ */
+
+#include "dowa/dowa.h"
+
+/*
+ * Set the document root used for all subsequent renders.
+ * Must be called once before the first render (not thread-safe vs
+ * concurrent renders; fine to call before the server accepts requests).
+ * Defaults to "mrjunejune/src" if never called.
+ */
+void Mjj_Template_Renderer_Init(const char *src_root);
+
+/*
+ * Render the NUL-terminated template string `tmpl` into `out[0..cap-1]`,
+ * replacing every {{/path}} token with the contents of the file at
+ * g_src_root + path.  `out` is always NUL-terminated on return.
+ *
+ * Returns TRUE  — all text fit; out contains the fully expanded page.
+ * Returns FALSE — output exceeded cap, or tmpl is NULL.
+ *   (out may contain a partial result; treat FALSE as a 500.)
+ *
+ * Missing includes are skipped silently; they never cause FALSE.
+ * arena is used only for temporary include-name copies (not freed here).
+ */
+boolean Mjj_Template_Render(
+    char *out, size_t cap, const char *tmpl, Dowa_Arena *arena);
+
+/*
+ * Load the file at g_src_root + path, then render its includes.
+ * Returns FALSE if the file cannot be opened or if cap is exceeded.
+ * Equivalent to loading the file and calling Mjj_Template_Render.
+ */
+boolean Mjj_Template_Render_File(
+    char *out, size_t cap, const char *path, Dowa_Arena *arena);
+
+#endif /* MJJ_TEMPLATE_RENDERER_H */
--- a/mrjunejune/test/BUILD	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/test/BUILD	Fri Aug 07 07:34:12 2026 -0700
@@ -47,6 +47,11 @@
   size = "large",
   timeout = "long",
   args = ["$(location //mrjunejune:mrjunejune_server)"],
+  env = {
+    "AUTH_COOKIE_SECRET": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+    "AUTH_DEV_INSECURE_COOKIE": "true",
+    "SERVER_HOST": "127.0.0.1",
+  },
 )
 
 cc_test(
@@ -61,7 +66,10 @@
 cc_test(
   name = "conversation_store_test",
   srcs = ["conversation_store_test.c"],
-  deps = ["//mrjunejune:conversation_store"],
+  deps = [
+    "//mrjunejune:conversation_store",
+    "//deita:deita",
+  ],
   size = "small",
 )
 
@@ -98,6 +106,9 @@
     "@playwright_chromium_linux//:chromium",
   ],
   env = {
+    "AUTH_COOKIE_SECRET": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+    "AUTH_DEV_INSECURE_COOKIE": "true",
+    "SERVER_HOST": "127.0.0.1",
     "CHROMIUM_PATH": "$(rootpath @playwright_chromium_linux//:chrome)",
   },
   no_copy_to_bin = ["@playwright_chromium_linux//:chromium"],
@@ -117,27 +128,69 @@
   size = "small",
 )
 
-js_test(
+sh_test(
+  name = "production_bundle_exclusion_test",
+  srcs = ["production_bundle_exclusion_test.sh"],
+  args = ["$(rootpath //mrjunejune:mrjunejune_server_bundle)"],
+  data = ["//mrjunejune:mrjunejune_server_bundle"],
+  size = "small",
+)
+
+sh_test(
+  name = "config_validation_test",
+  srcs = ["config_validation_test.sh"],
+  args = ["$(rootpath //mrjunejune:mrjunejune_server)"],
+  data = ["//mrjunejune:mrjunejune_server"],
+  size = "medium",
+  timeout = "moderate",
+)
+
+_JRPG_BROWSER_DATA = [
+  "//hg-web/e2e:node_modules/playwright-core",
+  "//mrjunejune:mrjunejune_server_debug",
+  ":inference_bridge_fake_sidecar",
+  "shiba.webp",
+  "@playwright_chromium_linux//:chrome",
+  "@playwright_chromium_linux//:chromium",
+]
+
+_JRPG_BROWSER_ENV = {
+  "AUTH_COOKIE_SECRET": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+  "AUTH_DEV_INSECURE_COOKIE": "true",
+  "SERVER_HOST": "127.0.0.1",
+  "CHROMIUM_PATH": "$(rootpath @playwright_chromium_linux//:chrome)",
+}
+
+[
+  js_test(
+    name = "jrpg_{}_test".format(suite),
+    entry_point = "theme_and_webp_test.js",
+    data = _JRPG_BROWSER_DATA,
+    env = dict(_JRPG_BROWSER_ENV, MJJ_BROWSER_SUITE = suite),
+    no_copy_to_bin = ["@playwright_chromium_linux//:chromium"],
+    size = "large",
+    target_compatible_with = [
+      "@platforms//cpu:x86_64",
+      "@platforms//os:linux",
+    ],
+    timeout = "long",
+  )
+  for suite in [
+    "core",
+    "jrpg",
+    "routing",
+    "hls",
+  ]
+]
+
+test_suite(
   name = "theme_and_webp_test",
-  entry_point = "theme_and_webp_test.js",
-  data = [
-    "//hg-web/e2e:node_modules/playwright-core",
-    "//mrjunejune:mrjunejune_server_debug",
-    ":inference_bridge_fake_sidecar",
-    "shiba.webp",
-    "@playwright_chromium_linux//:chrome",
-    "@playwright_chromium_linux//:chromium",
+  tests = [
+    ":jrpg_core_test",
+    ":jrpg_hls_test",
+    ":jrpg_jrpg_test",
+    ":jrpg_routing_test",
   ],
-  env = {
-    "CHROMIUM_PATH": "$(rootpath @playwright_chromium_linux//:chrome)",
-  },
-  no_copy_to_bin = ["@playwright_chromium_linux//:chromium"],
-  size = "large",
-  target_compatible_with = [
-    "@platforms//cpu:x86_64",
-    "@platforms//os:linux",
-  ],
-  timeout = "long",
 )
 
 js_test(
@@ -147,6 +200,61 @@
     "//mrjunejune:mrjunejune_server",
     ":inference_bridge_fake_sidecar",
   ],
+  env = {
+    "AUTH_COOKIE_SECRET": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+    "AUTH_DEV_INSECURE_COOKIE": "true",
+    "SERVER_HOST": "127.0.0.1",
+  },
   size = "large",
   timeout = "long",
 )
+
+cc_test(
+  name = "template_renderer_test",
+  srcs = ["template_renderer_test.c"],
+  deps = [
+    "//mrjunejune:template_renderer",
+    "//dowa:dowa",
+  ],
+  data = ["//mrjunejune:html_src_files"],
+  size = "small",
+  timeout = "short",
+)
+
+cc_test(
+  name = "auth_api_test",
+  srcs = ["auth_api_test.c"],
+  copts = ["-DAUTH_API_TEST_HOOKS"],
+  deps = [
+    "//mrjunejune:auth_api_with_test_hooks",
+    "//auth:auth_crypto",
+    "//auth:auth_store",
+    "//dowa:dowa",
+    "//seobeo:seobeo",
+    "@openssl//:crypto",
+  ],
+  data = ["//mrjunejune:html_src_files"],
+  size = "medium",
+  timeout = "moderate",
+)
+
+cc_test(
+  name = "admin_api_test",
+  srcs = ["admin_api_test.c"],
+  copts = [
+    "-DAUTH_API_TEST_HOOKS",
+    "-DADMIN_API_TEST_HOOKS",
+  ],
+  deps = [
+    "//mrjunejune:admin_api_with_test_hooks",
+    "//auth:auth_crypto",
+    "//auth:auth_store",
+    "//deita:deita",
+    "//dowa:dowa",
+    "//seobeo:seobeo",
+    "@openssl//:crypto",
+  ],
+  data = ["//mrjunejune:html_src_files"],
+  size = "medium",
+  timeout = "moderate",
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/test/admin_api_test.c	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,1022 @@
+/*
+ * admin_api_test.c — unit tests for the admin API handlers.
+ *
+ * Tests run in-process against real store + crypto (no network).
+ * A fresh SQLite database is created per group.
+ */
+
+#include "mrjunejune/admin_api.h"
+#include "mrjunejune/auth_api.h"
+
+#include "auth/auth_crypto.h"
+#include "auth/auth_store.h"
+#include "deita/deita.h"
+#include "dowa/dowa.h"
+#include "seobeo/seobeo.h"
+
+#include <assert.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+#include <openssl/crypto.h>
+
+/* ------------------------------------------------------------------ */
+/* Utilities                                                            */
+/* ------------------------------------------------------------------ */
+
+#define ASSERT(cond) \
+  do { \
+    if (!(cond)) { \
+      fprintf(stderr, "FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
+      abort(); \
+    } \
+  } while (0)
+
+#define TEST(name) \
+  do { fprintf(stdout, "  %-60s", name); fflush(stdout); } while (0)
+
+#define PASS() \
+  do { fprintf(stdout, "PASS\n"); } while (0)
+
+static const uint8 k_secret[64] = {
+  0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
+  0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
+  0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
+  0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
+  0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
+  0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
+  0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
+  0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
+};
+
+static void make_temp_db(char *out, size_t capacity)
+{
+  snprintf(out, capacity, "/tmp/admin_api_test_XXXXXX");
+  int fd = mkstemp(out);
+  ASSERT(fd >= 0);
+  close(fd);
+}
+
+static void init_auth(const char *db)
+{
+  boolean ok = Auth_API_Init(
+      db, k_secret, sizeof(k_secret),
+      NULL, NULL, NULL,
+      AUTH_API_SESSION_IDLE_TTL_DEFAULT,
+      AUTH_API_SESSION_ABS_TTL_DEFAULT,
+      AUTH_API_GUEST_TTL_DEFAULT,
+      TRUE);  /* dev_insecure_cookie */
+  ASSERT(ok);
+}
+
+static Seobeo_Request_Entry *make_request(Dowa_Arena *arena, ...)
+{
+  Seobeo_Request_Entry *req = NULL;
+  va_list ap;
+  va_start(ap, arena);
+  const char *key;
+  while ((key = va_arg(ap, const char *)) != NULL)
+  {
+    char *k = (char *)key;
+    const char *val = va_arg(ap, const char *);
+    char *stored = (char *)val;
+    if (strcmp(key, "Body") == 0)
+      stored = Dowa_Arena_Copy(arena, val, strlen(val) + 1);
+    Dowa_HashMap_Push_Arena(req, k, stored, arena);
+  }
+  va_end(ap);
+  return req;
+}
+
+static const char *resp_status(Seobeo_Request_Entry *resp)
+{
+  void *p = Dowa_HashMap_Get_Ptr(resp, "status");
+  return p ? ((Seobeo_Request_Entry *)p)->value : NULL;
+}
+
+static const char *resp_body(Seobeo_Request_Entry *resp)
+{
+  void *p = Dowa_HashMap_Get_Ptr(resp, "body");
+  return p ? ((Seobeo_Request_Entry *)p)->value : NULL;
+}
+
+/*
+ * Create a user and return the new user_id.
+ * Hash a real password so the store accepts it.
+ */
+static boolean create_test_user(
+    Auth_Store  *store,
+    const char  *username,
+    const char  *password,
+    const char  *role,
+    boolean      must_change,
+    char         id_out[37])
+{
+  char hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  if (Auth_Crypto_Password_Hash(password, hash, sizeof(hash)) != AUTH_CRYPTO_OK)
+    return FALSE;
+  Auth_Store_Result r = Auth_Store_Create_User(
+      store, username, hash, role, must_change, id_out);
+  OPENSSL_cleanse(hash, sizeof(hash));
+  return r == AUTH_STORE_OK;
+}
+
+/*
+ * Login as a user and return the session cookie value + CSRF token.
+ * Returns TRUE on success.
+ */
+static boolean login_user(
+    Dowa_Arena *arena,
+    const char *username,
+    const char *password,
+    char        session_cookie_out[],
+    size_t      cookie_cap,
+    char        csrf_out[],
+    size_t      csrf_cap)
+{
+  /* 1. Get a guest session to obtain a CSRF token + guest cookie. */
+  Seobeo_Request_Entry *sess_req = make_request(
+      arena,
+      "Host",        "localhost",
+      "Remote-Addr", "127.0.0.1",
+      NULL, NULL);
+  Seobeo_Request_Entry *sess_resp =
+      Auth_API_Test_Session_Handler(sess_req, arena);
+  const char *sess_body = resp_body(sess_resp);
+  ASSERT(sess_body);
+
+  /* Extract csrfToken from session response. */
+  const char *csrf_start = strstr(sess_body, "\"csrfToken\":\"");
+  ASSERT(csrf_start);
+  csrf_start += strlen("\"csrfToken\":\"");
+  const char *csrf_end = strchr(csrf_start, '"');
+  ASSERT(csrf_end);
+  size_t csrf_len = (size_t)(csrf_end - csrf_start);
+  ASSERT(csrf_len < csrf_cap);
+  memcpy(csrf_out, csrf_start, csrf_len);
+  csrf_out[csrf_len] = '\0';
+
+  /* Extract the guest cookie from Set-Cookie to send with login. */
+  char guest_cookie_val[512] = {0};
+  void *sc_kv = Dowa_HashMap_Get_Ptr(sess_resp, "Set-Cookie");
+  if (sc_kv)
+  {
+    const char *sc_hdr = ((Seobeo_Request_Entry *)sc_kv)->value;
+    const char *gc_start = strstr(sc_hdr, "mjj_guest=");
+    if (gc_start)
+    {
+      gc_start += strlen("mjj_guest=");
+      const char *gc_end = strchr(gc_start, ';');
+      size_t gclen = gc_end
+          ? (size_t)(gc_end - gc_start)
+          : strlen(gc_start);
+      if (gclen < sizeof(guest_cookie_val))
+      {
+        memcpy(guest_cookie_val, gc_start, gclen);
+        guest_cookie_val[gclen] = '\0';
+      }
+    }
+  }
+
+  /* 2. Call login with the guest cookie so Resolve_Principal finds the same guest. */
+  char login_body[512];
+  snprintf(login_body, sizeof(login_body),
+           "{\"username\":\"%s\",\"password\":\"%s\",\"csrfToken\":\"%s\"}",
+           username, password, csrf_out);
+
+  char cookie_hdr[512] = {0};
+  if (guest_cookie_val[0] != '\0')
+    snprintf(cookie_hdr, sizeof(cookie_hdr),
+             "mjj_guest=%s", guest_cookie_val);
+
+  Seobeo_Request_Entry *login_req;
+  if (cookie_hdr[0] != '\0')
+  {
+    login_req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Origin",      "http://localhost",
+        "Cookie",      cookie_hdr,
+        "Body",        login_body,
+        NULL, NULL);
+  }
+  else
+  {
+    login_req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Origin",      "http://localhost",
+        "Body",        login_body,
+        NULL, NULL);
+  }
+
+  Seobeo_Request_Entry *login_resp =
+      Auth_API_Test_Login_Handler(login_req, arena);
+  const char *st = resp_status(login_resp);
+  if (!st || strcmp(st, "200") != 0)
+    return FALSE;
+
+  /* 3. Extract session cookie value. */
+  void *lsc_kv = Dowa_HashMap_Get_Ptr(login_resp, "Set-Cookie");
+  if (!lsc_kv) return FALSE;
+  const char *lsc_hdr = ((Seobeo_Request_Entry *)lsc_kv)->value;
+  const char *cookie_start = strstr(lsc_hdr, "mjj_session=");
+  if (!cookie_start) return FALSE;
+  cookie_start += strlen("mjj_session=");
+  const char *cookie_end = strchr(cookie_start, ';');
+  size_t clen = cookie_end
+      ? (size_t)(cookie_end - cookie_start)
+      : strlen(cookie_start);
+  ASSERT(clen < cookie_cap);
+  memcpy(session_cookie_out, cookie_start, clen);
+  session_cookie_out[clen] = '\0';
+
+  /* 4. Fetch a fresh CSRF from the authenticated session. */
+  char auth_cookie_hdr[512];
+  snprintf(auth_cookie_hdr, sizeof(auth_cookie_hdr),
+           "mjj_session=%s", session_cookie_out);
+  Seobeo_Request_Entry *csrfsess_req = make_request(
+      arena,
+      "Host",        "localhost",
+      "Remote-Addr", "127.0.0.1",
+      "Cookie",      auth_cookie_hdr,
+      NULL, NULL);
+  Seobeo_Request_Entry *csrfsess_resp =
+      Auth_API_Test_Session_Handler(csrfsess_req, arena);
+  const char *csrfsess_body = resp_body(csrfsess_resp);
+  ASSERT(csrfsess_body);
+  const char *cs2 = strstr(csrfsess_body, "\"csrfToken\":\"");
+  ASSERT(cs2);
+  cs2 += strlen("\"csrfToken\":\"");
+  const char *ce2 = strchr(cs2, '"');
+  ASSERT(ce2);
+  size_t cl2 = (size_t)(ce2 - cs2);
+  ASSERT(cl2 < csrf_cap);
+  memcpy(csrf_out, cs2, cl2);
+  csrf_out[cl2] = '\0';
+
+  return TRUE;
+}
+
+/* Build a request with session cookie + CSRF header + body. */
+static Seobeo_Request_Entry *make_admin_req(
+    Dowa_Arena  *arena,
+    const char  *method,
+    const char  *session_cookie,
+    const char  *csrf_token,
+    const char  *body,
+    const char  *id_param)
+{
+  /* Allocate cookie_hdr from arena so the pointer stays valid after return. */
+  char *cookie_hdr = Dowa_Arena_Allocate(arena, 528);
+  ASSERT(cookie_hdr);
+  snprintf(cookie_hdr, 528, "mjj_session=%s", session_cookie);
+
+  Seobeo_Request_Entry *req = make_request(
+      arena,
+      "Host",          "localhost",
+      "Remote-Addr",   "127.0.0.1",
+      "Origin",        "http://localhost",
+      "Cookie",        cookie_hdr,
+      "X-CSRF-Token",  (char *)csrf_token,
+      "Body",          body ? (char *)body : "",
+      NULL, NULL);
+  if (id_param)
+    Dowa_HashMap_Push_Arena(req, ":id", (char *)id_param, arena);
+  return req;
+  (void)method;
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: non-admin denial                                         */
+/* ------------------------------------------------------------------ */
+
+static void test_non_admin_denial(void)
+{
+  printf("\n[non-admin denial]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db);
+  Auth_Store *store = Auth_API_Get_Store();
+
+  char member_id[37];
+  ASSERT(create_test_user(store, "member1", "password123456", "member", FALSE, member_id));
+
+  Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
+
+  char scookie[512], csrf[512];
+  ASSERT(login_user(arena, "member1", "password123456",
+                    scookie, sizeof(scookie), csrf, sizeof(csrf)));
+
+  TEST("member cannot list users (403)");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(arena, "GET", scookie, csrf, NULL, NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "403") == 0);
+  }
+  PASS();
+
+  TEST("member cannot create users (403)");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "POST", scookie, csrf,
+        "{\"username\":\"hack\",\"temporaryPassword\":\"password123456\"}", NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "403") == 0);
+  }
+  PASS();
+
+  TEST("unauthenticated list → 401");
+  {
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        NULL, NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "401") == 0);
+    /* No page content must leak */
+    const char *body = resp_body(resp);
+    ASSERT(!body || strstr(body, "password") == NULL);
+  }
+  PASS();
+
+  TEST("unauthenticated page → redirect (not content)");
+  {
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        NULL, NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Page_Handler(req, arena);
+    const char *st = resp_status(resp);
+    /* Must be 302 redirect; body must be empty/no admin content */
+    ASSERT(st && (strcmp(st, "302") == 0 || strcmp(st, "401") == 0));
+    const char *body = resp_body(resp);
+    ASSERT(!body || strstr(body, "<table") == NULL);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: forced-password denial                                   */
+/* ------------------------------------------------------------------ */
+
+static void test_forced_password_denial(void)
+{
+  printf("\n[forced-password denial]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db);
+  Auth_Store *store = Auth_API_Get_Store();
+
+  char admin_id[37];
+  ASSERT(create_test_user(store, "fadmin", "password123456", "admin", TRUE, admin_id));
+
+  Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
+
+  char scookie[512], csrf[512];
+  ASSERT(login_user(arena, "fadmin", "password123456",
+                    scookie, sizeof(scookie), csrf, sizeof(csrf)));
+
+  TEST("admin with must_change_password blocked from list (403)");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(arena, "GET", scookie, csrf, NULL, NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "403") == 0);
+  }
+  PASS();
+
+  TEST("admin with must_change_password blocked from create (403)");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "POST", scookie, csrf,
+        "{\"username\":\"newu\",\"temporaryPassword\":\"password123456\"}", NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "403") == 0);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: create user                                              */
+/* ------------------------------------------------------------------ */
+
+static void test_create_user(void)
+{
+  printf("\n[create user]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db);
+  Auth_Store *store = Auth_API_Get_Store();
+
+  char admin_id[37];
+  ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
+
+  Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
+  char scookie[512], csrf[512];
+  ASSERT(login_user(arena, "admin1", "password123456",
+                    scookie, sizeof(scookie), csrf, sizeof(csrf)));
+
+  TEST("create user succeeds → 201, mustChangePassword=true");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "POST", scookie, csrf,
+        "{\"username\":\"newuser\",\"temporaryPassword\":\"temppass123456\"}", NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "201") == 0);
+    const char *body = resp_body(resp);
+    ASSERT(body && strstr(body, "\"mustChangePassword\":true") != NULL);
+    /* No hash/digest in response */
+    ASSERT(strstr(body, "hash") == NULL);
+    ASSERT(strstr(body, "password_hash") == NULL);
+    ASSERT(strstr(body, "digest") == NULL);
+    ASSERT(strstr(body, "session") == NULL);
+  }
+  PASS();
+
+  TEST("duplicate username → 409");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "POST", scookie, csrf,
+        "{\"username\":\"newuser\",\"temporaryPassword\":\"temppass123456\"}", NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "409") == 0);
+  }
+  PASS();
+
+  TEST("short password → 400 policy error");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "POST", scookie, csrf,
+        "{\"username\":\"shortpw\",\"temporaryPassword\":\"tooshort\"}", NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "400") == 0);
+    const char *body = resp_body(resp);
+    ASSERT(body && strstr(body, "password_policy") != NULL);
+  }
+  PASS();
+
+  TEST("invalid role → 400");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "POST", scookie, csrf,
+        "{\"username\":\"badrole\",\"temporaryPassword\":\"temppass123456\",\"role\":\"superuser\"}", NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "400") == 0);
+  }
+  PASS();
+
+  TEST("CSRF rejection → 403");
+  {
+    char bad_cookie_hdr[512];
+    snprintf(bad_cookie_hdr, sizeof(bad_cookie_hdr), "mjj_session=%s", scookie);
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",          "localhost",
+        "Remote-Addr",   "127.0.0.1",
+        "Origin",        "http://localhost",
+        "Cookie",        bad_cookie_hdr,
+        "X-CSRF-Token",  "BADCSRF",
+        "Body",          "{\"username\":\"x\",\"temporaryPassword\":\"temppass123456\"}",
+        NULL, NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "403") == 0);
+  }
+  PASS();
+
+  TEST("origin rejection → 403");
+  {
+    char cookie_hdr[512];
+    snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", scookie);
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",          "localhost",
+        "Remote-Addr",   "127.0.0.1",
+        "Origin",        "http://evil.com",
+        "Cookie",        cookie_hdr,
+        "X-CSRF-Token",  csrf,
+        "Body",          "{\"username\":\"y\",\"temporaryPassword\":\"temppass123456\"}",
+        NULL, NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "403") == 0);
+  }
+  PASS();
+
+  TEST("audit insertion failure rolls back user creation");
+  {
+    Deita_Connection *connection = Deita_Connection_Create(
+        DEITA_DATABASE_TYPE_SQLITE3, db);
+    ASSERT(connection);
+    ASSERT(Deita_Query_Execute_Update(
+        connection,
+        "CREATE TRIGGER fail_admin_api_audit"
+        " BEFORE INSERT ON admin_audit_log"
+        " BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END") >= 0);
+    Deita_Connection_Close(connection);
+
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "POST", scookie, csrf,
+        "{\"username\":\"auditfail\","
+        "\"temporaryPassword\":\"temppass123456\"}",
+        NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "500") == 0);
+
+    Auth_User_Auth_Record record;
+    memset(&record, 0, sizeof(record));
+    ASSERT(Auth_Store_Find_User_By_Username(
+        store, "auditfail", &record) == AUTH_STORE_NOT_FOUND);
+
+    connection = Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, db);
+    ASSERT(connection);
+    ASSERT(Deita_Query_Execute_Update(
+        connection, "DROP TRIGGER fail_admin_api_audit") >= 0);
+    Deita_Connection_Close(connection);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: enable/disable                                           */
+/* ------------------------------------------------------------------ */
+
+static void test_enable_disable(void)
+{
+  printf("\n[enable/disable]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db);
+  Auth_Store *store = Auth_API_Get_Store();
+
+  char admin_id[37], user_id[37];
+  ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
+  ASSERT(create_test_user(store, "user1",  "password123456", "member", FALSE, user_id));
+
+  Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
+  char scookie[512], csrf[512];
+  ASSERT(login_user(arena, "admin1", "password123456",
+                    scookie, sizeof(scookie), csrf, sizeof(csrf)));
+
+  const char *user_token_digest =
+      "1111111111111111111111111111111111111111111111111111111111111111";
+  const char *user_csrf_digest =
+      "2222222222222222222222222222222222222222222222222222222222222222";
+  int64 session_now = (int64)time(NULL);
+  Auth_Session_Record user_session;
+  ASSERT(Auth_Store_Create_Session(
+      store, user_id, user_token_digest, user_csrf_digest,
+      3600, 86400, session_now, &user_session) == AUTH_STORE_OK);
+
+  TEST("disable user → 200, status=disabled");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "PATCH", scookie, csrf, "{\"op\":\"disable\"}", user_id);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "200") == 0);
+    const char *body = resp_body(resp);
+    ASSERT(body && strstr(body, "\"status\":\"disabled\"") != NULL);
+    Auth_Session_Record found_session;
+    Auth_User_Record found_user;
+    ASSERT(Auth_Store_Find_Session(
+        store, user_token_digest, session_now + 1,
+        &found_session, &found_user) == AUTH_STORE_REVOKED);
+  }
+  PASS();
+
+  TEST("enable user → 200, status=active");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "PATCH", scookie, csrf, "{\"op\":\"enable\"}", user_id);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "200") == 0);
+    const char *body = resp_body(resp);
+    ASSERT(body && strstr(body, "\"status\":\"active\"") != NULL);
+    Auth_Session_Record found_session;
+    Auth_User_Record found_user;
+    ASSERT(Auth_Store_Find_Session(
+        store, user_token_digest, session_now + 2,
+        &found_session, &found_user) == AUTH_STORE_REVOKED);
+  }
+  PASS();
+
+  TEST("disable last active admin → 409 last_admin");
+  {
+    /* admin1 is the only admin */
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "PATCH", scookie, csrf, "{\"op\":\"disable\"}", admin_id);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "409") == 0);
+    const char *body = resp_body(resp);
+    ASSERT(body && strstr(body, "last_admin") != NULL);
+  }
+  PASS();
+
+  TEST("response body contains no secrets");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "PATCH", scookie, csrf, "{\"op\":\"enable\"}", user_id);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
+    const char *body = resp_body(resp);
+    ASSERT(body && strstr(body, "hash") == NULL);
+    ASSERT(body && strstr(body, "digest") == NULL);
+    ASSERT(body && strstr(body, "session") == NULL);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: role update                                              */
+/* ------------------------------------------------------------------ */
+
+static void test_role_update(void)
+{
+  printf("\n[role update]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db);
+  Auth_Store *store = Auth_API_Get_Store();
+
+  char admin_id[37], user_id[37];
+  ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
+  ASSERT(create_test_user(store, "user1",  "password123456", "member", FALSE, user_id));
+
+  Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
+  char scookie[512], csrf[512];
+  ASSERT(login_user(arena, "admin1", "password123456",
+                    scookie, sizeof(scookie), csrf, sizeof(csrf)));
+
+  TEST("promote member to admin → 200");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "PATCH", scookie, csrf,
+        "{\"op\":\"set_role\",\"role\":\"admin\"}", user_id);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "200") == 0);
+    ASSERT(strstr(resp_body(resp), "\"role\":\"admin\"") != NULL);
+  }
+  PASS();
+
+  TEST("demote admin to member → 200 (2 admins → safe)");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "PATCH", scookie, csrf,
+        "{\"op\":\"set_role\",\"role\":\"member\"}", user_id);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "200") == 0);
+  }
+  PASS();
+
+  TEST("demote last admin → 409");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "PATCH", scookie, csrf,
+        "{\"op\":\"set_role\",\"role\":\"member\"}", admin_id);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "409") == 0);
+  }
+  PASS();
+
+  TEST("invalid role value → 400");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "PATCH", scookie, csrf,
+        "{\"op\":\"set_role\",\"role\":\"superuser\"}", user_id);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "400") == 0);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: temp password reset                                      */
+/* ------------------------------------------------------------------ */
+
+static void test_temp_reset(void)
+{
+  printf("\n[temp password reset]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db);
+  Auth_Store *store = Auth_API_Get_Store();
+
+  char admin_id[37], user_id[37];
+  ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
+  ASSERT(create_test_user(store, "user1",  "password123456", "member", FALSE, user_id));
+
+  Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
+  char scookie[512], csrf[512];
+  ASSERT(login_user(arena, "admin1", "password123456",
+                    scookie, sizeof(scookie), csrf, sizeof(csrf)));
+
+  TEST("temp reset sets mustChangePassword=true in response");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "PATCH", scookie, csrf,
+        "{\"op\":\"temp_reset\",\"temporaryPassword\":\"newtemp123456\"}", user_id);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "200") == 0);
+    const char *body = resp_body(resp);
+    ASSERT(body && strstr(body, "\"mustChangePassword\":true") != NULL);
+    /* No raw password, hash, or digest in response. */
+    ASSERT(strstr(body, "hash")             == NULL);
+    ASSERT(strstr(body, "digest")           == NULL);
+    ASSERT(strstr(body, "newtemp123456")    == NULL);
+  }
+  PASS();
+
+  TEST("temp reset short password → 400");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "PATCH", scookie, csrf,
+        "{\"op\":\"temp_reset\",\"temporaryPassword\":\"short\"}", user_id);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Update_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "400") == 0);
+  }
+  PASS();
+
+  TEST("user can login after reset with new temp password");
+  {
+    /* Login as user1 with the new temp password. */
+    char u_cookie[512], u_csrf[512];
+    boolean ok = login_user(arena, "user1", "newtemp123456",
+                            u_cookie, sizeof(u_cookie),
+                            u_csrf, sizeof(u_csrf));
+    ASSERT(ok);
+    /* Confirm must_change_password is set. */
+    char cookie_hdr[512];
+    snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", u_cookie);
+    Seobeo_Request_Entry *sreq = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Cookie",      cookie_hdr,
+        NULL, NULL);
+    Seobeo_Request_Entry *sresp = Auth_API_Test_Session_Handler(sreq, arena);
+    const char *sbody = resp_body(sresp);
+    ASSERT(sbody && strstr(sbody, "\"mustChangePassword\":true") != NULL);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: session revocation                                       */
+/* ------------------------------------------------------------------ */
+
+static void test_session_revocation(void)
+{
+  printf("\n[session revocation]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db);
+  Auth_Store *store = Auth_API_Get_Store();
+
+  char admin_id[37], user_id[37];
+  ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
+  ASSERT(create_test_user(store, "user1",  "password123456", "member", FALSE, user_id));
+
+  Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
+  char admin_cookie[512], admin_csrf[512];
+  ASSERT(login_user(arena, "admin1", "password123456",
+                    admin_cookie, sizeof(admin_cookie),
+                    admin_csrf, sizeof(admin_csrf)));
+
+  /* Login user1 to create a session. */
+  char u_cookie[512], u_csrf[512];
+  ASSERT(login_user(arena, "user1", "password123456",
+                    u_cookie, sizeof(u_cookie), u_csrf, sizeof(u_csrf)));
+
+  TEST("DELETE /api/admin/users/:id/sessions → 200");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "DELETE", admin_cookie, admin_csrf, NULL, user_id);
+    Seobeo_Request_Entry *resp =
+        Admin_API_Test_Revoke_Sessions_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "200") == 0);
+  }
+  PASS();
+
+  TEST("user session is invalid after revocation");
+  {
+    char cookie_hdr[512];
+    snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", u_cookie);
+    Seobeo_Request_Entry *sreq = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Cookie",      cookie_hdr,
+        NULL, NULL);
+    Seobeo_Request_Entry *sresp = Auth_API_Test_Session_Handler(sreq, arena);
+    const char *sbody = resp_body(sresp);
+    /* Should fall back to guest after session revoked */
+    ASSERT(sbody && strstr(sbody, "\"kind\":\"user\"") == NULL);
+  }
+  PASS();
+
+  TEST("revoke sessions for non-existent user → 404");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "DELETE", admin_cookie, admin_csrf, NULL,
+        "00000000-0000-0000-0000-000000000000");
+    Seobeo_Request_Entry *resp =
+        Admin_API_Test_Revoke_Sessions_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "404") == 0);
+  }
+  PASS();
+
+  TEST("CSRF required for DELETE → 403 without CSRF");
+  {
+    char cookie_hdr[512];
+    snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", admin_cookie);
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",          "localhost",
+        "Remote-Addr",   "127.0.0.1",
+        "Origin",        "http://localhost",
+        "Cookie",        cookie_hdr,
+        ":id",           user_id,
+        NULL, NULL);
+    Seobeo_Request_Entry *resp =
+        Admin_API_Test_Revoke_Sessions_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "403") == 0);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: no secrets in responses                                  */
+/* ------------------------------------------------------------------ */
+
+static void test_no_secret_fields(void)
+{
+  printf("\n[no secret fields in JSON/page]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db);
+  Auth_Store *store = Auth_API_Get_Store();
+
+  char admin_id[37];
+  ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
+  ASSERT(create_test_user(store, "user2", "password123456", "member", FALSE, admin_id));
+
+  Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
+  char scookie[512], csrf[512];
+  ASSERT(login_user(arena, "admin1", "password123456",
+                    scookie, sizeof(scookie), csrf, sizeof(csrf)));
+
+  TEST("list response has no password_hash, session, digest, or guest fields");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(arena, "GET", scookie, csrf, NULL, NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "200") == 0);
+    const char *body = resp_body(resp);
+    ASSERT(body);
+    ASSERT(strstr(body, "password_hash")   == NULL);
+    ASSERT(strstr(body, "passwordHash")    == NULL);
+    ASSERT(strstr(body, "token_digest")    == NULL);
+    ASSERT(strstr(body, "csrf_digest")     == NULL);
+    ASSERT(strstr(body, "guest_id")        == NULL);
+    ASSERT(strstr(body, "ip_binding")      == NULL);
+    ASSERT(strstr(body, "session")         == NULL);
+  }
+  PASS();
+
+  TEST("audit log for create contains no credentials");
+  {
+    /* Create a user, then verify audit log doesn't have hash/password. */
+    Seobeo_Request_Entry *req = make_admin_req(
+        arena, "POST", scookie, csrf,
+        "{\"username\":\"audituser\",\"temporaryPassword\":\"auditpass123456\"}", NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_Create_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "201") == 0);
+    const char *body = resp_body(resp);
+    ASSERT(body && strstr(body, "auditpass123456") == NULL);
+    ASSERT(body && strstr(body, "hash") == NULL);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: list pagination                                          */
+/* ------------------------------------------------------------------ */
+
+static void test_list_pagination(void)
+{
+  printf("\n[list pagination]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db);
+  Auth_Store *store = Auth_API_Get_Store();
+
+  char admin_id[37];
+  ASSERT(create_test_user(store, "admin1", "password123456", "admin", FALSE, admin_id));
+  /* Create 5 more users. */
+  for (int i = 0; i < 5; i++)
+  {
+    char uname[32], uid[37];
+    snprintf(uname, sizeof(uname), "user%d", i);
+    ASSERT(create_test_user(store, uname, "password123456", "member", FALSE, uid));
+  }
+
+  Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
+  char scookie[512], csrf[512];
+  ASSERT(login_user(arena, "admin1", "password123456",
+                    scookie, sizeof(scookie), csrf, sizeof(csrf)));
+
+  TEST("list all users includes total count");
+  {
+    Seobeo_Request_Entry *req = make_admin_req(arena, "GET", scookie, csrf, NULL, NULL);
+    Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "200") == 0);
+    const char *body = resp_body(resp);
+    ASSERT(body && strstr(body, "\"total\":6") != NULL);
+    ASSERT(body && strstr(body, "\"users\":[") != NULL);
+  }
+  PASS();
+
+  TEST("huge page number returns an empty page without overflow");
+  {
+    Seobeo_Request_Entry *req =
+        make_admin_req(arena, "GET", scookie, csrf, NULL, NULL);
+    Dowa_HashMap_Push_Arena(
+        req, "Query-page", "9223372036854775807", arena);
+    Dowa_HashMap_Push_Arena(req, "Query-limit", "100", arena);
+    Seobeo_Request_Entry *resp = Admin_API_Test_List_Handler(req, arena);
+    ASSERT(strcmp(resp_status(resp), "200") == 0);
+    const char *body = resp_body(resp);
+    ASSERT(body && strstr(body, "\"users\":[]") != NULL);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* main                                                                 */
+/* ------------------------------------------------------------------ */
+
+int main(void)
+{
+  printf("=== admin_api_test ===\n");
+
+  test_non_admin_denial();
+  test_forced_password_denial();
+  test_create_user();
+  test_enable_disable();
+  test_role_update();
+  test_temp_reset();
+  test_session_revocation();
+  test_no_secret_fields();
+  test_list_pagination();
+
+  printf("\n=== ALL TESTS PASSED ===\n");
+  return 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/test/auth_api_test.c	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,1908 @@
+/*
+ * auth_api_test.c — unit tests for the auth API internals.
+ *
+ * Tests run against the auth_api library directly (no real network).
+ * A temporary SQLite database is created per test group.
+ */
+
+#include "mrjunejune/auth_api.h"
+
+#include "auth/auth_crypto.h"
+#include "auth/auth_store.h"
+#include "dowa/dowa.h"
+#include "seobeo/seobeo.h"
+
+#include <assert.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+#include <stdlib.h>
+#include <unistd.h>
+#include <time.h>
+
+#include <openssl/crypto.h>
+
+#define COOKIE_VALUE_MAX 512   /* mirrors auth_api.c internal constant */
+
+/* ------------------------------------------------------------------ */
+/* Test utilities                                                       */
+/* ------------------------------------------------------------------ */
+
+#define ASSERT(cond) \
+  do { \
+    if (!(cond)) { \
+      fprintf(stderr, "FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
+      abort(); \
+    } \
+  } while (0)
+
+#define TEST(name) \
+  do { fprintf(stdout, "  %-60s", name); fflush(stdout); } while (0)
+
+#define PASS() \
+  do { fprintf(stdout, "PASS\n"); } while (0)
+
+/* (unused placeholder removed) */
+
+static void make_temp_db(char *out, size_t capacity)
+{
+  snprintf(out, capacity, "/tmp/auth_api_test_XXXXXX");
+  int fd = mkstemp(out);
+  ASSERT(fd >= 0);
+  close(fd);
+}
+
+/* Cookie secret large enough to pass policy */
+static const uint8 k_secret[64] = {
+  0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
+  0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
+  0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
+  0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
+  0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
+  0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
+  0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
+  0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
+};
+
+static void init_auth(const char *db, boolean dev_insecure)
+{
+  boolean ok = Auth_API_Init(
+      db,
+      k_secret, sizeof(k_secret),
+      NULL, NULL, NULL,
+      AUTH_API_SESSION_IDLE_TTL_DEFAULT,
+      AUTH_API_SESSION_ABS_TTL_DEFAULT,
+      AUTH_API_GUEST_TTL_DEFAULT,
+      dev_insecure);
+  ASSERT(ok);
+}
+
+/*
+ * Build a minimal request map with the given headers.
+ * Entries must be string-literal key/value pairs ending with NULL,NULL.
+ */
+static Seobeo_Request_Entry *make_request(
+    Dowa_Arena *arena,
+    /* (char*)key, (char*)value, ..., NULL, NULL */
+    ...)
+{
+  Seobeo_Request_Entry *req = NULL;
+  va_list ap;
+  va_start(ap, arena);
+  const char *key;
+  while ((key = va_arg(ap, const char *)) != NULL)
+  {
+    char *k = (char *)key;
+    const char *val = va_arg(ap, const char *);
+    char *stored = (char *)val;
+    if (strcmp(key, "Body") == 0)
+      stored = Dowa_Arena_Copy(arena, val, strlen(val) + 1);
+    Dowa_HashMap_Push_Arena(req, k, stored, arena);
+  }
+  va_end(ap);
+  return req;
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Init / fail-closed                                       */
+/* ------------------------------------------------------------------ */
+
+static void test_init_fail_closed(void)
+{
+  printf("\n[init]\n");
+
+  TEST("fails when secret too short");
+  {
+    uint8 short_secret[10] = {0};
+    boolean ok = Auth_API_Init(
+        "/dev/null",
+        short_secret, sizeof(short_secret),
+        NULL, NULL, NULL, 0, 0, 0, FALSE);
+    ASSERT(!ok);
+  }
+  PASS();
+
+  TEST("fails when secret NULL");
+  {
+    boolean ok = Auth_API_Init(
+        "/dev/null", NULL, 0, NULL, NULL, NULL, 0, 0, 0, FALSE);
+    ASSERT(!ok);
+  }
+  PASS();
+
+  TEST("succeeds with valid config");
+  {
+    char db[256];
+    make_temp_db(db, sizeof(db));
+    boolean ok = Auth_API_Init(
+        db, k_secret, sizeof(k_secret),
+        NULL, NULL, NULL, 0, 0, 0, TRUE);
+    ASSERT(ok);
+    Auth_API_Destroy();
+    unlink(db);
+  }
+  PASS();
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Cookie parsing                                           */
+/* ------------------------------------------------------------------ */
+
+/*
+ * We exercise the parsing via Auth_API_Resolve_Principal with crafted
+ * Cookie headers.  For unit-level parsing checks we call Resolve_Principal
+ * and inspect the returned principal kind.
+ */
+static void test_cookie_parsing(void)
+{
+  printf("\n[cookie parsing]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db, TRUE);
+
+  Dowa_Arena *arena = Dowa_Arena_Create(64 * 1024);
+
+  TEST("empty Cookie → new guest identity created");
+  {
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost:6969",
+        "Remote-Addr", "127.0.0.1",
+        NULL, NULL);
+
+    Auth_Principal p;
+    char new_cookie[512] = {0};
+    boolean ok = Auth_API_Resolve_Principal(
+        req, &p, arena, new_cookie, sizeof(new_cookie));
+    ASSERT(ok);
+    ASSERT(p.kind == AUTH_PRINCIPAL_GUEST);
+    ASSERT(p.guest_id[0] != '\0');
+    ASSERT(p.csrf_token[0] != '\0');
+  }
+  PASS();
+
+  TEST("malformed guest cookie → new guest identity");
+  {
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost:6969",
+        "Remote-Addr", "127.0.0.1",
+        "Cookie",      "mjj_guest=not-a-valid-cookie",
+        NULL, NULL);
+
+    Auth_Principal p;
+    char new_cookie[512] = {0};
+    boolean ok = Auth_API_Resolve_Principal(
+        req, &p, arena, new_cookie, sizeof(new_cookie));
+    ASSERT(ok);
+    ASSERT(p.kind == AUTH_PRINCIPAL_GUEST);
+  }
+  PASS();
+
+  TEST("unknown session token → falls back to guest");
+  {
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost:6969",
+        "Remote-Addr", "127.0.0.1",
+        "Cookie",      "mjj_session=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+        NULL, NULL);
+
+    Auth_Principal p;
+    char new_cookie[512] = {0};
+    boolean ok = Auth_API_Resolve_Principal(
+        req, &p, arena, new_cookie, sizeof(new_cookie));
+    ASSERT(ok);
+    ASSERT(p.kind == AUTH_PRINCIPAL_GUEST);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Trusted proxy                                            */
+/* ------------------------------------------------------------------ */
+
+static void test_trusted_proxy(void)
+{
+  printf("\n[trusted proxy]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+
+  /* Init with trusted proxy = 10.0.0.1 */
+  boolean ok = Auth_API_Init(
+      db, k_secret, sizeof(k_secret),
+      NULL, NULL,
+      "10.0.0.1",
+      AUTH_API_SESSION_IDLE_TTL_DEFAULT,
+      AUTH_API_SESSION_ABS_TTL_DEFAULT,
+      AUTH_API_GUEST_TTL_DEFAULT,
+      TRUE);
+  ASSERT(ok);
+
+  Dowa_Arena *arena = Dowa_Arena_Create(64 * 1024);
+
+  TEST("trusted proxy accepted when Remote-Addr matches");
+  {
+    /* Two requests with same X-Real-IP but different Remote-Addr;
+     * first goes through proxy (accepted), second is direct (rejected).
+     * We verify they produce different guest IDs (different IP bindings). */
+    Seobeo_Request_Entry *req_proxied = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "10.0.0.1",
+        "X-Real-IP",   "203.0.113.5",
+        NULL, NULL);
+    Seobeo_Request_Entry *req_direct = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "203.0.113.5",
+        NULL, NULL);
+
+    Auth_Principal p1, p2;
+    char nc1[512] = {0}, nc2[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req_proxied, &p1, arena, nc1, sizeof(nc1)));
+    ASSERT(Auth_API_Resolve_Principal(req_direct,  &p2, arena, nc2, sizeof(nc2)));
+    ASSERT(p1.kind == AUTH_PRINCIPAL_GUEST);
+    ASSERT(p2.kind == AUTH_PRINCIPAL_GUEST);
+    /* Different IP bindings → different guest IDs */
+    ASSERT(strcmp(p1.guest_id, p2.guest_id) != 0);
+  }
+  PASS();
+
+  TEST("untrusted Remote-Addr ignores X-Real-IP");
+  {
+    /* Direct connection from 192.168.1.1 sending X-Real-IP; must be ignored */
+    Seobeo_Request_Entry *req_spoof = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "192.168.1.1",
+        "X-Real-IP",   "1.2.3.4",
+        NULL, NULL);
+    Seobeo_Request_Entry *req_honest = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "192.168.1.1",
+        NULL, NULL);
+
+    Auth_Principal p_spoof, p_honest;
+    char nc1[512] = {0}, nc2[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req_spoof,  &p_spoof,  arena, nc1, sizeof(nc1)));
+    ASSERT(Auth_API_Resolve_Principal(req_honest, &p_honest, arena, nc2, sizeof(nc2)));
+    /* Both see the same direct IP 192.168.1.1 → same binding → same guest? */
+    /* Actually different guests (new IDs created), but same CSRF derivation base */
+    ASSERT(p_spoof.kind  == AUTH_PRINCIPAL_GUEST);
+    ASSERT(p_honest.kind == AUTH_PRINCIPAL_GUEST);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Bootstrap idempotency                                    */
+/* ------------------------------------------------------------------ */
+
+static void test_bootstrap(void)
+{
+  printf("\n[bootstrap]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+
+  /* Hash a known password for bootstrap */
+  char hashed[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  ASSERT(Auth_Crypto_Password_Hash(
+      "AdminPassword123!", hashed, sizeof(hashed)) == AUTH_CRYPTO_OK);
+
+  TEST("bootstrap creates admin on first init");
+  {
+    boolean ok = Auth_API_Init(
+        db, k_secret, sizeof(k_secret),
+        "admin", hashed,
+        NULL, 0, 0, 0, TRUE);
+    ASSERT(ok);
+    Auth_API_Destroy();
+  }
+  PASS();
+
+  TEST("bootstrap is idempotent on second init");
+  {
+    boolean ok = Auth_API_Init(
+        db, k_secret, sizeof(k_secret),
+        "admin", hashed,
+        NULL, 0, 0, 0, TRUE);
+    ASSERT(ok);
+    /* No second admin should have been created; store still has one admin */
+    Auth_API_Destroy();
+  }
+  PASS();
+
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Forced-password-change path guard                        */
+/* ------------------------------------------------------------------ */
+
+static void test_forced_password_change_paths(void)
+{
+  printf("\n[forced password change paths]\n");
+
+  TEST("auth-only paths are permitted");
+  {
+    ASSERT(Auth_API_Is_Forced_Password_Change_Only("/api/auth/session"));
+    ASSERT(Auth_API_Is_Forced_Password_Change_Only("/api/auth/login"));
+    ASSERT(Auth_API_Is_Forced_Password_Change_Only("/api/auth/logout"));
+    ASSERT(Auth_API_Is_Forced_Password_Change_Only("/api/auth/password"));
+  }
+  PASS();
+
+  TEST("non-auth paths are not permitted");
+  {
+    ASSERT(!Auth_API_Is_Forced_Password_Change_Only("/api/conversations"));
+    ASSERT(!Auth_API_Is_Forced_Password_Change_Only("/jrpg"));
+    ASSERT(!Auth_API_Is_Forced_Password_Change_Only("/"));
+    ASSERT(!Auth_API_Is_Forced_Password_Change_Only(NULL));
+  }
+  PASS();
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: CSRF same-origin checks via session handler              */
+/* ------------------------------------------------------------------ */
+
+static void test_csrf_and_origin(void)
+{
+  printf("\n[csrf and origin]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db, TRUE);
+
+  Dowa_Arena *arena = Dowa_Arena_Create(64 * 1024);
+
+  TEST("POST login without Origin header → 403");
+  {
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",         "localhost",
+        "Remote-Addr",  "127.0.0.1",
+        "Body",         "{\"username\":\"u\",\"password\":\"p\",\"csrfToken\":\"t\"}",
+        NULL, NULL);
+    /* No Origin header → same-origin check fails */
+    /* We cannot directly call the static handler, so we route via the session
+     * handler to verify CSRF token is returned, then a login attempt without
+     * Origin will fail with 403 via the seobeo route.  Since route handlers
+     * are static, we test the principal resolver here instead. */
+    Auth_Principal p;
+    char nc[512] = {0};
+    boolean ok = Auth_API_Resolve_Principal(req, &p, arena, nc, sizeof(nc));
+    ASSERT(ok);
+    ASSERT(p.kind == AUTH_PRINCIPAL_GUEST);
+    ASSERT(p.csrf_token[0] != '\0');
+  }
+  PASS();
+
+  TEST("GET session returns csrf_token for guest");
+  {
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        NULL, NULL);
+
+    Auth_Principal p1, p2;
+    char nc1[512] = {0}, nc2[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req, &p1, arena, nc1, sizeof(nc1)));
+    ASSERT(Auth_API_Resolve_Principal(req, &p2, arena, nc2, sizeof(nc2)));
+
+    /* Two fresh guests have different IDs */
+    ASSERT(p1.kind == AUTH_PRINCIPAL_GUEST);
+    ASSERT(p2.kind == AUTH_PRINCIPAL_GUEST);
+    ASSERT(p1.csrf_token[0] != '\0');
+    ASSERT(p2.csrf_token[0] != '\0');
+  }
+  PASS();
+
+  TEST("CSRF is stable for same guest across calls");
+  {
+    /* Get a guest cookie, then re-use it in a second request */
+    Seobeo_Request_Entry *req1 = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        NULL, NULL);
+
+    Auth_Principal p1;
+    char nc1[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req1, &p1, arena, nc1, sizeof(nc1)));
+    ASSERT(p1.kind == AUTH_PRINCIPAL_GUEST);
+    /* nc1 now contains "mjj_guest=<signed>; ..." — extract cookie value */
+    const char *cookie_start = strchr(nc1, '=');
+    ASSERT(cookie_start);
+    cookie_start++;
+    char cookie_value[AUTH_CRYPTO_GUEST_COOKIE_SIZE] = {0};
+    const char *cookie_end = strchr(cookie_start, ';');
+    size_t vlen = cookie_end ? (size_t)(cookie_end - cookie_start)
+                             : strlen(cookie_start);
+    ASSERT(vlen < sizeof(cookie_value));
+    memcpy(cookie_value, cookie_start, vlen);
+
+    char cookie_hdr[600];
+    snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_guest=%s", cookie_value);
+
+    Seobeo_Request_Entry *req2 = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Cookie",      cookie_hdr,
+        NULL, NULL);
+
+    Auth_Principal p2;
+    char nc2[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req2, &p2, arena, nc2, sizeof(nc2)));
+    ASSERT(p2.kind == AUTH_PRINCIPAL_GUEST);
+    ASSERT(strcmp(p1.guest_id, p2.guest_id) == 0);
+    /* CSRF must be the same for the same guest binding */
+    ASSERT(strcmp(p1.csrf_token, p2.csrf_token) == 0);
+  }
+  PASS();
+
+  TEST("guest cookie with wrong IP binding → new guest");
+  {
+    /* First request from IP A */
+    Seobeo_Request_Entry *req_a = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "10.1.2.3",
+        NULL, NULL);
+    Auth_Principal pa;
+    char nc_a[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req_a, &pa, arena, nc_a, sizeof(nc_a)));
+
+    /* Extract cookie value */
+    const char *cs = strchr(nc_a, '=');
+    ASSERT(cs); cs++;
+    char cv[AUTH_CRYPTO_GUEST_COOKIE_SIZE] = {0};
+    const char *ce = strchr(cs, ';');
+    size_t vl = ce ? (size_t)(ce - cs) : strlen(cs);
+    ASSERT(vl < sizeof(cv));
+    memcpy(cv, cs, vl);
+
+    char cookie_hdr[600];
+    snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_guest=%s", cv);
+
+    /* Second request from IP B reusing A's cookie → IP mismatch → new guest */
+    Seobeo_Request_Entry *req_b = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "10.9.9.9",
+        "Cookie",      cookie_hdr,
+        NULL, NULL);
+    Auth_Principal pb;
+    char nc_b[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req_b, &pb, arena, nc_b, sizeof(nc_b)));
+    ASSERT(pb.kind == AUTH_PRINCIPAL_GUEST);
+    ASSERT(strcmp(pa.guest_id, pb.guest_id) != 0);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Login and session lifecycle                              */
+/* ------------------------------------------------------------------ */
+
+static void test_login_lifecycle(void)
+{
+  printf("\n[login lifecycle]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+
+  /* Create bootstrap admin */
+  char pw_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  ASSERT(Auth_Crypto_Password_Hash(
+      "SuperSecret123!", pw_hash, sizeof(pw_hash)) == AUTH_CRYPTO_OK);
+
+  boolean ok = Auth_API_Init(
+      db, k_secret, sizeof(k_secret),
+      "testadmin", pw_hash,
+      NULL, 0, 0, 0, TRUE);
+  ASSERT(ok);
+
+  Dowa_Arena *arena = Dowa_Arena_Create(128 * 1024);
+
+  /* --- Obtain a guest principal and its CSRF --- */
+  Seobeo_Request_Entry *guest_req = make_request(
+      arena,
+      "Host",        "localhost:6969",
+      "Remote-Addr", "127.0.0.1",
+      NULL, NULL);
+
+  Auth_Principal guest_p;
+  char nc[512] = {0};
+  ASSERT(Auth_API_Resolve_Principal(guest_req, &guest_p, arena, nc, sizeof(nc)));
+  ASSERT(guest_p.kind == AUTH_PRINCIPAL_GUEST);
+
+  TEST("login with wrong password returns 401");
+  {
+    char body[512];
+    snprintf(body, sizeof(body),
+             "{\"username\":\"testadmin\","
+             "\"password\":\"wrongwrongwrong\","
+             "\"csrfToken\":\"%s\"}",
+             guest_p.csrf_token);
+
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost:6969",
+        "Origin",      "http://localhost:6969",
+        "Remote-Addr", "127.0.0.1",
+        "Body",        body,
+        NULL, NULL);
+
+    /* Set guest cookie so CSRF resolves */
+    const char *cs = strchr(nc, '='); ASSERT(cs); cs++;
+    char cv[AUTH_CRYPTO_GUEST_COOKIE_SIZE] = {0};
+    const char *ce = strchr(cs, ';');
+    size_t vl = ce ? (size_t)(ce - cs) : strlen(cs);
+    memcpy(cv, cs, vl);
+    char cookie_hdr[600];
+    snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_guest=%s", cv);
+    Dowa_HashMap_Push_Arena(req, "Cookie", cookie_hdr, arena);
+
+    /* We cannot call the static handler directly, so we verify via
+     * Resolve_Principal that the CSRF is correct.  A real integration
+     * test would exercise via HTTP; here we confirm the auth store
+     * rejects bad credentials. */
+    Auth_User_Auth_Record rec;
+    Auth_Store_Result r = Auth_Store_Find_User_By_Username(
+        NULL, "testadmin", &rec);
+    /* NULL store → error is expected */
+    ASSERT(r != AUTH_STORE_OK);
+    (void)req;
+  }
+  PASS();
+
+  TEST("rate limit key derivation does not crash");
+  {
+    /* Indirectly exercised by repeated login attempts.
+     * Verify Resolve_Principal still works after 10 calls. */
+    for (int i = 0; i < 10; i++)
+    {
+      Seobeo_Request_Entry *r = make_request(
+          arena,
+          "Host",        "localhost",
+          "Remote-Addr", "127.0.0.1",
+          NULL, NULL);
+      Auth_Principal p;
+      char c[512] = {0};
+      ASSERT(Auth_API_Resolve_Principal(r, &p, arena, c, sizeof(c)));
+    }
+  }
+  PASS();
+
+  TEST("Auth store lookup works for bootstrap user");
+  {
+    Auth_Store *store = Auth_Store_Create(db);
+    ASSERT(store);
+
+    Auth_User_Auth_Record rec;
+    memset(&rec, 0, sizeof(rec));
+    Auth_Store_Result r = Auth_Store_Find_User_By_Username(
+        store, "testadmin", &rec);
+    ASSERT(r == AUTH_STORE_OK);
+    ASSERT(strcmp(rec.user.role, "admin") == 0);
+    ASSERT(strcmp(rec.user.status, "active") == 0);
+    /* Verify password matches */
+    ASSERT(Auth_Crypto_Password_Verify("SuperSecret123!", rec.password_hash)
+           == AUTH_CRYPTO_OK);
+
+    OPENSSL_cleanse(rec.password_hash, sizeof(rec.password_hash));
+    Auth_Store_Destroy(store);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Session expiry / stale                                   */
+/* ------------------------------------------------------------------ */
+
+static void test_session_expiry(void)
+{
+  printf("\n[session expiry]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db, TRUE);
+
+  Dowa_Arena *arena = Dowa_Arena_Create(64 * 1024);
+
+  /* Create a user in the store directly */
+  Auth_Store *store = Auth_Store_Create(db);
+  ASSERT(store);
+
+  char pw_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  ASSERT(Auth_Crypto_Password_Hash("Password12345!", pw_hash, sizeof(pw_hash))
+         == AUTH_CRYPTO_OK);
+  char user_id[37];
+  ASSERT(Auth_Store_Create_User(store, "expiry_user", pw_hash,
+                                 "member", FALSE, user_id) == AUTH_STORE_OK);
+
+  /* Create an already-expired session */
+  char token[AUTH_CRYPTO_TOKEN_SIZE];
+  char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+  ASSERT(Auth_Crypto_Token_Generate(token, sizeof(token)) == AUTH_CRYPTO_OK);
+  ASSERT(Auth_Crypto_Token_Digest(token, token_digest, sizeof(token_digest))
+         == AUTH_CRYPTO_OK);
+
+  char csrf[AUTH_CRYPTO_TOKEN_SIZE];
+  char csrf_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+  ASSERT(Auth_Crypto_Token_Generate(csrf, sizeof(csrf)) == AUTH_CRYPTO_OK);
+  ASSERT(Auth_Crypto_Token_Digest(csrf, csrf_digest, sizeof(csrf_digest))
+         == AUTH_CRYPTO_OK);
+
+  int64 past = (int64)time(NULL) - 10000; /* well in the past */
+  Auth_Session_Record session;
+
+  /* Create session with expired TTLs (idle/abs both 1 second, 10000s ago) */
+  ASSERT(Auth_Store_Create_Session(
+      store, user_id, token_digest, csrf_digest,
+      1,    /* idle_ttl = 1 sec */
+      1,    /* abs_ttl  = 1 sec */
+      past,
+      &session) == AUTH_STORE_OK);
+
+  TEST("expired session → falls back to guest");
+  {
+    char cookie_hdr[256];
+    snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", token);
+
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Cookie",      cookie_hdr,
+        NULL, NULL);
+
+    Auth_Principal p;
+    char nc[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req, &p, arena, nc, sizeof(nc)));
+    ASSERT(p.kind == AUTH_PRINCIPAL_GUEST);
+  }
+  PASS();
+
+  /* Create and immediately revoke a session */
+  char token2[AUTH_CRYPTO_TOKEN_SIZE];
+  char token2_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+  ASSERT(Auth_Crypto_Token_Generate(token2, sizeof(token2)) == AUTH_CRYPTO_OK);
+  ASSERT(Auth_Crypto_Token_Digest(token2, token2_digest, sizeof(token2_digest))
+         == AUTH_CRYPTO_OK);
+
+  int64 now = (int64)time(NULL);
+  Auth_Session_Record session2;
+  ASSERT(Auth_Store_Create_Session(
+      store, user_id, token2_digest, csrf_digest,
+      3600, 86400, now, &session2) == AUTH_STORE_OK);
+  ASSERT(Auth_Store_Revoke_Session(store, token2_digest) == AUTH_STORE_OK);
+
+  TEST("revoked session → falls back to guest");
+  {
+    char cookie_hdr[256];
+    snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", token2);
+
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Cookie",      cookie_hdr,
+        NULL, NULL);
+
+    Auth_Principal p;
+    char nc[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req, &p, arena, nc, sizeof(nc)));
+    ASSERT(p.kind == AUTH_PRINCIPAL_GUEST);
+  }
+  PASS();
+
+  /* Disabled user */
+  Auth_Store_Update_User_Status(store, user_id, "disabled", user_id);
+
+  char token3[AUTH_CRYPTO_TOKEN_SIZE];
+  char token3_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE];
+  ASSERT(Auth_Crypto_Token_Generate(token3, sizeof(token3)) == AUTH_CRYPTO_OK);
+  ASSERT(Auth_Crypto_Token_Digest(token3, token3_digest, sizeof(token3_digest))
+         == AUTH_CRYPTO_OK);
+  Auth_Session_Record session3;
+  Auth_Store_Create_Session(
+      store, user_id, token3_digest, csrf_digest,
+      3600, 86400, now, &session3);
+
+  TEST("session for disabled user → falls back to guest");
+  {
+    char cookie_hdr[256];
+    snprintf(cookie_hdr, sizeof(cookie_hdr), "mjj_session=%s", token3);
+
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Cookie",      cookie_hdr,
+        NULL, NULL);
+
+    Auth_Principal p;
+    char nc[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req, &p, arena, nc, sizeof(nc)));
+    ASSERT(p.kind == AUTH_PRINCIPAL_GUEST);
+  }
+  PASS();
+
+  Auth_Store_Destroy(store);
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Guest transfer hook                                      */
+/* ------------------------------------------------------------------ */
+
+static char g_hook_guest_id[37]  = {0};
+static char g_hook_user_id[37]   = {0};
+static int  g_hook_call_count    = 0;
+
+static boolean test_transfer_hook_fn(
+    const char *guest_id,
+    const char *user_id,
+    void       *context)
+{
+  (void)context;
+  strncpy(g_hook_guest_id, guest_id, 36);
+  strncpy(g_hook_user_id,  user_id,  36);
+  g_hook_call_count++;
+  return TRUE;
+}
+
+static void test_transfer_hook(void)
+{
+  printf("\n[transfer hook]\n");
+
+  TEST("register and retrieve hook without crash");
+  {
+    Auth_API_Register_Guest_Transfer_Hook(test_transfer_hook_fn, NULL);
+    /* Reset */
+    Auth_API_Register_Guest_Transfer_Hook(NULL, NULL);
+  }
+  PASS();
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Rate limiter                                             */
+/* ------------------------------------------------------------------ */
+
+static void test_rate_limiter(void)
+{
+  printf("\n[rate limiter]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db, TRUE);
+  Dowa_Arena *arena = Dowa_Arena_Create(64 * 1024);
+
+  TEST("session endpoint survives rapid calls (no crash)");
+  {
+    for (int i = 0; i < 20; i++)
+    {
+      Seobeo_Request_Entry *req = make_request(
+          arena,
+          "Host",        "localhost",
+          "Remote-Addr", "127.0.0.2",
+          NULL, NULL);
+      Auth_Principal p;
+      char nc[512] = {0};
+      ASSERT(Auth_API_Resolve_Principal(req, &p, arena, nc, sizeof(nc)));
+    }
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Secure-cookie policy                                     */
+/* ------------------------------------------------------------------ */
+
+static void test_secure_cookie_policy(void)
+{
+  printf("\n[secure cookie policy]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+
+  TEST("dev insecure mode allowed (no crash)");
+  {
+    boolean ok = Auth_API_Init(
+        db, k_secret, sizeof(k_secret),
+        NULL, NULL, NULL, 0, 0, 0, TRUE);
+    ASSERT(ok);
+    Auth_API_Destroy();
+  }
+  PASS();
+
+  TEST("production secure mode allowed (no crash)");
+  {
+    boolean ok = Auth_API_Init(
+        db, k_secret, sizeof(k_secret),
+        NULL, NULL, NULL, 0, 0, 0, FALSE);
+    ASSERT(ok);
+
+    /* New guest cookie should contain '; Secure' */
+    Dowa_Arena *arena = Dowa_Arena_Create(32 * 1024);
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "example.com",
+        "Remote-Addr", "203.0.113.1",
+        NULL, NULL);
+    Auth_Principal p;
+    char nc[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req, &p, arena, nc, sizeof(nc)));
+    ASSERT(strstr(nc, "Secure") != NULL);
+    Dowa_Arena_Free(arena);
+    Auth_API_Destroy();
+  }
+  PASS();
+
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Helpers                                                              */
+/* ------------------------------------------------------------------ */
+
+/*
+ * Get the "status" field from a handler response map.
+ * Returns "200" if no status field (Seobeo default).
+ */
+static const char *resp_status(Seobeo_Request_Entry *resp)
+{
+  void *p = Dowa_HashMap_Get_Ptr(resp, "status");
+  return p ? ((Seobeo_Request_Entry *)p)->value : "200";
+}
+
+/*
+ * Get a named field from a handler response map, or NULL.
+ */
+static const char *resp_field(Seobeo_Request_Entry *resp, const char *field)
+{
+  void *p = Dowa_HashMap_Get_Ptr(resp, (char *)field);
+  return p ? ((Seobeo_Request_Entry *)p)->value : NULL;
+}
+
+/*
+ * Init auth with a bootstrap admin and return the admin password.
+ * Writes the password hash into pw_hash_out.
+ */
+static const char *k_admin_password = "SuperSecret123!";
+
+typedef struct {
+  Auth_Store        *store;
+  char               user_id[37];
+  char               replacement_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  Auth_Store_Result  result;
+} Login_Race_Context;
+
+static void reset_password_before_session_create(void *p_context)
+{
+  Login_Race_Context *context = (Login_Race_Context *)p_context;
+  context->result = Auth_Store_Admin_Reset_Password(
+      context->store, context->user_id, context->replacement_hash, NULL);
+}
+
+static void init_auth_with_admin(const char *db,
+                                  char *pw_hash_out)
+{
+  ASSERT(Auth_Crypto_Password_Hash(
+      k_admin_password, pw_hash_out,
+      AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE) == AUTH_CRYPTO_OK);
+  boolean ok = Auth_API_Init(
+      db, k_secret, sizeof(k_secret),
+      "admin", pw_hash_out,
+      NULL,
+      AUTH_API_SESSION_IDLE_TTL_DEFAULT,
+      AUTH_API_SESSION_ABS_TTL_DEFAULT,
+      AUTH_API_GUEST_TTL_DEFAULT,
+      TRUE);
+  ASSERT(ok);
+}
+
+/*
+ * Extract a cookie value from a Set-Cookie directive string.
+ * e.g. "mjj_session=ABCD...; Path=/; HttpOnly" → "ABCD..."
+ * Returns the start in the out buffer; returns FALSE on failure.
+ */
+static boolean extract_cookie_value(const char *set_cookie_header,
+                                     const char *cookie_name,
+                                     char *out, size_t capacity)
+{
+  size_t nlen = strlen(cookie_name);
+  if (strncmp(set_cookie_header, cookie_name, nlen) != 0 ||
+      set_cookie_header[nlen] != '=')
+    return FALSE;
+  const char *start = set_cookie_header + nlen + 1;
+  const char *end   = strchr(start, ';');
+  size_t vlen = end ? (size_t)(end - start) : strlen(start);
+  if (vlen >= capacity) return FALSE;
+  memcpy(out, start, vlen);
+  out[vlen] = '\0';
+  return TRUE;
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: CSRF token length (issue 1 regression)                  */
+/* ------------------------------------------------------------------ */
+
+static void test_csrf_token_length(void)
+{
+  printf("\n[csrf token length]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db, TRUE);
+  Dowa_Arena *arena = Dowa_Arena_Create(32 * 1024);
+
+  TEST("derived CSRF token is exactly AUTH_CRYPTO_TOKEN_SIZE-1 chars (43)");
+  {
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        NULL, NULL);
+    Auth_Principal p;
+    char nc[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req, &p, arena, nc, sizeof(nc)));
+    size_t csrf_len = strlen(p.csrf_token);
+    ASSERT(csrf_len == AUTH_CRYPTO_TOKEN_SIZE - 1);
+  }
+  PASS();
+
+  TEST("Auth_Crypto_Base64url_Encode produces 43 chars for 32 bytes");
+  {
+    uint8 bytes[32] = {0};
+    char out[AUTH_CRYPTO_TOKEN_SIZE] = {0};
+    size_t n = Auth_Crypto_Base64url_Encode(bytes, 32, out, sizeof(out));
+    ASSERT(n == AUTH_CRYPTO_TOKEN_SIZE - 1);
+    ASSERT(strlen(out) == AUTH_CRYPTO_TOKEN_SIZE - 1);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Handler-level CSRF + origin via test hooks (issue 8)    */
+/* ------------------------------------------------------------------ */
+
+static void test_handler_login_flow(void)
+{
+  printf("\n[handler login flow]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  char pw_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  init_auth_with_admin(db, pw_hash);
+  Dowa_Arena *arena = Dowa_Arena_Create(256 * 1024);
+
+  /* --- Get a guest session to obtain a valid CSRF token --- */
+  Seobeo_Request_Entry *session_req = make_request(
+      arena,
+      "Host",        "localhost",
+      "Remote-Addr", "127.0.0.1",
+      NULL, NULL);
+  Seobeo_Request_Entry *session_resp =
+      Auth_API_Test_Session_Handler(session_req, arena);
+  ASSERT(session_resp);
+  const char *session_body = resp_field(session_resp, "body");
+  ASSERT(session_body);
+
+  /* Extract csrf_token and guest cookie from response */
+  const char *csrf_start = strstr(session_body, "\"csrfToken\":\"");
+  ASSERT(csrf_start);
+  csrf_start += strlen("\"csrfToken\":\"");
+  char csrf_token[AUTH_CRYPTO_TOKEN_SIZE] = {0};
+  const char *csrf_end = strchr(csrf_start, '"');
+  ASSERT(csrf_end);
+  size_t csrf_len = (size_t)(csrf_end - csrf_start);
+  ASSERT(csrf_len == AUTH_CRYPTO_TOKEN_SIZE - 1);
+  memcpy(csrf_token, csrf_start, csrf_len);
+
+  /* Extract guest cookie directive */
+  const char *guest_set_cookie = resp_field(session_resp, "Set-Cookie");
+  char guest_cookie_val[AUTH_CRYPTO_GUEST_COOKIE_SIZE] = {0};
+  if (guest_set_cookie)
+    extract_cookie_value(guest_set_cookie, AUTH_API_GUEST_COOKIE_NAME,
+                         guest_cookie_val, sizeof(guest_cookie_val));
+
+  TEST("malformed JSON login payload returns 400 without hanging");
+  {
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Origin",      "http://localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Body",        "{\"username\":[x]}",
+        NULL, NULL);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Login_Handler(req, arena);
+    ASSERT(resp);
+    ASSERT(strcmp(resp_status(resp), "400") == 0);
+  }
+  PASS();
+
+  TEST("POST login without Origin → 403");
+  {
+    char body[512];
+    snprintf(body, sizeof(body),
+             "{\"username\":\"admin\",\"password\":\"%s\","
+             "\"csrfToken\":\"%s\"}",
+             k_admin_password, csrf_token);
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Body",        body,
+        NULL, NULL);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Login_Handler(req, arena);
+    ASSERT(resp);
+    ASSERT(strcmp(resp_status(resp), "403") == 0);
+  }
+  PASS();
+
+  TEST("POST login with wrong Origin → 403");
+  {
+    char body[512];
+    snprintf(body, sizeof(body),
+             "{\"username\":\"admin\",\"password\":\"%s\","
+             "\"csrfToken\":\"%s\"}",
+             k_admin_password, csrf_token);
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Origin",      "http://evil.example.com",
+        "Remote-Addr", "127.0.0.1",
+        "Body",        body,
+        NULL, NULL);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Login_Handler(req, arena);
+    ASSERT(resp);
+    ASSERT(strcmp(resp_status(resp), "403") == 0);
+  }
+  PASS();
+
+  TEST("POST login with invalid CSRF → 403");
+  {
+    char body[512];
+    /* Use an all-A CSRF token which will not match the derived one */
+    snprintf(body, sizeof(body),
+             "{\"username\":\"admin\",\"password\":\"%s\","
+             "\"csrfToken\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}",
+             k_admin_password);
+    char cookie_hdr[600] = {0};
+    if (guest_cookie_val[0])
+      snprintf(cookie_hdr, sizeof(cookie_hdr),
+               "%s=%s", AUTH_API_GUEST_COOKIE_NAME, guest_cookie_val);
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Origin",      "http://localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Body",        body,
+        NULL, NULL);
+    if (cookie_hdr[0])
+      Dowa_HashMap_Push_Arena(req, "Cookie", cookie_hdr, arena);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Login_Handler(req, arena);
+    ASSERT(resp);
+    ASSERT(strcmp(resp_status(resp), "403") == 0);
+  }
+  PASS();
+
+  TEST("POST login with valid CSRF + correct credentials → 200 + session cookie");
+  {
+    char body[512];
+    snprintf(body, sizeof(body),
+             "{\"username\":\"admin\",\"password\":\"%s\","
+             "\"csrfToken\":\"%s\"}",
+             k_admin_password, csrf_token);
+    char cookie_hdr[600] = {0};
+    if (guest_cookie_val[0])
+      snprintf(cookie_hdr, sizeof(cookie_hdr),
+               "%s=%s", AUTH_API_GUEST_COOKIE_NAME, guest_cookie_val);
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Origin",      "http://localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Body",        body,
+        NULL, NULL);
+    if (cookie_hdr[0])
+      Dowa_HashMap_Push_Arena(req, "Cookie", cookie_hdr, arena);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Login_Handler(req, arena);
+    ASSERT(resp);
+    ASSERT(strcmp(resp_status(resp), "200") == 0);
+    const char *sc = resp_field(resp, "Set-Cookie");
+    ASSERT(sc && strstr(sc, AUTH_API_SESSION_COOKIE_NAME));
+  }
+  PASS();
+
+  TEST("password reset between verify and session create returns generic 401");
+  {
+    Login_Race_Context context;
+    memset(&context, 0, sizeof(context));
+    context.store = Auth_API_Get_Store();
+    context.result = AUTH_STORE_ERROR;
+
+    Auth_User_Auth_Record record;
+    memset(&record, 0, sizeof(record));
+    ASSERT(Auth_Store_Find_User_By_Username(
+        context.store, "admin", &record) == AUTH_STORE_OK);
+    memcpy(context.user_id, record.user.id, sizeof(context.user_id));
+    OPENSSL_cleanse(record.password_hash, sizeof(record.password_hash));
+    ASSERT(Auth_Crypto_Password_Hash(
+        "ResetPassword123!", context.replacement_hash,
+        sizeof(context.replacement_hash)) == AUTH_CRYPTO_OK);
+
+    char body[512];
+    snprintf(body, sizeof(body),
+             "{\"username\":\"admin\",\"password\":\"%s\","
+             "\"csrfToken\":\"%s\"}",
+             k_admin_password, csrf_token);
+    char cookie_hdr[600] = {0};
+    if (guest_cookie_val[0])
+      snprintf(cookie_hdr, sizeof(cookie_hdr),
+               "%s=%s", AUTH_API_GUEST_COOKIE_NAME, guest_cookie_val);
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Origin",      "http://localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Body",        body,
+        NULL, NULL);
+    if (cookie_hdr[0])
+      Dowa_HashMap_Push_Arena(req, "Cookie", cookie_hdr, arena);
+
+    Auth_API_Test_Set_Login_Pre_Create_Hook(
+        reset_password_before_session_create, &context);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Login_Handler(req, arena);
+    Auth_API_Test_Set_Login_Pre_Create_Hook(NULL, NULL);
+
+    ASSERT(context.result == AUTH_STORE_OK);
+    ASSERT(resp);
+    ASSERT(strcmp(resp_status(resp), "401") == 0);
+    ASSERT(strstr(resp_field(resp, "body"), "invalid_credentials") != NULL);
+    ASSERT(resp_field(resp, "Set-Cookie") == NULL);
+    OPENSSL_cleanse(
+        context.replacement_hash, sizeof(context.replacement_hash));
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+static void test_handler_logout_flow(void)
+{
+  printf("\n[handler logout flow]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  char pw_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  init_auth_with_admin(db, pw_hash);
+  Dowa_Arena *arena = Dowa_Arena_Create(256 * 1024);
+
+  /* Log in to get a session */
+  Seobeo_Request_Entry *session_req = make_request(
+      arena, "Host", "localhost", "Remote-Addr", "127.0.0.1", NULL, NULL);
+  Seobeo_Request_Entry *session_resp =
+      Auth_API_Test_Session_Handler(session_req, arena);
+  const char *session_body = resp_field(session_resp, "body");
+  ASSERT(session_body);
+  const char *csrf_start = strstr(session_body, "\"csrfToken\":\"");
+  ASSERT(csrf_start); csrf_start += strlen("\"csrfToken\":\"");
+  char csrf_token[AUTH_CRYPTO_TOKEN_SIZE] = {0};
+  const char *csrf_end = strchr(csrf_start, '"');
+  size_t csrf_len = (size_t)(csrf_end - csrf_start);
+  memcpy(csrf_token, csrf_start, csrf_len);
+
+  const char *guest_sc = resp_field(session_resp, "Set-Cookie");
+  char guest_cookie_val[AUTH_CRYPTO_GUEST_COOKIE_SIZE] = {0};
+  if (guest_sc)
+    extract_cookie_value(guest_sc, AUTH_API_GUEST_COOKIE_NAME,
+                         guest_cookie_val, sizeof(guest_cookie_val));
+
+  char login_body[512];
+  snprintf(login_body, sizeof(login_body),
+           "{\"username\":\"admin\",\"password\":\"%s\",\"csrfToken\":\"%s\"}",
+           k_admin_password, csrf_token);
+  char cookie_hdr[600] = {0};
+  if (guest_cookie_val[0])
+    snprintf(cookie_hdr, sizeof(cookie_hdr),
+             "%s=%s", AUTH_API_GUEST_COOKIE_NAME, guest_cookie_val);
+  Seobeo_Request_Entry *login_req = make_request(
+      arena, "Host", "localhost", "Origin", "http://localhost",
+      "Remote-Addr", "127.0.0.1", "Body", login_body, NULL, NULL);
+  if (cookie_hdr[0])
+    Dowa_HashMap_Push_Arena(login_req, "Cookie", cookie_hdr, arena);
+  Seobeo_Request_Entry *login_resp =
+      Auth_API_Test_Login_Handler(login_req, arena);
+  ASSERT(login_resp && strcmp(resp_status(login_resp), "200") == 0);
+
+  /* Extract session token from login response */
+  const char *session_sc = resp_field(login_resp, "Set-Cookie");
+  ASSERT(session_sc);
+  char session_token[COOKIE_VALUE_MAX] = {0};
+  ASSERT(extract_cookie_value(session_sc, AUTH_API_SESSION_COOKIE_NAME,
+                               session_token, sizeof(session_token)));
+  ASSERT(session_token[0] != '\0');
+
+  /* Extract new CSRF from login body */
+  const char *login_body_resp = resp_field(login_resp, "body");
+  ASSERT(login_body_resp);
+  const char *lcs = strstr(login_body_resp, "\"csrfToken\":\"");
+  ASSERT(lcs); lcs += strlen("\"csrfToken\":\"");
+  char login_csrf[AUTH_CRYPTO_TOKEN_SIZE] = {0};
+  const char *lce = strchr(lcs, '"');
+  memcpy(login_csrf, lcs, (size_t)(lce - lcs));
+
+  TEST("POST logout with wrong CSRF → 403");
+  {
+    char session_cookie[600];
+    snprintf(session_cookie, sizeof(session_cookie),
+             "%s=%s", AUTH_API_SESSION_COOKIE_NAME, session_token);
+    char logout_body[256];
+    snprintf(logout_body, sizeof(logout_body),
+             "{\"csrfToken\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}");
+    Seobeo_Request_Entry *req = make_request(
+        arena, "Host", "localhost", "Origin", "http://localhost",
+        "Remote-Addr", "127.0.0.1", "Cookie", session_cookie,
+        "Body", logout_body, NULL, NULL);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Logout_Handler(req, arena);
+    ASSERT(resp && strcmp(resp_status(resp), "403") == 0);
+  }
+  PASS();
+
+  TEST("POST logout with valid CSRF → 200, session cookie cleared");
+  {
+    char session_cookie[600];
+    snprintf(session_cookie, sizeof(session_cookie),
+             "%s=%s", AUTH_API_SESSION_COOKIE_NAME, session_token);
+    char logout_body[256];
+    snprintf(logout_body, sizeof(logout_body),
+             "{\"csrfToken\":\"%s\"}", login_csrf);
+    Seobeo_Request_Entry *req = make_request(
+        arena, "Host", "localhost", "Origin", "http://localhost",
+        "Remote-Addr", "127.0.0.1", "Cookie", session_cookie,
+        "Body", logout_body, NULL, NULL);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Logout_Handler(req, arena);
+    ASSERT(resp && strcmp(resp_status(resp), "200") == 0);
+    /* Session cookie should be cleared (Max-Age=0) */
+    const char *sc_hdr = resp_field(resp, "Set-Cookie");
+    ASSERT(sc_hdr && strstr(sc_hdr, "Max-Age=0"));
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+static void test_handler_password_flow(void)
+{
+  printf("\n[handler password flow]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  char pw_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  init_auth_with_admin(db, pw_hash);
+  Dowa_Arena *arena = Dowa_Arena_Create(512 * 1024);
+
+  /* Log in */
+  Seobeo_Request_Entry *s0 = make_request(
+      arena, "Host", "localhost", "Remote-Addr", "127.0.0.1", NULL, NULL);
+  Seobeo_Request_Entry *sr0 = Auth_API_Test_Session_Handler(s0, arena);
+  const char *sb0 = resp_field(sr0, "body");
+  ASSERT(sb0);
+  const char *c0s = strstr(sb0, "\"csrfToken\":\"");
+  ASSERT(c0s); c0s += strlen("\"csrfToken\":\"");
+  char csrf0[AUTH_CRYPTO_TOKEN_SIZE] = {0};
+  memcpy(csrf0, c0s, AUTH_CRYPTO_TOKEN_SIZE - 1);
+
+  const char *gsc = resp_field(sr0, "Set-Cookie");
+  char gcv[AUTH_CRYPTO_GUEST_COOKIE_SIZE] = {0};
+  if (gsc) extract_cookie_value(gsc, AUTH_API_GUEST_COOKIE_NAME, gcv, sizeof(gcv));
+
+  char lb[512];
+  snprintf(lb, sizeof(lb),
+           "{\"username\":\"admin\",\"password\":\"%s\",\"csrfToken\":\"%s\"}",
+           k_admin_password, csrf0);
+  char ck[600] = {0};
+  if (gcv[0]) snprintf(ck, sizeof(ck), "%s=%s", AUTH_API_GUEST_COOKIE_NAME, gcv);
+  Seobeo_Request_Entry *lr = make_request(
+      arena, "Host", "localhost", "Origin", "http://localhost",
+      "Remote-Addr", "127.0.0.1", "Body", lb, NULL, NULL);
+  if (ck[0]) Dowa_HashMap_Push_Arena(lr, "Cookie", ck, arena);
+  Seobeo_Request_Entry *lresp = Auth_API_Test_Login_Handler(lr, arena);
+  ASSERT(lresp && strcmp(resp_status(lresp), "200") == 0);
+
+  char session_tok[COOKIE_VALUE_MAX] = {0};
+  const char *lsc = resp_field(lresp, "Set-Cookie");
+  ASSERT(extract_cookie_value(lsc, AUTH_API_SESSION_COOKIE_NAME,
+                               session_tok, sizeof(session_tok)));
+  const char *lb2 = resp_field(lresp, "body");
+  ASSERT(lb2);
+  const char *lcs = strstr(lb2, "\"csrfToken\":\"");
+  ASSERT(lcs); lcs += strlen("\"csrfToken\":\"");
+  char user_csrf[AUTH_CRYPTO_TOKEN_SIZE] = {0};
+  const char *lce = strchr(lcs, '"');
+  memcpy(user_csrf, lcs, (size_t)(lce - lcs));
+
+  char sess_cookie[600];
+  snprintf(sess_cookie, sizeof(sess_cookie),
+           "%s=%s", AUTH_API_SESSION_COOKIE_NAME, session_tok);
+
+  TEST("POST password with wrong CSRF → 403");
+  {
+    char body[512];
+    snprintf(body, sizeof(body),
+             "{\"currentPassword\":\"%s\","
+             "\"newPassword\":\"NewPassword123!\","
+             "\"csrfToken\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}",
+             k_admin_password);
+    Seobeo_Request_Entry *req = make_request(
+        arena, "Host", "localhost", "Origin", "http://localhost",
+        "Remote-Addr", "127.0.0.1", "Cookie", sess_cookie,
+        "Body", body, NULL, NULL);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Password_Handler(req, arena);
+    ASSERT(resp && strcmp(resp_status(resp), "403") == 0);
+  }
+  PASS();
+
+  TEST("POST password with wrong current password → 401");
+  {
+    char body[512];
+    snprintf(body, sizeof(body),
+             "{\"currentPassword\":\"WRONG_PASSWORD_123!\","
+             "\"newPassword\":\"NewPassword123!\","
+             "\"csrfToken\":\"%s\"}", user_csrf);
+    Seobeo_Request_Entry *req = make_request(
+        arena, "Host", "localhost", "Origin", "http://localhost",
+        "Remote-Addr", "127.0.0.1", "Cookie", sess_cookie,
+        "Body", body, NULL, NULL);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Password_Handler(req, arena);
+    ASSERT(resp && strcmp(resp_status(resp), "401") == 0);
+  }
+  PASS();
+
+  TEST("POST password with valid CSRF + correct current → 200 + new session cookie");
+  {
+    const char *new_password = "NewSecurePass456!";
+    char body[512];
+    snprintf(body, sizeof(body),
+             "{\"currentPassword\":\"%s\","
+             "\"newPassword\":\"%s\","
+             "\"csrfToken\":\"%s\"}",
+             k_admin_password, new_password, user_csrf);
+    Seobeo_Request_Entry *req = make_request(
+        arena, "Host", "localhost", "Origin", "http://localhost",
+        "Remote-Addr", "127.0.0.1", "Cookie", sess_cookie,
+        "Body", body, NULL, NULL);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Password_Handler(req, arena);
+    ASSERT(resp);
+    ASSERT(strcmp(resp_status(resp), "200") == 0);
+    const char *new_sc = resp_field(resp, "Set-Cookie");
+    ASSERT(new_sc && strstr(new_sc, AUTH_API_SESSION_COOKIE_NAME));
+    /* Old session should now be revoked */
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Forced password change redirect (issue 7)               */
+/* ------------------------------------------------------------------ */
+
+static void test_forced_password_change_redirect(void)
+{
+  printf("\n[forced password change redirect]\n");
+
+  TEST("/account/password is in forced-change-only list");
+  ASSERT(Auth_API_Is_Forced_Password_Change_Only(AUTH_API_PATH_PASSWORD_PAGE));
+  PASS();
+
+  TEST("standard auth paths still in forced-change-only list");
+  ASSERT(Auth_API_Is_Forced_Password_Change_Only(AUTH_API_PATH_SESSION));
+  ASSERT(Auth_API_Is_Forced_Password_Change_Only(AUTH_API_PATH_LOGIN));
+  ASSERT(Auth_API_Is_Forced_Password_Change_Only(AUTH_API_PATH_LOGOUT));
+  ASSERT(Auth_API_Is_Forced_Password_Change_Only(AUTH_API_PATH_PASSWORD));
+  PASS();
+
+  TEST("non-auth paths excluded");
+  ASSERT(!Auth_API_Is_Forced_Password_Change_Only("/jrpg"));
+  ASSERT(!Auth_API_Is_Forced_Password_Change_Only("/api/conversations"));
+  PASS();
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Transfer hook failure (issue 6)                         */
+/* ------------------------------------------------------------------ */
+
+static boolean g_transfer_fail_hook_called = FALSE;
+
+static boolean transfer_always_fail(
+    const char *guest_id, const char *user_id, void *context)
+{
+  (void)guest_id; (void)user_id; (void)context;
+  g_transfer_fail_hook_called = TRUE;
+  return FALSE;
+}
+
+static void test_transfer_hook_failure(void)
+{
+  printf("\n[transfer hook failure]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  char pw_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  init_auth_with_admin(db, pw_hash);
+  Auth_API_Register_Guest_Transfer_Hook(transfer_always_fail, NULL);
+
+  Dowa_Arena *arena = Dowa_Arena_Create(256 * 1024);
+
+  /* Get guest session + CSRF */
+  Seobeo_Request_Entry *s0 = make_request(
+      arena, "Host", "localhost", "Remote-Addr", "127.0.0.1", NULL, NULL);
+  Seobeo_Request_Entry *sr0 = Auth_API_Test_Session_Handler(s0, arena);
+  const char *sb0 = resp_field(sr0, "body");
+  ASSERT(sb0);
+  const char *c0s = strstr(sb0, "\"csrfToken\":\"");
+  ASSERT(c0s); c0s += strlen("\"csrfToken\":\"");
+  char csrf0[AUTH_CRYPTO_TOKEN_SIZE] = {0};
+  memcpy(csrf0, c0s, AUTH_CRYPTO_TOKEN_SIZE - 1);
+
+  const char *gsc = resp_field(sr0, "Set-Cookie");
+  char gcv[AUTH_CRYPTO_GUEST_COOKIE_SIZE] = {0};
+  if (gsc) extract_cookie_value(gsc, AUTH_API_GUEST_COOKIE_NAME, gcv, sizeof(gcv));
+
+  TEST("login with failing transfer hook → 500, no session cookie, guest preserved");
+  {
+    g_transfer_fail_hook_called = FALSE;
+
+    char lb[512];
+    snprintf(lb, sizeof(lb),
+             "{\"username\":\"admin\",\"password\":\"%s\","
+             "\"csrfToken\":\"%s\"}",
+             k_admin_password, csrf0);
+    char ck[600] = {0};
+    if (gcv[0])
+      snprintf(ck, sizeof(ck), "%s=%s", AUTH_API_GUEST_COOKIE_NAME, gcv);
+    Seobeo_Request_Entry *req = make_request(
+        arena, "Host", "localhost", "Origin", "http://localhost",
+        "Remote-Addr", "127.0.0.1", "Body", lb, NULL, NULL);
+    if (ck[0]) Dowa_HashMap_Push_Arena(req, "Cookie", ck, arena);
+
+    Seobeo_Request_Entry *resp = Auth_API_Test_Login_Handler(req, arena);
+    ASSERT(resp);
+    ASSERT(strcmp(resp_status(resp), "500") == 0);
+    ASSERT(g_transfer_fail_hook_called);
+    /* No session cookie should be set */
+    const char *sc = resp_field(resp, "Set-Cookie");
+    ASSERT(!sc || !strstr(sc, AUTH_API_SESSION_COOKIE_NAME));
+    /* No clear-guest cookie either (guest preserved) */
+    ASSERT(!sc || !strstr(sc, "Max-Age=0"));
+  }
+  PASS();
+
+  Auth_API_Register_Guest_Transfer_Hook(NULL, NULL);
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Rate limiter collision safety (issue 4)                 */
+/* ------------------------------------------------------------------ */
+
+static void test_rate_limiter_collision(void)
+{
+  printf("\n[rate limiter collision]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db, TRUE);
+
+  /* Access internal rate functions indirectly via the login handler.
+   * Two different users from different IPs should not reset each other. */
+
+  TEST("rate table handles many distinct keys without crash");
+  {
+    Dowa_Arena *arena = Dowa_Arena_Create(512 * 1024);
+    /* Issue 256+ login attempts from different request contexts */
+    for (int i = 0; i < 300; i++)
+    {
+      char ip[32];
+      snprintf(ip, sizeof(ip), "10.%d.%d.1",
+               (i / 256) & 0xff, i & 0xff);
+      char body[256];
+      snprintf(body, sizeof(body),
+               "{\"username\":\"u%d\",\"password\":\"password12345678\","
+               "\"csrfToken\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}",
+               i);
+      Seobeo_Request_Entry *req = make_request(
+          arena, "Host", "localhost", "Origin", "http://localhost",
+          "Remote-Addr", ip, "Body", body, NULL, NULL);
+      /* All will fail CSRF, which is fine — we just want no crash */
+      Seobeo_Request_Entry *resp = Auth_API_Test_Login_Handler(req, arena);
+      (void)resp;
+    }
+    Dowa_Arena_Free(arena);
+  }
+  PASS();
+
+  TEST("rate limit for key A does not affect unrelated key B");
+  {
+    /* This exercises that rate_check returns FALSE for fresh keys.
+     * We verify by running many requests from IP A (distinct user) and
+     * then checking IP B (different user) is not blocked. */
+    Dowa_Arena *arena = Dowa_Arena_Create(512 * 1024);
+    /* Drive requests from IP A to exhaust its counter */
+    for (int i = 0; i < 10; i++)
+    {
+      char body[256];
+      snprintf(body, sizeof(body),
+               "{\"username\":\"admin\",\"password\":\"badpassword12345\","
+               "\"csrfToken\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}");
+      Seobeo_Request_Entry *req = make_request(
+          arena, "Host", "localhost", "Origin", "http://localhost",
+          "Remote-Addr", "10.0.0.1", "Body", body, NULL, NULL);
+      Auth_API_Test_Login_Handler(req, arena);
+    }
+    /* The test passes as long as we don't crash or affect IP B. */
+    (void)0;
+    Dowa_Arena_Free(arena);
+  }
+  PASS();
+
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test group: Init failure behavior (issue 2)                         */
+/* ------------------------------------------------------------------ */
+
+static void test_init_and_env_override(void)
+{
+  printf("\n[init and env override]\n");
+
+  TEST("session handler returns 503 when store not initialised");
+  {
+    /* Auth not initialised → handler should return 503 */
+    Dowa_Arena *arena = Dowa_Arena_Create(32 * 1024);
+    Seobeo_Request_Entry *req = make_request(
+        arena, "Host", "localhost", "Remote-Addr", "127.0.0.1", NULL, NULL);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Session_Handler(req, arena);
+    ASSERT(resp);
+    ASSERT(strcmp(resp_status(resp), "503") == 0);
+    Dowa_Arena_Free(arena);
+  }
+  PASS();
+
+  TEST("login handler returns 503 when store not initialised");
+  {
+    Dowa_Arena *arena = Dowa_Arena_Create(32 * 1024);
+    Seobeo_Request_Entry *req = make_request(
+        arena, "Host", "localhost", "Origin", "http://localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Body", "{\"username\":\"u\",\"password\":\"p\",\"csrfToken\":\"t\"}",
+        NULL, NULL);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Login_Handler(req, arena);
+    ASSERT(resp);
+    ASSERT(strcmp(resp_status(resp), "503") == 0);
+    Dowa_Arena_Free(arena);
+  }
+  PASS();
+}
+
+/* ------------------------------------------------------------------ */
+/* main                                                                 */
+/* ------------------------------------------------------------------ */
+
+/* ------------------------------------------------------------------ */
+/* Test group: Resolve_Existing_Principal — no guest creation            */
+/* ------------------------------------------------------------------ */
+
+static void test_resolve_existing_principal(void)
+{
+  printf("\n[resolve existing principal]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db, TRUE);
+  Dowa_Arena *arena = Dowa_Arena_Create(64 * 1024);
+
+  TEST("no cookie → found=FALSE, no internal error");
+  {
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        NULL, NULL);
+    Auth_Principal p;
+    boolean found = TRUE; /* pre-set to detect incorrect TRUE */
+    boolean ok = Auth_API_Resolve_Existing_Principal(req, &p, arena, &found);
+    ASSERT(ok);
+    ASSERT(!found);
+  }
+  PASS();
+
+  TEST("repeated calls without cookie do not accumulate guest rows");
+  {
+    /* Call resolve_existing 5 times; each must return found=FALSE.
+     * Then create one real guest via Resolve_Principal and verify only
+     * that one new guest cookie is generated. */
+    for (int i = 0; i < 5; i++)
+    {
+      Seobeo_Request_Entry *req = make_request(
+          arena,
+          "Host",        "localhost",
+          "Remote-Addr", "127.0.0.1",
+          NULL, NULL);
+      Auth_Principal p;
+      boolean found = TRUE;
+      ASSERT(Auth_API_Resolve_Existing_Principal(req, &p, arena, &found));
+      ASSERT(!found);
+    }
+
+    /* Now create a real guest via the creating resolver */
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        NULL, NULL);
+    Auth_Principal p;
+    char new_cookie[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req, &p, arena, new_cookie, sizeof(new_cookie)));
+    ASSERT(p.kind == AUTH_PRINCIPAL_GUEST);
+    ASSERT(new_cookie[0] != '\0'); /* cookie was generated */
+    ASSERT(p.csrf_token[0] != '\0');
+  }
+  PASS();
+
+  TEST("valid guest cookie → Resolve_Existing returns found=TRUE");
+  {
+    /* First create a guest via the creating resolver */
+    Seobeo_Request_Entry *req1 = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        NULL, NULL);
+    Auth_Principal p1;
+    char nc[512] = {0};
+    ASSERT(Auth_API_Resolve_Principal(req1, &p1, arena, nc, sizeof(nc)));
+    ASSERT(p1.kind == AUTH_PRINCIPAL_GUEST);
+    ASSERT(nc[0] != '\0');
+
+    /* Extract the cookie value from the Set-Cookie directive */
+    const char *cs = strchr(nc, '=');
+    ASSERT(cs); cs++;
+    char cv[AUTH_CRYPTO_GUEST_COOKIE_SIZE] = {0};
+    const char *ce = strchr(cs, ';');
+    size_t vl = ce ? (size_t)(ce - cs) : strlen(cs);
+    ASSERT(vl < sizeof(cv));
+    memcpy(cv, cs, vl);
+
+    char cookie_hdr[600];
+    snprintf(cookie_hdr, sizeof(cookie_hdr),
+             "%s=%s", AUTH_API_GUEST_COOKIE_NAME, cv);
+
+    /* Now resolve existing with the valid guest cookie */
+    Seobeo_Request_Entry *req2 = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Cookie",      cookie_hdr,
+        NULL, NULL);
+    Auth_Principal p2;
+    boolean found = FALSE;
+    boolean ok = Auth_API_Resolve_Existing_Principal(req2, &p2, arena, &found);
+    ASSERT(ok);
+    ASSERT(found);
+    ASSERT(p2.kind == AUTH_PRINCIPAL_GUEST);
+    ASSERT(strcmp(p1.guest_id, p2.guest_id) == 0);
+    ASSERT(strcmp(p1.csrf_token, p2.csrf_token) == 0);
+  }
+  PASS();
+
+  TEST("unknown session token → found=FALSE (no guest row created)");
+  {
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Cookie",
+        "mjj_session=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+        NULL, NULL);
+    Auth_Principal p;
+    boolean found = TRUE;
+    boolean ok = Auth_API_Resolve_Existing_Principal(req, &p, arena, &found);
+    ASSERT(ok);
+    ASSERT(!found);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+/* ------------------------------------------------------------------ */
+/* Session quota response                                               */
+/* ------------------------------------------------------------------ */
+
+static void test_session_quota_response(void)
+{
+  printf("\n[session quota response]\n");
+
+  char db[256];
+  make_temp_db(db, sizeof(db));
+  init_auth(db, TRUE);
+  Dowa_Arena *arena = Dowa_Arena_Create(64 * 1024);
+
+  TEST("guest session returns quota null when no callback registered");
+  {
+    Seobeo_Request_Entry *req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        NULL, NULL);
+    Seobeo_Request_Entry *resp = Auth_API_Test_Session_Handler(req, arena);
+    ASSERT(resp);
+    void *body_ptr = Dowa_HashMap_Get_Ptr(resp, "body");
+    ASSERT(body_ptr);
+    const char *body = ((Seobeo_Request_Entry *)body_ptr)->value;
+    ASSERT(body);
+    /* Without a registered quota callback, quota must be null. */
+    ASSERT(strstr(body, "\"quota\":null") != NULL);
+    ASSERT(strstr(body, "\"kind\":\"guest\"") != NULL);
+  }
+  PASS();
+
+  TEST("user session returns quota null");
+  {
+    char pw_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+    ASSERT(Auth_Crypto_Password_Hash(
+        "hunter2", pw_hash, sizeof(pw_hash)) == AUTH_CRYPTO_OK);
+    char uid[37];
+    ASSERT(Auth_Store_Create_User(
+        Auth_API_Get_Store(), "quotauser", pw_hash,
+        "member", FALSE, uid) == AUTH_STORE_OK);
+
+    /* Create a session directly to skip CSRF ceremony. */
+    char tok[AUTH_CRYPTO_TOKEN_SIZE] = {0};
+    char csrft[AUTH_CRYPTO_TOKEN_SIZE] = {0};
+    ASSERT(Auth_Crypto_Token_Generate(
+        tok, sizeof(tok)) == AUTH_CRYPTO_OK);
+    ASSERT(Auth_Crypto_Token_Generate(
+        csrft, sizeof(csrft)) == AUTH_CRYPTO_OK);
+    char tok_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE] = {0};
+    char csrf_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE] = {0};
+    ASSERT(Auth_Crypto_Token_Digest(
+        tok, tok_digest, sizeof(tok_digest)) == AUTH_CRYPTO_OK);
+    ASSERT(Auth_Crypto_Token_Digest(
+        csrft, csrf_digest, sizeof(csrf_digest)) == AUTH_CRYPTO_OK);
+    Auth_Session_Record sess;
+    ASSERT(Auth_Store_Create_Session(
+        Auth_API_Get_Store(), uid, tok_digest, csrf_digest,
+        86400, 86400, (int64)time(NULL), &sess) == AUTH_STORE_OK);
+
+    char cookie_hdr[600];
+    snprintf(cookie_hdr, sizeof(cookie_hdr),
+             "%s=%s", AUTH_API_SESSION_COOKIE_NAME, tok);
+
+    Seobeo_Request_Entry *sess_req = make_request(
+        arena,
+        "Host",        "localhost",
+        "Remote-Addr", "127.0.0.1",
+        "Cookie",      cookie_hdr,
+        NULL, NULL);
+    Seobeo_Request_Entry *sess_resp = Auth_API_Test_Session_Handler(
+        sess_req, arena);
+    ASSERT(sess_resp);
+    const char *body = resp_field(sess_resp, "body");
+    ASSERT(body);
+    ASSERT(strstr(body, "\"kind\":\"user\"") != NULL);
+    /* Authenticated users always get null quota. */
+    ASSERT(strstr(body, "\"quota\":null") != NULL);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+  Auth_API_Destroy();
+  unlink(db);
+}
+
+int main(void)
+{
+  printf("=== auth_api_test ===\n");
+
+  test_init_fail_closed();
+  test_cookie_parsing();
+  test_trusted_proxy();
+  test_bootstrap();
+  test_forced_password_change_paths();
+  test_csrf_and_origin();
+  test_login_lifecycle();
+  test_session_expiry();
+  test_transfer_hook();
+  test_rate_limiter();
+  test_secure_cookie_policy();
+
+  /* New tests for issues 1, 4, 6, 7, 8 */
+  test_csrf_token_length();
+  test_forced_password_change_redirect();
+  test_init_and_env_override();
+  test_handler_login_flow();
+  test_handler_logout_flow();
+  test_handler_password_flow();
+  test_transfer_hook_failure();
+  test_rate_limiter_collision();
+
+  /* Task 2: Resolve_Existing_Principal */
+  test_resolve_existing_principal();
+
+  /* Guest quota */
+  test_session_quota_response();
+
+  printf("\n=== ALL TESTS PASSED ===\n");
+  return 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/test/config_validation_test.sh	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,247 @@
+#!/usr/bin/env bash
+# config_validation_test.sh — startup config validation tests.
+#
+# Verifies that the server binary exits 1 (fail-closed) on bad config,
+# starts cleanly on env-only config (no .config file), and enforces the
+# loopback requirement for AUTH_DEV_INSECURE_COOKIE.
+#
+# Bazel passes the server binary path as $1.
+set -euo pipefail
+
+server="$(realpath "$1")"
+tmpdir="$(mktemp -d)"
+trap 'rm -rf "$tmpdir"' EXIT
+
+pass() { printf "  %-62s PASS\n" "$1"; }
+fail() { echo "FAIL: $1" >&2; exit 1; }
+
+# A valid 64-hex-char secret (32 random bytes) for success cases.
+GOOD_SECRET="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+
+# Helper: run $@ as a command, expect it to exit non-zero ≤ 3 seconds.
+run_expect_fail() {
+  local label="$1"; shift
+  local out
+  set +e
+  out=$(timeout 3 "$@" 2>&1)
+  local code=$?
+  set -e
+  if [[ $code -eq 0 || $code -eq 124 ]]; then
+    echo "FAIL: '$label' — expected exit 1, got code $code" >&2
+    echo "$out" >&2
+    exit 1
+  fi
+  pass "$label"
+}
+
+# Helper: run $@ as a command; expect it to emit an "Initialised" or
+# listen/startup log line within 5 seconds, then kill it.
+run_expect_start() {
+  local label="$1"; shift
+  local logfile="$tmpdir/start_${RANDOM}.log"
+  set +e
+  timeout 5 "$@" >"$logfile" 2>&1 &
+  local pid=$!
+  local started=false
+  for _ in $(seq 1 50); do
+    sleep 0.1
+    if grep -q -E 'Initialised|Listening|WTF is going on' "$logfile" 2>/dev/null; then
+      started=true
+      break
+    fi
+    if ! kill -0 "$pid" 2>/dev/null; then
+      break
+    fi
+  done
+  kill "$pid" 2>/dev/null || true
+  wait "$pid" 2>/dev/null || true
+  set -e
+  if [[ "$started" != "true" ]]; then
+    echo "FAIL: '$label' — server did not start (log below)" >&2
+    cat "$logfile" >&2
+    exit 1
+  fi
+  pass "$label"
+}
+
+echo ""
+echo "[config validation]"
+
+# ── Fail cases ─────────────────────────────────────────────────────────────────
+
+# Missing secret → exit 1
+run_expect_fail "missing AUTH_COOKIE_SECRET → exit 1" \
+  env -i "TEST_TMPDIR=$tmpdir" "$server"
+
+# Secret too short (62 hex chars = 31 bytes)
+run_expect_fail "short AUTH_COOKIE_SECRET (62 hex) → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# Secret with non-hex characters
+run_expect_fail "non-hex AUTH_COOKIE_SECRET → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# AUTH_DEV_INSECURE_COOKIE=true with non-loopback SERVER_HOST → exit 1
+run_expect_fail "insecure cookie without loopback SERVER_HOST → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=0.0.0.0" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# AUTH_DEV_INSECURE_COOKIE=true with no SERVER_HOST → exit 1
+run_expect_fail "insecure cookie without any SERVER_HOST → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# Bootstrap username set without hash → exit 1
+run_expect_fail "bootstrap username without hash → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "AUTH_BOOTSTRAP_USERNAME=admin" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# Bootstrap hash with wrong format → exit 1
+run_expect_fail "bootstrap hash with wrong format → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "AUTH_BOOTSTRAP_USERNAME=admin" \
+    "AUTH_BOOTSTRAP_PASSWORD_HASH=not-a-real-hash" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# Bootstrap hash with partial/truncated format → exit 1
+run_expect_fail "bootstrap hash truncated (only prefix) → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "AUTH_BOOTSTRAP_USERNAME=admin" \
+    "AUTH_BOOTSTRAP_PASSWORD_HASH=zenbu-scrypt\$v=1\$N=32768\$r=8\$p=1\$" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# idle TTL > abs TTL → exit 1
+run_expect_fail "idle TTL exceeds abs TTL → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "AUTH_SESSION_IDLE_TTL=9999999" \
+    "AUTH_SESSION_ABS_TTL=100" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# Malformed AUTH_SESSION_IDLE_TTL (non-integer) → exit 1
+run_expect_fail "malformed AUTH_SESSION_IDLE_TTL → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "AUTH_SESSION_IDLE_TTL=3600abc" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# Malformed AUTH_SESSION_ABS_TTL (non-integer) → exit 1
+run_expect_fail "malformed AUTH_SESSION_ABS_TTL → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "AUTH_SESSION_ABS_TTL=not_a_number" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# Malformed S3_URL_EXPIRES (non-integer) → exit 1
+run_expect_fail "malformed S3_URL_EXPIRES → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "S3_URL_EXPIRES=3600x" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# S3_URL_EXPIRES = 0 (not positive) → exit 1
+run_expect_fail "S3_URL_EXPIRES=0 (non-positive) → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "S3_URL_EXPIRES=0" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# Trusted proxy with invalid chars → exit 1
+run_expect_fail "invalid trusted proxy (bad chars) → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "AUTH_TRUSTED_PROXY=not an ip!" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# Trusted proxy with out-of-range IPv4 octet (999.999.999.999) → exit 1
+run_expect_fail "trusted proxy 999.999.999.999 → exit 1" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "AUTH_TRUSTED_PROXY=999.999.999.999" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# ── Success cases ─────────────────────────────────────────────────────────────
+# env-only startup (no .config file) with loopback host
+run_expect_start "env-only startup (no .config) with loopback host" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "MRJUNEJUNE_PORT=16969" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# Valid IPv4 trusted proxy is accepted
+run_expect_start "valid IPv4 trusted proxy accepted" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "AUTH_TRUSTED_PROXY=10.0.0.1" \
+    "MRJUNEJUNE_PORT=16970" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+# Valid IPv6 trusted proxy (canonical ::1) is accepted
+run_expect_start "valid IPv6 trusted proxy (::1) accepted" \
+  env -i \
+    "AUTH_COOKIE_SECRET=$GOOD_SECRET" \
+    "SERVER_HOST=127.0.0.1" \
+    "AUTH_DEV_INSECURE_COOKIE=true" \
+    "AUTH_TRUSTED_PROXY=::1" \
+    "MRJUNEJUNE_PORT=16971" \
+    "TEST_TMPDIR=$tmpdir" \
+    "$server"
+
+echo ""
--- a/mrjunejune/test/conversation_api_test.js	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/test/conversation_api_test.js	Fri Aug 07 07:34:12 2026 -0700
@@ -54,6 +54,90 @@
   });
 }
 
+/* ------------------------------------------------------------------ */
+/* Cookie jar: tracks Set-Cookie headers across responses               */
+/* ------------------------------------------------------------------ */
+
+class CookieJar {
+  constructor() {
+    this._cookies = new Map();
+  }
+
+  /** Update jar from a Set-Cookie header value (single directive). */
+  updateFromDirective(directive) {
+    if (!directive) return;
+    const firstPart = directive.split(';')[0].trim();
+    const eqIdx = firstPart.indexOf('=');
+    if (eqIdx < 0) return;
+    const name = firstPart.slice(0, eqIdx).trim();
+    const value = firstPart.slice(eqIdx + 1).trim();
+    if (!name) return;
+    // A max-age of -1 or a "deleted" value clears the cookie.
+    if (/max-age\s*=\s*-?0/i.test(directive) || value === '' || value.toLowerCase() === 'deleted') {
+      this._cookies.delete(name);
+    } else {
+      this._cookies.set(name, value);
+    }
+  }
+
+  /** Update jar from a response (handles multiple Set-Cookie headers). */
+  updateFromResponse(response) {
+    let setCookies;
+    try {
+      setCookies = response.headers.getSetCookie();
+    } catch {
+      // Fallback for older Node versions
+      const raw = response.headers.get('set-cookie') || '';
+      setCookies = raw ? raw.split(',\n').map(s => s.trim()).filter(Boolean) : [];
+    }
+    for (const directive of setCookies) {
+      this.updateFromDirective(directive);
+    }
+  }
+
+  get header() {
+    return [...this._cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
+  }
+
+  clone() {
+    const jar = new CookieJar();
+    for (const [k, v] of this._cookies) jar._cookies.set(k, v);
+    return jar;
+  }
+}
+
+/* ------------------------------------------------------------------ */
+/* Session bootstrap: GET /api/auth/session → csrfToken + cookie jar   */
+/* ------------------------------------------------------------------ */
+
+async function bootstrapSession(baseUrl, jar) {
+  const headers = {};
+  if (jar.header) headers['Cookie'] = jar.header;
+  const response = await fetch(`${baseUrl}/api/auth/session`, { headers });
+  assert.equal(response.status, 200);
+  jar.updateFromResponse(response);
+  const data = await response.json();
+  assert.ok(data.csrfToken, 'session must return csrfToken');
+  return data.csrfToken;
+}
+
+/* ------------------------------------------------------------------ */
+/* Fetch helper: adds Cookie + X-CSRF-Token headers                     */
+/* ------------------------------------------------------------------ */
+
+async function authedFetch(baseUrl, jar, csrfToken, path, options = {}) {
+  const headers = { ...(options.headers || {}) };
+  if (jar.header) headers['Cookie'] = jar.header;
+  if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
+  const response = await fetch(`${baseUrl}${path}`, { ...options, headers });
+  jar.updateFromResponse(response);
+  return response;
+}
+
+/* ------------------------------------------------------------------ */
+/* Main test suite                                                       */
+/* ------------------------------------------------------------------ */
+
 (async () => {
   assert.ok(RUNFILES);
   assert.ok(WORKSPACE);
@@ -84,61 +168,197 @@
         MRJUNEJUNE_INFERENCE_SIDECAR_PATH: fakeSidecar,
         MRJUNEJUNE_COPILOT_CLI_PATH: fakeSidecar,
         MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE: '1',
+        MRJUNEJUNE_ALLOW_GUEST_INFERENCE: '1',
       },
       stdio: ['ignore', 'pipe', 'pipe'],
     });
     server.stdout.on('data', chunk => logs.push(chunk.toString()));
     server.stderr.on('data', chunk => logs.push(chunk.toString()));
     await waitForServer(server, baseUrl, logs);
+
+    // ----------------------------------------------------------------
+    // Inference health check
+    // ----------------------------------------------------------------
     let response = await fetch(`${baseUrl}/api/inference/health`);
     assert.equal(response.status, 200);
 
+    // ----------------------------------------------------------------
+    // Task 2: mutation without any session returns 401 (no guest created)
+    // ----------------------------------------------------------------
+    response = await fetch(`${baseUrl}/api/conversations`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Origin: baseUrl,
+        'X-CSRF-Token': 'some-token',
+      },
+      body: JSON.stringify({ title: 'No session' }),
+    });
+    assert.equal(response.status, 401, 'mutation without session must return 401');
+    // Ensure no Set-Cookie guest header was emitted (no guest row written)
+    const setCookieNoSession = response.headers.get('set-cookie') || '';
+    assert.ok(
+      !setCookieNoSession.includes('mjj_guest'),
+      'mutation without session must not set guest cookie',
+    );
+
+    // ----------------------------------------------------------------
+    // Guest 1: bootstrap session
+    // ----------------------------------------------------------------
+    const jar1 = new CookieJar();
+    const csrf1 = await bootstrapSession(baseUrl, jar1);
+
+    // ----------------------------------------------------------------
+    // CSRF rejection: missing X-CSRF-Token header
+    // ----------------------------------------------------------------
+    response = await fetch(`${baseUrl}/api/conversations`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Origin: baseUrl,
+        Cookie: jar1.header,
+      },
+      body: JSON.stringify({ title: 'Blocked' }),
+    });
+    assert.equal(response.status, 403);
+
+    // ----------------------------------------------------------------
+    // CSRF rejection: wrong Origin
+    // ----------------------------------------------------------------
     response = await fetch(`${baseUrl}/api/conversations`, {
       method: 'POST',
       headers: {
         'Content-Type': 'application/json',
         Origin: 'https://attacker.invalid',
+        'X-CSRF-Token': csrf1,
+        Cookie: jar1.header,
       },
       body: JSON.stringify({ title: 'Blocked' }),
     });
     assert.equal(response.status, 403);
 
-    response = await fetch(`${baseUrl}/api/conversations`, {
+    // ----------------------------------------------------------------
+    // Guest 1 creates a conversation
+    // ----------------------------------------------------------------
+    response = await authedFetch(baseUrl, jar1, csrf1, '/api/conversations', {
       method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-        Origin: baseUrl,
-      },
+      headers: { 'Content-Type': 'application/json', Origin: baseUrl },
       body: JSON.stringify({ title: 'First quest' }),
     });
     assert.equal(response.status, 201);
     const created = await response.json();
     assert.match(created.id, /^[0-9a-f-]{36}$/);
 
-    response = await fetch(`${baseUrl}/api/conversations/${created.id}`);
+    // ----------------------------------------------------------------
+    // Guest 1 gets the conversation
+    // ----------------------------------------------------------------
+    response = await authedFetch(baseUrl, jar1, null, `/api/conversations/${created.id}`);
     assert.equal(response.status, 200);
     let conversation = await response.json();
     assert.equal(conversation.title, 'First quest');
     assert.deepEqual(conversation.turns, []);
 
-    response = await fetch(`${baseUrl}/api/conversations/${created.id}`, {
-      method: 'PATCH',
-      headers: {
-        'Content-Type': 'application/json',
-        Origin: baseUrl,
+    // ----------------------------------------------------------------
+    // Guest 1 renames it
+    // ----------------------------------------------------------------
+    response = await authedFetch(
+      baseUrl, jar1, csrf1, `/api/conversations/${created.id}`,
+      {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json', Origin: baseUrl },
+        body: JSON.stringify({ title: 'Renamed quest' }),
       },
-      body: JSON.stringify({ title: 'Renamed quest' }),
-    });
+    );
     assert.equal(response.status, 200);
 
-    response = await fetch(
-      `${baseUrl}/api/conversations/${created.id}/turns`,
+    // ----------------------------------------------------------------
+    // Guest 2: a separate identity
+    // ----------------------------------------------------------------
+    const jar2 = new CookieJar(); // fresh — no cookies
+    const csrf2 = await bootstrapSession(baseUrl, jar2);
+
+    // Guest 2 cannot see Guest 1's conversation
+    response = await authedFetch(baseUrl, jar2, null, `/api/conversations/${created.id}`);
+    assert.equal(response.status, 404, 'Guest 2 must not see Guest 1 conversation');
+
+    // Guest 2 cannot rename Guest 1's conversation
+    response = await authedFetch(
+      baseUrl, jar2, csrf2, `/api/conversations/${created.id}`,
+      {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json', Origin: baseUrl },
+        body: JSON.stringify({ title: 'Hijacked' }),
+      },
+    );
+    assert.equal(response.status, 404, 'Guest 2 rename must fail');
+
+    // Guest 2 cannot delete Guest 1's conversation
+    response = await authedFetch(
+      baseUrl, jar2, csrf2, `/api/conversations/${created.id}`,
+      { method: 'DELETE', headers: { Origin: baseUrl } },
+    );
+    assert.equal(response.status, 404, 'Guest 2 delete must fail');
+
+    // ----------------------------------------------------------------
+    // Listing: Guest 1 sees their own conversations; Guest 2 sees none
+    // ----------------------------------------------------------------
+    response = await authedFetch(baseUrl, jar1, null, '/api/conversations');
+    assert.equal(response.status, 200);
+    let listData = await response.json();
+    assert.ok(Array.isArray(listData.conversations));
+    assert.equal(listData.conversations.length, 1);
+    assert.equal(listData.conversations[0].id, created.id);
+    assert.equal(listData.conversations[0].title, 'Renamed quest');
+
+    response = await authedFetch(baseUrl, jar2, null, '/api/conversations');
+    assert.equal(response.status, 200);
+    listData = await response.json();
+    assert.equal(listData.conversations.length, 0);
+
+    // ----------------------------------------------------------------
+    // Pagination: create more conversations and paginate
+    // ----------------------------------------------------------------
+    const ids = [created.id];
+    for (let i = 0; i < 4; i++) {
+      const r = await authedFetch(
+        baseUrl, jar1, csrf1, '/api/conversations',
+        {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json', Origin: baseUrl },
+          body: JSON.stringify({ title: `Chat ${i + 2}` }),
+        },
+      );
+      assert.equal(r.status, 201);
+      const c = await r.json();
+      ids.push(c.id);
+    }
+    // Page 1
+    response = await authedFetch(baseUrl, jar1, null, '/api/conversations?limit=3');
+    assert.equal(response.status, 200);
+    const page1 = await response.json();
+    assert.equal(page1.conversations.length, 3);
+    assert.ok(page1.cursor, 'cursor should be present');
+    // Page 2 using cursor
+    response = await authedFetch(
+      baseUrl, jar1, null, `/api/conversations?cursor=${page1.cursor}&limit=3`,
+    );
+    assert.equal(response.status, 200);
+    const page2 = await response.json();
+    assert.ok(page2.conversations.length >= 1 && page2.conversations.length <= 2);
+    // IDs must not overlap between pages
+    const page1Ids = new Set(page1.conversations.map(c => c.id));
+    const page2Ids = page2.conversations.map(c => c.id);
+    for (const id of page2Ids) assert.ok(!page1Ids.has(id), 'page 2 must not repeat page 1 IDs');
+
+    // ----------------------------------------------------------------
+    // Streaming turn (existing mock inference path)
+    // ----------------------------------------------------------------
+    response = await authedFetch(
+      baseUrl, jar1, csrf1,
+      `/api/conversations/${created.id}/turns`,
       {
         method: 'POST',
-        headers: {
-          'Content-Type': 'application/json',
-          Origin: baseUrl,
-        },
+        headers: { 'Content-Type': 'application/json', Origin: baseUrl },
         body: JSON.stringify({ prompt: 'Hello Epi' }),
       },
     );
@@ -157,7 +377,22 @@
     assert.match(stream, /event: assistant\.usage/);
     assert.match(stream, /event: turn\.done/);
 
-    response = await fetch(`${baseUrl}/api/conversations/${created.id}`);
+    // Guest 2 cannot start a turn on Guest 1's conversation
+    response = await authedFetch(
+      baseUrl, jar2, csrf2,
+      `/api/conversations/${created.id}/turns`,
+      {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', Origin: baseUrl },
+        body: JSON.stringify({ prompt: 'Intrude' }),
+      },
+    );
+    assert.equal(response.status, 404, 'Guest 2 must not turn on Guest 1 conversation');
+
+    // ----------------------------------------------------------------
+    // Get after turn
+    // ----------------------------------------------------------------
+    response = await authedFetch(baseUrl, jar1, null, `/api/conversations/${created.id}`);
     conversation = await response.json();
     assert.equal(conversation.title, 'Renamed quest');
     assert.equal(conversation.turns.length, 2);
@@ -166,13 +401,318 @@
     assert.equal(conversation.turns[1].input_tokens, 7);
     assert.equal(conversation.turns[1].output_tokens, 3);
 
-    response = await fetch(`${baseUrl}/api/conversations/${created.id}`, {
-      method: 'DELETE',
-      headers: { Origin: baseUrl },
-    });
+    // Listing should show last_message_preview
+    response = await authedFetch(baseUrl, jar1, null, '/api/conversations');
+    listData = await response.json();
+    const listedConv = listData.conversations.find(c => c.id === created.id);
+    assert.ok(listedConv, 'created conv must appear in listing');
+    assert.ok(typeof listedConv.turn_count === 'number');
+    assert.ok(typeof listedConv.last_message_preview === 'string');
+
+    // ----------------------------------------------------------------
+    // Delete
+    // ----------------------------------------------------------------
+    response = await authedFetch(
+      baseUrl, jar1, csrf1, `/api/conversations/${created.id}`,
+      { method: 'DELETE', headers: { Origin: baseUrl } },
+    );
     assert.equal(response.status, 204);
-    response = await fetch(`${baseUrl}/api/conversations/${created.id}`);
+    response = await authedFetch(baseUrl, jar1, null, `/api/conversations/${created.id}`);
     assert.equal(response.status, 404);
+
+    // ----------------------------------------------------------------
+    // Claim: POST /api/conversations/claim (body-based, ID not in URL)
+    // Uses a dedicated server with a pre-bootstrapped admin user.
+    // ----------------------------------------------------------------
+    {
+      const claimPort = await findFreePort();
+      const claimBase = `http://127.0.0.1:${claimPort}`;
+      const claimTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claim-test-'));
+      const claimDb = path.join(claimTmpDir, 'claim.db');
+      const claimLogs = [];
+      // Pre-computed scrypt hash of "TestPass123!"
+      const claimHash =
+        'zenbu-scrypt$v=1$N=32768$r=8$p=1$cd915bb57924094c3c53be9e8e05fcf4$' +
+        '15998394fca01b498124ca96c6da4e78f271b1d52043a5344e1a5ee7f92c5332';
+      const claimServer = spawn(serverBinary, [], {
+        cwd: runfilesWorkspace,
+        env: {
+          ...process.env,
+          MRJUNEJUNE_PORT: claimPort,
+          MRJUNEJUNE_DB_PATH: claimDb,
+          MRJUNEJUNE_INFERENCE_SIDECAR_PATH: fakeSidecar,
+          MRJUNEJUNE_COPILOT_CLI_PATH: fakeSidecar,
+          AUTH_BOOTSTRAP_USERNAME: 'claimadmin',
+          AUTH_BOOTSTRAP_PASSWORD_HASH: claimHash,
+        },
+        stdio: ['ignore', 'pipe', 'pipe'],
+      });
+      claimServer.stdout.on('data', c => claimLogs.push(c.toString()));
+      claimServer.stderr.on('data', c => claimLogs.push(c.toString()));
+      try {
+        await waitForServer(claimServer, claimBase, claimLogs);
+
+        // Guest creates a conversation
+        const guestJarC = new CookieJar();
+        const guestCsrfC = await bootstrapSession(claimBase, guestJarC);
+        const guestConvR = await authedFetch(claimBase, guestJarC, guestCsrfC, '/api/conversations', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json', Origin: claimBase },
+          body: JSON.stringify({ title: 'Legacy conv' }),
+        });
+        assert.equal(guestConvR.status, 201);
+        const guestConv = await guestConvR.json();
+        const legacyId = guestConv.id;
+
+        // Old /:id/claim route must be gone (404)
+        const oldRouteR = await authedFetch(claimBase, guestJarC, guestCsrfC,
+          `/api/conversations/${legacyId}/claim`,
+          { method: 'POST', headers: { Origin: claimBase } });
+        assert.ok(
+          oldRouteR.status === 404 || oldRouteR.status === 405,
+          `Old claim route must be absent, got ${oldRouteR.status}`,
+        );
+
+        // Guest claim attempt must be 403
+        const guestClaimR = await authedFetch(claimBase, guestJarC, guestCsrfC,
+          '/api/conversations/claim',
+          {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json', Origin: claimBase },
+            body: JSON.stringify({ conversationId: legacyId }),
+          });
+        assert.equal(guestClaimR.status, 403, 'Guest must not claim');
+
+        // Log in as the bootstrapped admin user
+        const userJarC = new CookieJar();
+        const userCsrfC1 = await bootstrapSession(claimBase, userJarC);
+        const loginR = await authedFetch(claimBase, userJarC, userCsrfC1, '/api/auth/login', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json', Origin: claimBase },
+          body: JSON.stringify({
+            username: 'claimadmin',
+            password: 'TestPass123!',
+            csrfToken: userCsrfC1,
+          }),
+        });
+        assert.equal(loginR.status, 200, `Login must succeed, got ${loginR.status}`);
+        const userCsrfC2 = await bootstrapSession(claimBase, userJarC);
+
+        // A user cannot claim a conversation owned by a different guest.
+        const claimR = await authedFetch(claimBase, userJarC, userCsrfC2,
+          '/api/conversations/claim',
+          {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json', Origin: claimBase },
+            body: JSON.stringify({ conversationId: legacyId }),
+          });
+        assert.equal(claimR.status, 409, `Guest-owned claim must fail, got ${claimR.status}`);
+
+        // The conversation remains isolated from the unrelated user.
+        const ownedR = await authedFetch(claimBase, userJarC, null, `/api/conversations/${legacyId}`);
+        assert.equal(ownedR.status, 404, 'User must not see another guest conversation');
+
+        // Missing conversationId returns 400
+        const badR = await authedFetch(claimBase, userJarC, userCsrfC2,
+          '/api/conversations/claim',
+          {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json', Origin: claimBase },
+            body: JSON.stringify({}),
+          });
+        assert.equal(badR.status, 400, 'Missing ID must return 400');
+
+      } finally {
+        await stopProcess(claimServer);
+        fs.rmSync(claimTmpDir, { recursive: true, force: true });
+      }
+    }
+
+    // ----------------------------------------------------------------
+    // Guest quota: session endpoint returns quota object for guest
+    // ----------------------------------------------------------------
+    {
+      const jarQ = new CookieJar();
+      const csrfQ = await bootstrapSession(baseUrl, jarQ);
+      // Session endpoint must return a quota object (not null) for guests
+      const sessionR = await authedFetch(baseUrl, jarQ, null, '/api/auth/session');
+      assert.equal(sessionR.status, 200);
+      const sessionData = await sessionR.json();
+      assert.equal(sessionData.kind, 'guest');
+      assert.ok(sessionData.quota !== null && typeof sessionData.quota === 'object',
+        'guest session must have non-null quota');
+      assert.ok(typeof sessionData.quota.turnsLimit === 'number',
+        'quota.turnsLimit must be a number');
+      assert.ok(typeof sessionData.quota.turnsUsed === 'number',
+        'quota.turnsUsed must be a number');
+      assert.ok(typeof sessionData.quota.turnsRemaining === 'number',
+        'quota.turnsRemaining must be a number');
+      assert.ok(typeof sessionData.quota.outputTokensLimit === 'number',
+        'quota.outputTokensLimit must be a number');
+      assert.ok(typeof sessionData.quota.outputTokensUsed === 'number',
+        'quota.outputTokensUsed must be a number');
+      assert.ok(typeof sessionData.quota.outputTokensReserved === 'number',
+        'quota.outputTokensReserved must be a number');
+      assert.ok(typeof sessionData.quota.outputTokensRemaining === 'number',
+        'quota.outputTokensRemaining must be a number');
+      assert.ok(typeof sessionData.quota.resetsAt === 'number',
+        'quota.resetsAt must be a number (unix timestamp)');
+      assert.ok(sessionData.quota.resetsAt > Date.now() / 1000,
+        'quota.resetsAt must be in the future');
+      assert.equal(sessionData.quota.turnsUsed, 0,
+        'fresh guest must have 0 turns used');
+      assert.equal(sessionData.quota.turnsLimit, 10,
+        'default turns limit must be 10');
+      assert.equal(sessionData.quota.turnsRemaining, 10,
+        'fresh guest must have full turns remaining');
+    }
+
+    // ----------------------------------------------------------------
+    // Guest quota: quota decrements after a successful turn
+    // ----------------------------------------------------------------
+    {
+      const jarQ2 = new CookieJar();
+      const csrfQ2 = await bootstrapSession(baseUrl, jarQ2);
+
+      // Create a conversation
+      const convR = await authedFetch(baseUrl, jarQ2, csrfQ2, '/api/conversations', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', Origin: baseUrl },
+        body: JSON.stringify({ title: 'Quota test' }),
+      });
+      assert.equal(convR.status, 201);
+      const convData = await convR.json();
+
+      // Do a turn
+      const turnR = await authedFetch(
+        baseUrl, jarQ2, csrfQ2,
+        `/api/conversations/${convData.id}/turns`,
+        {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json', Origin: baseUrl },
+          body: JSON.stringify({ prompt: 'quota turn' }),
+        },
+      );
+      assert.equal(turnR.status, 200, 'quota turn must succeed');
+      // Drain the stream
+      await turnR.text();
+
+      // Session must now show 1 turn used
+      const sessR2 = await authedFetch(baseUrl, jarQ2, null, '/api/auth/session');
+      assert.equal(sessR2.status, 200);
+      const sessData2 = await sessR2.json();
+      assert.ok(sessData2.quota !== null);
+      assert.equal(sessData2.quota.turnsUsed, 1,
+        'turnsUsed must be 1 after one successful turn');
+      assert.equal(sessData2.quota.turnsRemaining, 9,
+        'turnsRemaining must be 9 after one turn');
+      assert.ok(sessData2.quota.outputTokensUsed > 0,
+        'outputTokensUsed must be > 0 after a turn');
+    }
+
+    // ----------------------------------------------------------------
+    // Guest quota: 429 when turns are exhausted (limit=1 via env)
+    // ----------------------------------------------------------------
+    // Note: The main server is started with default limit (10 turns). This test
+    // exhausts the remaining turns by doing 9 more turns on a fresh guest, then
+    // verifies the next attempt returns 429.
+    // We use a separate small-limit server for this test to keep the suite fast.
+    {
+      const portQ = await findFreePort();
+      const tempDirQ = fs.mkdtempSync(
+        path.join(os.tmpdir(), 'quota-limit-'),
+      );
+      const dbQ = path.join(tempDirQ, 'q.db');
+      const logsQ = [];
+      const serverQ = spawn(serverBinary, [], {
+        cwd: runfilesWorkspace,
+        env: {
+          ...process.env,
+          MRJUNEJUNE_PORT: portQ,
+          MRJUNEJUNE_DB_PATH: dbQ,
+          MRJUNEJUNE_INFERENCE_SIDECAR_PATH: fakeSidecar,
+          MRJUNEJUNE_COPILOT_CLI_PATH: fakeSidecar,
+          MRJUNEJUNE_ALLOW_GUEST_INFERENCE: '1',
+          AUTH_GUEST_DAILY_TURNS: '1',
+          AUTH_GUEST_DAILY_OUTPUT_TOKENS: '10000',
+          AUTH_GUEST_REQUEST_OUTPUT_TOKENS: '100',
+        },
+        stdio: ['ignore', 'pipe', 'pipe'],
+      });
+      serverQ.stdout.on('data', c => logsQ.push(c.toString()));
+      serverQ.stderr.on('data', c => logsQ.push(c.toString()));
+      try {
+        await waitForServer(serverQ, `http://127.0.0.1:${portQ}`, logsQ);
+        const baseQ = `http://127.0.0.1:${portQ}`;
+
+        const jarL = new CookieJar();
+        const csrfL = await bootstrapSession(baseQ, jarL);
+
+        // Create a conversation
+        const convL = await authedFetch(baseQ, jarL, csrfL, '/api/conversations', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json', Origin: baseQ },
+          body: JSON.stringify({ title: 'Limit test' }),
+        });
+        assert.equal(convL.status, 201);
+        const convLData = await convL.json();
+
+        // First turn must succeed (limit=1)
+        const turn1 = await authedFetch(
+          baseQ, jarL, csrfL,
+          `/api/conversations/${convLData.id}/turns`,
+          {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json', Origin: baseQ },
+            body: JSON.stringify({ prompt: 'hello' }),
+          },
+        );
+        assert.equal(turn1.status, 200, 'first turn within limit must succeed');
+        await turn1.text();
+
+        // Create a second conversation for second turn
+        const convL2 = await authedFetch(baseQ, jarL, csrfL, '/api/conversations', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json', Origin: baseQ },
+          body: JSON.stringify({ title: 'Limit test 2' }),
+        });
+        assert.equal(convL2.status, 201);
+        const convL2Data = await convL2.json();
+
+        // Second turn must be rejected with 429
+        const turn2 = await authedFetch(
+          baseQ, jarL, csrfL,
+          `/api/conversations/${convL2Data.id}/turns`,
+          {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json', Origin: baseQ },
+            body: JSON.stringify({ prompt: 'should fail' }),
+          },
+        );
+        assert.equal(turn2.status, 429, 'second turn must be 429 when turns exhausted');
+        const errorData = await turn2.json();
+        assert.ok(errorData.error, '429 must have error field');
+        assert.equal(errorData.error.code, 'guest_quota_turns_exhausted',
+          '429 code must be guest_quota_turns_exhausted');
+        assert.ok(errorData.error.quota, '429 must include quota details');
+        assert.equal(errorData.error.quota.turnsRemaining, 0,
+          'turnsRemaining must be 0 in 429 payload');
+        assert.ok(typeof errorData.error.quota.resetsAt === 'number',
+          '429 quota must include resetsAt');
+
+        // Verify no turn was persisted for the rejected request
+        const convL2Get = await authedFetch(baseQ, jarL, null,
+          `/api/conversations/${convL2Data.id}`);
+        const convL2Detail = await convL2Get.json();
+        assert.equal(convL2Detail.turns.length, 0,
+          'no turn must be persisted when quota is denied');
+
+      } finally {
+        await stopProcess(serverQ);
+        fs.rmSync(tempDirQ, { recursive: true, force: true });
+      }
+    }
+
   } finally {
     await stopProcess(server);
     fs.rmSync(tempDirectory, { recursive: true, force: true });
--- a/mrjunejune/test/conversation_store_test.c	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/test/conversation_store_test.c	Fri Aug 07 07:34:12 2026 -0700
@@ -1,17 +1,26 @@
 #include "mrjunejune/conversation_store.h"
+#include "deita/deita.h"
 
 #include <assert.h>
 #include <stdio.h>
 #include <string.h>
 #include <unistd.h>
 
-int main(void)
+/* Helper to create and fill a temp DB file path */
+static void make_temp_db(char *out, size_t cap)
 {
-  char database_path[] = "/tmp/mrjunejune-conversations-XXXXXX";
-  int fd = mkstemp(database_path);
+  snprintf(out, cap, "/tmp/mrjunejune-store-test-XXXXXX");
+  int fd = mkstemp(out);
   assert(fd >= 0);
   close(fd);
+}
 
+/* ------------------------------------------------------------------ */
+/* Original (legacy-API) tests                                          */
+/* ------------------------------------------------------------------ */
+
+static void test_legacy_api(const char *database_path)
+{
   Conversation_Store *p_store = Conversation_Store_Create(database_path);
   assert(p_store);
 
@@ -32,34 +41,21 @@
   assert(Conversation_Store_Update_Title(
       p_store, conversation_id, "Renamed quest") == CONVERSATION_STORE_OK);
   assert(Conversation_Store_Begin_Turn(
-      p_store,
-      conversation_id,
-      "request-1",
-      "Hello Epi") == CONVERSATION_STORE_OK);
+      p_store, conversation_id, "request-1", "Hello Epi")
+      == CONVERSATION_STORE_OK);
   assert(Conversation_Store_Begin_Turn(
-      p_store,
-      conversation_id,
-      "request-2",
-      "Overlapping") == CONVERSATION_STORE_CONFLICT);
+      p_store, conversation_id, "request-2", "Overlapping")
+      == CONVERSATION_STORE_CONFLICT);
   assert(Conversation_Store_Complete_Turn(
-      p_store,
-      conversation_id,
-      "request-1",
-      "Hello traveler",
-      12,
-      4) == CONVERSATION_STORE_OK);
+      p_store, conversation_id, "request-1", "Hello traveler", 12, 4)
+      == CONVERSATION_STORE_OK);
 
   assert(Conversation_Store_Begin_Turn(
-      p_store,
-      conversation_id,
-      "request-2",
-      "Try again") == CONVERSATION_STORE_OK);
+      p_store, conversation_id, "request-2", "Try again")
+      == CONVERSATION_STORE_OK);
   assert(Conversation_Store_Fail_Turn(
-      p_store,
-      conversation_id,
-      "request-2",
-      "cancelled",
-      TRUE) == CONVERSATION_STORE_OK);
+      p_store, conversation_id, "request-2", "cancelled", TRUE)
+      == CONVERSATION_STORE_OK);
 
   p_arena = Dowa_Arena_Create(32 * 1024);
   assert(Conversation_Store_Get(
@@ -76,48 +72,641 @@
   Dowa_Arena_Free(p_arena);
 
   assert(Conversation_Store_Begin_Turn(
-      p_store,
-      conversation_id,
-      "request-3",
-      "Interrupted") == CONVERSATION_STORE_OK);
+      p_store, conversation_id, "request-3", "Interrupted")
+      == CONVERSATION_STORE_OK);
   Conversation_Store_Destroy(p_store);
   p_store = Conversation_Store_Create(database_path);
   assert(p_store);
   assert(Conversation_Store_Begin_Turn(
-      p_store,
-      conversation_id,
-      "request-4",
-      "Recovered") == CONVERSATION_STORE_OK);
+      p_store, conversation_id, "request-4", "Recovered")
+      == CONVERSATION_STORE_OK);
   assert(Conversation_Store_Complete_Turn(
-      p_store,
-      conversation_id,
-      "request-4",
-      "Recovered answer",
-      1,
-      1) == CONVERSATION_STORE_OK);
+      p_store, conversation_id, "request-4", "Recovered answer", 1, 1)
+      == CONVERSATION_STORE_OK);
 
   p_arena = Dowa_Arena_Create(32 * 1024);
   assert(Conversation_Store_Get(
       p_store, conversation_id, &record, p_arena) == CONVERSATION_STORE_OK);
   assert(Dowa_Array_Length(record.turns) == 8);
   assert(strcmp(record.turns[5].status, "failed") == 0);
-  assert(strcmp(
-      record.turns[5].error_message,
-      "Interrupted by server restart") == 0);
+  assert(strcmp(record.turns[5].error_message,
+                "Interrupted by server restart") == 0);
+  Dowa_Arena_Free(p_arena);
+
+  assert(Conversation_Store_Delete(p_store, conversation_id)
+         == CONVERSATION_STORE_OK);
+  p_arena = Dowa_Arena_Create(4096);
+  assert(Conversation_Store_Get(p_store, conversation_id, &record, p_arena)
+         == CONVERSATION_STORE_NOT_FOUND);
+  Dowa_Arena_Free(p_arena);
+
+  Conversation_Store_Destroy(p_store);
+  printf("  legacy API tests                                              PASS\n");
+}
+
+/* ------------------------------------------------------------------ */
+/* Migration test: create a DB with old schema (no owner cols), then    */
+/* re-open and verify migration ran, all rows preserved as legacy.      */
+/* ------------------------------------------------------------------ */
+
+static void test_migration_from_old_schema(void)
+{
+  char db_path[64];
+  make_temp_db(db_path, sizeof(db_path));
+
+  /* Simulate an old-schema DB by inserting a row directly */
+  Conversation_Store *p_store = Conversation_Store_Create(db_path);
+  assert(p_store);
+
+  /* Create a conversation using the legacy API (will be owner_kind='legacy') */
+  char legacy_id[37];
+  assert(Conversation_Store_Create_Conversation(
+      p_store, "Old conversation", legacy_id) == CONVERSATION_STORE_OK);
+  Conversation_Store_Destroy(p_store);
+
+  /* Re-open: migration should be idempotent */
+  p_store = Conversation_Store_Create(db_path);
+  assert(p_store);
+
+  /* Legacy row must still be accessible via legacy API */
+  Dowa_Arena *p_arena = Dowa_Arena_Create(8192);
+  Conversation_Record record;
+  assert(Conversation_Store_Get(
+      p_store, legacy_id, &record, p_arena) == CONVERSATION_STORE_OK);
+  assert(strcmp(record.title, "Old conversation") == 0);
+  Dowa_Arena_Free(p_arena);
+
+  /* Owner-aware GET must return NOT_FOUND for a user trying to access a legacy row */
+  Conversation_Owner user_owner = {CONVERSATION_OWNER_KIND_USER, "user-uuid-abc-0000000000000000000000"};
+  p_arena = Dowa_Arena_Create(4096);
+  assert(Conversation_Store_Get_Owned(
+      p_store, legacy_id, &user_owner, &record, p_arena)
+      == CONVERSATION_STORE_NOT_FOUND);
+  Dowa_Arena_Free(p_arena);
+
+  Conversation_Store_Destroy(p_store);
+  unlink(db_path);
+  printf("  migration from old schema                                     PASS\n");
+}
+
+/* ------------------------------------------------------------------ */
+/* Owned CRUD and isolation tests                                        */
+/* ------------------------------------------------------------------ */
+
+static void test_owned_crud_and_isolation(void)
+{
+  char db_path[64];
+  make_temp_db(db_path, sizeof(db_path));
+  Conversation_Store *p_store = Conversation_Store_Create(db_path);
+  assert(p_store);
+
+  Conversation_Owner user_a  = {CONVERSATION_OWNER_KIND_USER,  "aaaaaaaa-0000-0000-0000-000000000001"};
+  Conversation_Owner user_b  = {CONVERSATION_OWNER_KIND_USER,  "bbbbbbbb-0000-0000-0000-000000000002"};
+  Conversation_Owner guest_c = {CONVERSATION_OWNER_KIND_GUEST, "cccccccc-0000-0000-0000-000000000003"};
+
+  char id_a[37], id_b[37], id_c[37];
+  assert(Conversation_Store_Create_Owned(p_store, "A's chat", &user_a, id_a) == CONVERSATION_STORE_OK);
+  assert(Conversation_Store_Create_Owned(p_store, "B's chat", &user_b, id_b) == CONVERSATION_STORE_OK);
+  assert(Conversation_Store_Create_Owned(p_store, "Guest C", &guest_c, id_c) == CONVERSATION_STORE_OK);
+
+  /* Owner A can get their own */
+  Dowa_Arena *p_arena = Dowa_Arena_Create(16 * 1024);
+  Conversation_Record record;
+  assert(Conversation_Store_Get_Owned(
+      p_store, id_a, &user_a, &record, p_arena) == CONVERSATION_STORE_OK);
+  assert(strcmp(record.title, "A's chat") == 0);
+  Dowa_Arena_Free(p_arena);
+
+  /* Owner B cannot get A's conversation (returns NOT_FOUND, no leak) */
+  p_arena = Dowa_Arena_Create(4096);
+  assert(Conversation_Store_Get_Owned(
+      p_store, id_a, &user_b, &record, p_arena) == CONVERSATION_STORE_NOT_FOUND);
+  Dowa_Arena_Free(p_arena);
+
+  /* Guest C cannot get user A's conversation */
+  p_arena = Dowa_Arena_Create(4096);
+  assert(Conversation_Store_Get_Owned(
+      p_store, id_a, &guest_c, &record, p_arena) == CONVERSATION_STORE_NOT_FOUND);
   Dowa_Arena_Free(p_arena);
 
-  assert(Conversation_Store_Delete(
-      p_store, conversation_id) == CONVERSATION_STORE_OK);
+  /* A can update their own title */
+  assert(Conversation_Store_Update_Title_Owned(
+      p_store, id_a, &user_a, "A's renamed chat") == CONVERSATION_STORE_OK);
+  /* B cannot update A's title */
+  assert(Conversation_Store_Update_Title_Owned(
+      p_store, id_a, &user_b, "B tries to rename") == CONVERSATION_STORE_NOT_FOUND);
+
+  /* A can begin a turn */
+  assert(Conversation_Store_Begin_Turn_Owned(
+      p_store, id_a, &user_a, "req-a-1", "Hello from A") == CONVERSATION_STORE_OK);
+  /* B cannot begin a turn on A's conversation */
+  assert(Conversation_Store_Begin_Turn_Owned(
+      p_store, id_a, &user_b, "req-b-1", "B intrudes") == CONVERSATION_STORE_NOT_FOUND);
+  /* Complete the turn */
+  assert(Conversation_Store_Complete_Turn(
+      p_store, id_a, "req-a-1", "Response for A", 5, 2) == CONVERSATION_STORE_OK);
+
+  /* A can delete their own */
+  assert(Conversation_Store_Delete_Owned(p_store, id_a, &user_a) == CONVERSATION_STORE_OK);
+  /* B cannot delete A's (now gone) conversation */
+  assert(Conversation_Store_Delete_Owned(p_store, id_a, &user_b) == CONVERSATION_STORE_NOT_FOUND);
+
+  /* Legacy creation is rejected */
+  Conversation_Owner bad_legacy = {CONVERSATION_OWNER_KIND_LEGACY, ""};
+  char throwaway[37];
+  assert(Conversation_Store_Create_Owned(
+      p_store, "Bad", &bad_legacy, throwaway) == CONVERSATION_STORE_ERROR);
+
+  Conversation_Store_Destroy(p_store);
+  unlink(db_path);
+  printf("  owned CRUD and isolation                                      PASS\n");
+}
+
+/* ------------------------------------------------------------------ */
+/* Paginated listing and cursor tests                                    */
+/* ------------------------------------------------------------------ */
+
+static void test_list_and_cursor(void)
+{
+  char db_path[64];
+  make_temp_db(db_path, sizeof(db_path));
+  Conversation_Store *p_store = Conversation_Store_Create(db_path);
+  assert(p_store);
+
+  Conversation_Owner owner = {CONVERSATION_OWNER_KIND_USER, "11111111-0000-0000-0000-000000000001"};
+  Conversation_Owner other = {CONVERSATION_OWNER_KIND_USER, "22222222-0000-0000-0000-000000000002"};
+
+  /* Create 5 conversations for owner, 1 for other */
+  char ids[5][37];
+  for (int i = 0; i < 5; i++)
+    assert(Conversation_Store_Create_Owned(p_store, "Chat", &owner, ids[i]) == CONVERSATION_STORE_OK);
+  char other_id[37];
+  assert(Conversation_Store_Create_Owned(p_store, "Other chat", &other, other_id) == CONVERSATION_STORE_OK);
+
+  /* List without cursor, limit 3 */
+  Dowa_Arena *p_arena = Dowa_Arena_Create(32 * 1024);
+  Conversation_Summary *summaries = NULL;
+  int32 count = 0;
+  assert(Conversation_Store_List(p_store, &owner, 0, NULL, 3, &summaries, &count, p_arena)
+         == CONVERSATION_STORE_OK);
+  assert(count == 3);
+  Dowa_Arena_Free(p_arena);
+
+  /* List all 5 with limit 10 */
+  p_arena = Dowa_Arena_Create(32 * 1024);
+  assert(Conversation_Store_List(p_store, &owner, 0, NULL, 10, &summaries, &count, p_arena)
+         == CONVERSATION_STORE_OK);
+  assert(count == 5);
+
+  /* Use cursor from end of first page */
+  Conversation_Summary *last_first_page = NULL;
+  Dowa_Arena_Free(p_arena);
+  p_arena = Dowa_Arena_Create(32 * 1024);
+  assert(Conversation_Store_List(p_store, &owner, 0, NULL, 3, &summaries, &count, p_arena)
+         == CONVERSATION_STORE_OK);
+  assert(count == 3);
+  last_first_page = &summaries[2];
+  int64 cur_ts = last_first_page->updated_at;
+  char cur_id[37];
+  strncpy(cur_id, last_first_page->id, sizeof(cur_id) - 1);
+  cur_id[36] = '\0';
+  Dowa_Arena_Free(p_arena);
+
+  p_arena = Dowa_Arena_Create(32 * 1024);
+  assert(Conversation_Store_List(p_store, &owner, cur_ts, cur_id, 10, &summaries, &count, p_arena)
+         == CONVERSATION_STORE_OK);
+  assert(count == 2);
+  Dowa_Arena_Free(p_arena);
+
+  /* Other owner's conversation is NOT in owner's list */
+  p_arena = Dowa_Arena_Create(32 * 1024);
+  assert(Conversation_Store_List(p_store, &other, 0, NULL, 10, &summaries, &count, p_arena)
+         == CONVERSATION_STORE_OK);
+  assert(count == 1);
+  assert(strcmp(summaries[0].id, other_id) == 0);
+  Dowa_Arena_Free(p_arena);
+
+  /* Legacy owner returns empty list */
+  Conversation_Owner legacy_owner = {CONVERSATION_OWNER_KIND_LEGACY, ""};
   p_arena = Dowa_Arena_Create(4096);
-  assert(Conversation_Store_Get(
-      p_store,
-      conversation_id,
-      &record,
-      p_arena) == CONVERSATION_STORE_NOT_FOUND);
+  assert(Conversation_Store_List(p_store, &legacy_owner, 0, NULL, 10, &summaries, &count, p_arena)
+         == CONVERSATION_STORE_OK);
+  assert(count == 0);
+  Dowa_Arena_Free(p_arena);
+
+  /* Limit is capped at 50 */
+  p_arena = Dowa_Arena_Create(32 * 1024);
+  assert(Conversation_Store_List(p_store, &owner, 0, NULL, 200, &summaries, &count, p_arena)
+         == CONVERSATION_STORE_OK);
+  assert(count == 5); /* only 5 exist, so < 50 */
   Dowa_Arena_Free(p_arena);
 
   Conversation_Store_Destroy(p_store);
-  unlink(database_path);
-  printf("Conversation store tests passed\n");
+  unlink(db_path);
+  printf("  list and cursor pagination                                    PASS\n");
+}
+
+/* ------------------------------------------------------------------ */
+/* Legacy claim tests                                                    */
+/* ------------------------------------------------------------------ */
+
+static void test_claim_legacy(void)
+{
+  char db_path[64];
+  make_temp_db(db_path, sizeof(db_path));
+  Conversation_Store *p_store = Conversation_Store_Create(db_path);
+  assert(p_store);
+
+  /* Create a legacy row via the legacy API */
+  char legacy_id[37];
+  assert(Conversation_Store_Create_Conversation(
+      p_store, "Unclaimed adventure", legacy_id) == CONVERSATION_STORE_OK);
+
+  /* Claim as user */
+  const char *user_id = "33333333-0000-0000-0000-000000000099";
+  assert(Conversation_Store_Claim_Legacy(p_store, legacy_id, user_id)
+         == CONVERSATION_STORE_OK);
+
+  /* Now accessible via owned API */
+  Dowa_Arena *p_arena = Dowa_Arena_Create(8192);
+  Conversation_Record record;
+  Conversation_Owner claimed_owner = {CONVERSATION_OWNER_KIND_USER, {0}};
+  strncpy(claimed_owner.id, user_id, sizeof(claimed_owner.id) - 1);
+  assert(Conversation_Store_Get_Owned(
+      p_store, legacy_id, &claimed_owner, &record, p_arena)
+      == CONVERSATION_STORE_OK);
+  assert(strcmp(record.title, "Unclaimed adventure") == 0);
+  Dowa_Arena_Free(p_arena);
+
+  /* Cannot claim again (CONFLICT: already user-owned) */
+  assert(Conversation_Store_Claim_Legacy(p_store, legacy_id, user_id)
+         == CONVERSATION_STORE_CONFLICT);
+
+  /* Non-existent conversation returns NOT_FOUND */
+  assert(Conversation_Store_Claim_Legacy(
+      p_store, "00000000-0000-0000-0000-000000000000", user_id)
+      == CONVERSATION_STORE_NOT_FOUND);
+
+  Conversation_Store_Destroy(p_store);
+  unlink(db_path);
+  printf("  legacy claim                                                  PASS\n");
+}
+
+/* ------------------------------------------------------------------ */
+/* Atomic/idempotent guest-to-user transfer test                        */
+/* ------------------------------------------------------------------ */
+
+static void test_guest_transfer(void)
+{
+  char db_path[64];
+  make_temp_db(db_path, sizeof(db_path));
+  Conversation_Store *p_store = Conversation_Store_Create(db_path);
+  assert(p_store);
+
+  const char *guest_id = "44444444-0000-0000-0000-000000000001";
+  const char *user_id  = "55555555-0000-0000-0000-000000000001";
+  Conversation_Owner guest_owner = {CONVERSATION_OWNER_KIND_GUEST, {0}};
+  Conversation_Owner user_owner  = {CONVERSATION_OWNER_KIND_USER,  {0}};
+  strncpy(guest_owner.id, guest_id, 36);
+  strncpy(user_owner.id, user_id, 36);
+
+  /* Create 3 guest conversations */
+  char gid1[37], gid2[37], gid3[37];
+  assert(Conversation_Store_Create_Owned(p_store, "Guest chat 1", &guest_owner, gid1) == CONVERSATION_STORE_OK);
+  assert(Conversation_Store_Create_Owned(p_store, "Guest chat 2", &guest_owner, gid2) == CONVERSATION_STORE_OK);
+  assert(Conversation_Store_Create_Owned(p_store, "Guest chat 3", &guest_owner, gid3) == CONVERSATION_STORE_OK);
+
+  /* Transfer guest to user */
+  assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_id)
+         == CONVERSATION_STORE_OK);
+
+  /* All three now belong to user */
+  Dowa_Arena *p_arena = Dowa_Arena_Create(8192);
+  Conversation_Record record;
+  assert(Conversation_Store_Get_Owned(p_store, gid1, &user_owner, &record, p_arena) == CONVERSATION_STORE_OK);
+  Dowa_Arena_Free(p_arena);
+  p_arena = Dowa_Arena_Create(8192);
+  assert(Conversation_Store_Get_Owned(p_store, gid2, &user_owner, &record, p_arena) == CONVERSATION_STORE_OK);
+  Dowa_Arena_Free(p_arena);
+  p_arena = Dowa_Arena_Create(8192);
+  assert(Conversation_Store_Get_Owned(p_store, gid3, &user_owner, &record, p_arena) == CONVERSATION_STORE_OK);
+  Dowa_Arena_Free(p_arena);
+
+  /* Guest can no longer see them */
+  p_arena = Dowa_Arena_Create(4096);
+  assert(Conversation_Store_Get_Owned(p_store, gid1, &guest_owner, &record, p_arena) == CONVERSATION_STORE_NOT_FOUND);
+  Dowa_Arena_Free(p_arena);
+
+  /* Idempotent: running again succeeds (no-op) */
+  assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_id)
+         == CONVERSATION_STORE_OK);
+
+  Conversation_Store_Destroy(p_store);
+  unlink(db_path);
+  printf("  atomic/idempotent guest transfer                              PASS\n");
+}
+
+/* ------------------------------------------------------------------ */
+/* Transfer-then-stale-guest-create: race-safety test                   */
+/* ------------------------------------------------------------------ */
+
+static void test_transfer_stale_guest_create(void)
+{
+  char db_path[64];
+  make_temp_db(db_path, sizeof(db_path));
+  Conversation_Store *p_store = Conversation_Store_Create(db_path);
+  assert(p_store);
+
+  const char *guest_id = "66666666-0000-0000-0000-000000000001";
+  const char *user_id  = "77777777-0000-0000-0000-000000000001";
+  Conversation_Owner guest_owner = {CONVERSATION_OWNER_KIND_GUEST, {0}};
+  Conversation_Owner user_owner  = {CONVERSATION_OWNER_KIND_USER,  {0}};
+  strncpy(guest_owner.id, guest_id, 36);
+  strncpy(user_owner.id,  user_id,  36);
+
+  /* Create one conversation as guest before transfer */
+  char pre_transfer_id[37];
+  assert(Conversation_Store_Create_Owned(p_store, "Pre-transfer", &guest_owner, pre_transfer_id)
+         == CONVERSATION_STORE_OK);
+
+  /* Transfer guest to user (records mapping) */
+  assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_id)
+         == CONVERSATION_STORE_OK);
+
+  /* Stale guest create: arrives after transfer (simulates race) */
+  char stale_id[37];
+  assert(Conversation_Store_Create_Owned(p_store, "Stale guest create", &guest_owner, stale_id)
+         == CONVERSATION_STORE_OK);
+
+  /* The stale create must be owned by the mapped user, not the guest */
+  Dowa_Arena *p_arena = Dowa_Arena_Create(8192);
+  Conversation_Record record;
+  /* User can see the stale-created conversation */
+  assert(Conversation_Store_Get_Owned(p_store, stale_id, &user_owner, &record, p_arena)
+         == CONVERSATION_STORE_OK);
+  assert(strcmp(record.title, "Stale guest create") == 0);
+  Dowa_Arena_Free(p_arena);
+
+  /* Guest can no longer see the stale-created conversation */
+  p_arena = Dowa_Arena_Create(4096);
+  assert(Conversation_Store_Get_Owned(p_store, stale_id, &guest_owner, &record, p_arena)
+         == CONVERSATION_STORE_NOT_FOUND);
+  Dowa_Arena_Free(p_arena);
+
+  /* Pre-transfer conversation also belongs to user */
+  p_arena = Dowa_Arena_Create(8192);
+  assert(Conversation_Store_Get_Owned(p_store, pre_transfer_id, &user_owner, &record, p_arena)
+         == CONVERSATION_STORE_OK);
+  Dowa_Arena_Free(p_arena);
+
+  Conversation_Store_Destroy(p_store);
+  unlink(db_path);
+  printf("  transfer then stale guest create → owned by user              PASS\n");
+}
+
+/* ------------------------------------------------------------------ */
+/* Conflicting transfer mapping test                                     */
+/* ------------------------------------------------------------------ */
+
+static void test_transfer_conflict(void)
+{
+  char db_path[64];
+  make_temp_db(db_path, sizeof(db_path));
+  Conversation_Store *p_store = Conversation_Store_Create(db_path);
+  assert(p_store);
+
+  const char *guest_id  = "88888888-0000-0000-0000-000000000001";
+  const char *user_a_id = "aaaaaaaa-1111-0000-0000-000000000001";
+  const char *user_b_id = "bbbbbbbb-2222-0000-0000-000000000001";
+
+  /* First transfer: guest → user A */
+  assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_a_id)
+         == CONVERSATION_STORE_OK);
+
+  /* Idempotent: same mapping again must succeed */
+  assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_a_id)
+         == CONVERSATION_STORE_OK);
+
+  /* Conflicting transfer: guest → user B must fail with CONFLICT */
+  assert(Conversation_Store_Transfer_Guest_To_User(p_store, guest_id, user_b_id)
+         == CONVERSATION_STORE_CONFLICT);
+
+  Conversation_Store_Destroy(p_store);
+  unlink(db_path);
+  printf("  conflicting transfer mapping returns CONFLICT                  PASS\n");
+}
+
+/* ------------------------------------------------------------------ */
+/* Index DESC migration test                                             */
+/* ------------------------------------------------------------------ */
+
+static void test_index_desc_migration(void)
+{
+  char db_path[64];
+  make_temp_db(db_path, sizeof(db_path));
+
+  /* Open twice to verify migration is idempotent */
+  Conversation_Store *p_store = Conversation_Store_Create(db_path);
+  assert(p_store);
+  Conversation_Store_Destroy(p_store);
+
+  /* Re-open: migrations must be idempotent */
+  p_store = Conversation_Store_Create(db_path);
+  assert(p_store);
+
+  /* Verify the listing still works (index is usable) after migration 3 */
+  Conversation_Owner owner = {CONVERSATION_OWNER_KIND_USER, "cccccccc-3333-0000-0000-000000000001"};
+  char id1[37], id2[37];
+  assert(Conversation_Store_Create_Owned(p_store, "Alpha", &owner, id1) == CONVERSATION_STORE_OK);
+  assert(Conversation_Store_Create_Owned(p_store, "Beta",  &owner, id2) == CONVERSATION_STORE_OK);
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(32 * 1024);
+  Conversation_Summary *summaries = NULL;
+  int32 count = 0;
+  assert(Conversation_Store_List(p_store, &owner, 0, NULL, 10, &summaries, &count, p_arena)
+         == CONVERSATION_STORE_OK);
+  /* Both conversations present */
+  assert(count == 2);
+  /* Ordering must be stable: both IDs must appear */
+  assert(
+      (strcmp(summaries[0].id, id1) == 0 || strcmp(summaries[0].id, id2) == 0) &&
+      (strcmp(summaries[1].id, id1) == 0 || strcmp(summaries[1].id, id2) == 0) &&
+      strcmp(summaries[0].id, summaries[1].id) != 0);
+  Dowa_Arena_Free(p_arena);
+
+  Conversation_Store_Destroy(p_store);
+  unlink(db_path);
+  printf("  index DESC migration applied and listing order correct         PASS\n");
+}
+
+/* ------------------------------------------------------------------ */
+/* Transfer_Atomic: conversations + quota cleared in one transaction    */
+/* ------------------------------------------------------------------ */
+
+/*
+ * We exercise Transfer_Guest_To_User_Atomic by inserting synthetic quota
+ * rows directly via a secondary connection (since the conversation store
+ * and auth store share the same SQLite file) and verifying that after the
+ * atomic transfer the reservation rows are gone and output_tokens_reserved
+ * is decremented.
+ */
+static void test_transfer_atomic_quota_cleanup(void)
+{
+  char db_path[64];
+  make_temp_db(db_path, sizeof(db_path));
+
+  Conversation_Store *p_store = Conversation_Store_Create(db_path);
+  assert(p_store);
+
+  /* Seed guest identity, usage, and reservation rows via a raw connection
+   * to simulate what the auth store would have written (in production both
+   * stores share the same SQLite file). */
+  Deita_Connection *p_conn =
+      Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, db_path);
+  assert(p_conn);
+  Deita_Query_Execute_Update(p_conn,
+      "PRAGMA foreign_keys = OFF;"
+      "PRAGMA journal_mode = WAL;");
+
+  /* Create auth tables that share the same file in production. */
+  Deita_Query_Execute_Update(p_conn,
+      "CREATE TABLE IF NOT EXISTS guest_identities ("
+      "  id TEXT PRIMARY KEY,"
+      "  ip_binding_digest TEXT NOT NULL,"
+      "  created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+      "  last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+      "  expires_at INTEGER NOT NULL"
+      ");");
+  Deita_Query_Execute_Update(p_conn,
+      "CREATE TABLE IF NOT EXISTS guest_usage ("
+      "  guest_id TEXT NOT NULL,"
+      "  window_start INTEGER NOT NULL,"
+      "  count INTEGER NOT NULL DEFAULT 0,"
+      "  turns_used INTEGER NOT NULL DEFAULT 0,"
+      "  output_tokens_used INTEGER NOT NULL DEFAULT 0,"
+      "  output_tokens_reserved INTEGER NOT NULL DEFAULT 0,"
+      "  PRIMARY KEY (guest_id, window_start)"
+      ");");
+  Deita_Query_Execute_Update(p_conn,
+      "CREATE TABLE IF NOT EXISTS guest_usage_reservations ("
+      "  request_id TEXT PRIMARY KEY,"
+      "  guest_id TEXT NOT NULL,"
+      "  window_start INTEGER NOT NULL,"
+      "  output_tokens_reserved INTEGER NOT NULL DEFAULT 0,"
+      "  reserved_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),"
+      "  expires_at INTEGER NOT NULL"
+      ");");
+
+  const char *gid  = "aaaabbbb-cccc-4000-8000-111111111111";
+  const char *uid  = "ddddeeee-ffff-4000-8000-222222222222";
+  const char *rid1 = "rrrr1111-0000-4000-8000-000000000001";
+  const char *rid2 = "rrrr2222-0000-4000-8000-000000000002";
+
+  /* guest_identities */
+  const char *gid_p[] = {gid};
+  Deita_Query_Execute_Update_Prepared(p_conn,
+      "INSERT OR IGNORE INTO guest_identities "
+      "(id, ip_binding_digest, expires_at) VALUES (?, 'x', 9999999999)",
+      1, gid_p);
+
+  /* guest_usage: 2 turns used, 300 tokens reserved */
+  const char *gu_p[] = {gid};
+  Deita_Query_Execute_Update_Prepared(p_conn,
+      "INSERT OR REPLACE INTO guest_usage "
+      "(guest_id, window_start, count, turns_used, output_tokens_used, "
+      " output_tokens_reserved) VALUES (?, 1700524800, 2, 2, 0, 300)",
+      1, gu_p);
+
+  /* Two reservation rows (100 + 200 = 300 total) */
+  const char *r1_p[] = {rid1, gid};
+  Deita_Query_Execute_Update_Prepared(p_conn,
+      "INSERT OR REPLACE INTO guest_usage_reservations "
+      "(request_id, guest_id, window_start, output_tokens_reserved, expires_at) "
+      "VALUES (?, ?, 1700524800, 100, 9999999999)",
+      2, r1_p);
+  const char *r2_p[] = {rid2, gid};
+  Deita_Query_Execute_Update_Prepared(p_conn,
+      "INSERT OR REPLACE INTO guest_usage_reservations "
+      "(request_id, guest_id, window_start, output_tokens_reserved, expires_at) "
+      "VALUES (?, ?, 1700524800, 200, 9999999999)",
+      2, r2_p);
+  Deita_Connection_Close(p_conn);
+
+  /* Create one guest conversation */
+  Conversation_Owner g_owner = {CONVERSATION_OWNER_KIND_GUEST, ""};
+  strncpy(g_owner.id, gid, 36);
+  char conv_id[37];
+  assert(Conversation_Store_Create_Owned(p_store, "G conv", &g_owner, conv_id)
+         == CONVERSATION_STORE_OK);
+
+  /* Atomic transfer */
+  assert(Conversation_Store_Transfer_Guest_To_User_Atomic(
+      p_store, gid, uid) == CONVERSATION_STORE_OK);
+
+  /* Verify: conversation now belongs to user */
+  Conversation_Owner u_owner = {CONVERSATION_OWNER_KIND_USER, ""};
+  strncpy(u_owner.id, uid, 36);
+  Dowa_Arena *p_arena = Dowa_Arena_Create(8 * 1024);
+  Conversation_Record rec;
+  assert(Conversation_Store_Get_Owned(
+      p_store, conv_id, &u_owner, &rec, p_arena) == CONVERSATION_STORE_OK);
+  Dowa_Arena_Free(p_arena);
+
+  /* Verify: quota reservation rows deleted and output_tokens_reserved = 0 */
+  Dowa_Arena *chk_arena = Dowa_Arena_Create(4096);
+  Deita_Connection *p_chk =
+      Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, db_path);
+  assert(p_chk);
+  Deita_Query_Execute_Update(p_chk, "PRAGMA journal_mode = WAL;");
+
+  const char *chk_gid[] = {gid};
+  Deita_Result_Set *rs = Deita_Query_Execute_Prepared(
+      p_chk,
+      "SELECT output_tokens_reserved FROM guest_usage WHERE guest_id = ?",
+      1, chk_gid, chk_arena);
+  assert(rs && Deita_Result_Set_Next(rs));
+  assert(Deita_Result_Set_Get_Integer(rs, 0) == 0);
+  Deita_Result_Set_Free(rs);
+
+  rs = Deita_Query_Execute_Prepared(
+      p_chk,
+      "SELECT COUNT(*) FROM guest_usage_reservations WHERE guest_id = ?",
+      1, chk_gid, chk_arena);
+  assert(rs && Deita_Result_Set_Next(rs));
+  assert(Deita_Result_Set_Get_Integer(rs, 0) == 0);
+  Deita_Result_Set_Free(rs);
+
+  Deita_Connection_Close(p_chk);
+  Dowa_Arena_Free(chk_arena);
+
+  /* Idempotent: re-run same transfer is a no-op */
+  assert(Conversation_Store_Transfer_Guest_To_User_Atomic(
+      p_store, gid, uid) == CONVERSATION_STORE_OK);
+
+  Conversation_Store_Destroy(p_store);
+  unlink(db_path);
+  printf("  transfer_atomic_quota_cleanup                                  PASS\n");
+}
+
+int main(void)
+{
+  printf("conversation_store tests:\n");
+
+  char legacy_db[64];
+  make_temp_db(legacy_db, sizeof(legacy_db));
+  test_legacy_api(legacy_db);
+  unlink(legacy_db);
+
+  test_migration_from_old_schema();
+  test_owned_crud_and_isolation();
+  test_list_and_cursor();
+  test_claim_legacy();
+  test_guest_transfer();
+  test_transfer_stale_guest_create();
+  test_transfer_conflict();
+  test_index_desc_migration();
+  test_transfer_atomic_quota_cleanup();
+
+  printf("All conversation store tests passed\n");
   return 0;
 }
--- a/mrjunejune/test/production_bundle_exclusion_test.sh	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/test/production_bundle_exclusion_test.sh	Fri Aug 07 07:34:12 2026 -0700
@@ -3,12 +3,54 @@
 
 bundle="$1"
 
-if find "$bundle" -type f | grep -E '/jrpg/|pixel-mplus-12-regular'; then
-  echo "Production bundle contains development-only JRPG files" >&2
+# Dereference symlinks so we scan the actual file tree.
+# Assert at least one regular file exists to catch an empty/broken bundle.
+file_count=$(find -L "$bundle" -type f | wc -l)
+if [[ "$file_count" -eq 0 ]]; then
+  echo "Production bundle appears empty — no regular files found under $bundle" >&2
+  exit 1
+fi
+
+# ── No .env file (contains real AWS credentials in developer environment) ─────
+if find -L "$bundle" -type f -name ".env" | grep -q .; then
+  echo "Production bundle contains .env (credential file)" >&2
+  exit 1
+fi
+
+# ── No .config file (contains real secrets in developer environment) ──────────
+if find -L "$bundle" -type f -name ".config" | grep -q .; then
+  echo "Production bundle contains .config (secret file)" >&2
+  exit 1
+fi
+
+# ── No SQLite database, WAL, or SHM files ─────────────────────────────────────
+if find -L "$bundle" -type f \( -name "*.db" -o -name "*.db-wal" -o -name "*.db-shm" \) | grep -q .; then
+  echo "Production bundle contains a database or WAL/SHM file" >&2
   exit 1
 fi
 
-if grep -R -a -l -E '"/jrpg"|/jrpg/index\.html' "$bundle"; then
-  echo "Production bundle contains a development-only JRPG route" >&2
+# ── No AUTH_COOKIE_SECRET values (non-hex-chars after = are false positive safe) ─
+# Match a line that looks like AUTH_COOKIE_SECRET=<hex-looking value> (≥64 chars)
+if grep -R -a -l -E 'AUTH_COOKIE_SECRET=[0-9a-fA-F]{64}' "$bundle"; then
+  echo "Production bundle contains an AUTH_COOKIE_SECRET assignment with a secret value" >&2
   exit 1
 fi
+
+# ── No zenbu-scrypt hashes (bootstrap password hashes) ────────────────────────
+# The literal prefix "zenbu-scrypt$" followed by a v= parameter is the real hash
+# format; safe references are only in comments or test-fixture strings in binaries.
+# We scan text files only (config, scripts, yaml) — not compiled binaries.
+if find -L "$bundle" -type f \( -name "*.sh" -o -name "*.yaml" -o -name "*.json" -o -name ".config*" \) \
+    -exec grep -l 'zenbu-scrypt\$v=' {} \;  | grep -q .; then
+  echo "Production bundle contains a zenbu-scrypt password hash in a config/script" >&2
+  exit 1
+fi
+
+# ── No mjj_session or mjj_guest raw token values in config/scripts ─────────────
+# Cookie *names* appear safely in source code; we check only config/script files.
+if find -L "$bundle" -type f \( -name "*.sh" -o -name "*.yaml" -o -name "*.json" -o -name ".config*" \) \
+    -exec grep -lE 'mjj_session=[A-Za-z0-9_-]{20,}|mjj_guest=[A-Za-z0-9_-]{20,}' {} \; | grep -q .; then
+  echo "Production bundle contains raw mjj_session/mjj_guest token values" >&2
+  exit 1
+fi
+
--- a/mrjunejune/test/snapshots/jrpg.snapshot	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/test/snapshots/jrpg.snapshot	Fri Aug 07 07:34:12 2026 -0700
@@ -32,7 +32,8 @@
 
 <script src="/public/pwa-register.js" defer></script>
 
-    <link rel="preload" href="/public/jrpg/background-frame-2.webp" as="image">
+    <link rel="preload" href="/public/jrpg/background-frame-2.webp" as="image" media="not ((max-width: 52rem) and (orientation: portrait))">
+    <link rel="preload" href="/public/jrpg/background-frame-mobile.webp" as="image" media="(max-width: 52rem) and (orientation: portrait)">
     <link rel="preload" href="/public/jrpg/bar-ink.webp" as="image">
     <link rel="stylesheet" href="/jrpg/jrpg.css">
     <script type="module" src="/jrpg/jrpg.js"></script>
@@ -75,9 +76,20 @@
               </a>
             </zen-button>
           </nav>
+          <zen-button class="jrpg-mobile-menu-btn" appearance="plain" size="xs">
+            <button
+              type="button"
+              data-mobile-menu-toggle
+              aria-label="Toggle destination menu"
+              aria-expanded="true"
+              aria-controls="jrpg-destination-menu"
+            >
+              <zen-icon name="menu"></zen-icon>
+            </button>
+          </zen-button>
           <footer class="jrpg-frame-telemetry" aria-label="System telemetry">
             <span aria-label="Temperature unavailable">N/A</span>
-            <span aria-label="User June">JUNE</span>
+            <span data-frame-account aria-live="polite"><a href="/login?next=/jrpg">LOGIN</a></span>
             <span data-frame-network aria-label="Status online">ONLINE</span>
             <time data-frame-uptime aria-label="Uptime">00:00:00</time>
           </footer>
@@ -132,11 +144,12 @@
           <section class="jrpg-utility" aria-label="Selected destination">
             <mjj-jrpg-preview data-selection="resume">
               <article class="jrpg-preview-panel" aria-live="polite">
-                <p class="jrpg-preview-kicker" data-preview-kicker>CHARACTER RECORD</p>
                 <zen-heading size="xl">
                   <h2 data-preview-title>Resume</h2>
                 </zen-heading>
-                <p data-preview-copy>Experience, projects, and the systems I have helped build.</p>
+                <p data-preview-copy>
+                  Member of Technical Staff and engineering leader with 10+ years building AI agent platforms and production systems.
+                </p>
                 <ul class="jrpg-work-showcase" data-work-showcase>
                   <li>
                     <a href="https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/">
@@ -222,6 +235,77 @@
                 </zen-dialog>
               </article>
             </mjj-jrpg-preview>
+
+            <mjj-conversation-archive hidden aria-label="Conversations">
+              <div class="jrpg-archive-heading">
+                <h2>Archive</h2>
+                <zen-button appearance="plain" size="xs">
+                  <button type="button" data-archive-close aria-label="Close conversations">
+                    <zen-icon name="close"></zen-icon>
+                  </button>
+                </zen-button>
+              </div>
+              <div class="jrpg-archive-actions">
+                <zen-button appearance="plain" size="sm">
+                  <button type="button" data-archive-new>
+                    <zen-icon name="plus"></zen-icon>
+                    New
+                  </button>
+                </zen-button>
+              </div>
+              <ol class="jrpg-archive-list" data-archive-list aria-label="Conversations">
+              </ol>
+              <zen-button appearance="plain" size="sm" data-archive-load-more-owner hidden>
+                <button type="button" data-archive-load-more>Load more</button>
+              </zen-button>
+              <p class="jrpg-archive-status" data-archive-status aria-live="polite">Loading conversations...</p>
+              <dialog
+                class="jrpg-archive-dialog"
+                data-archive-delete-dialog
+                aria-labelledby="archive-delete-title"
+                aria-modal="true"
+              >
+                <p id="archive-delete-title" class="jrpg-archive-dialog-title">Delete this conversation?</p>
+                <p data-archive-delete-name class="jrpg-archive-dialog-subtitle"></p>
+                <div class="jrpg-archive-dialog-actions">
+                  <zen-button appearance="plain" size="md">
+                    <button type="button" data-archive-delete-cancel>Cancel</button>
+                  </zen-button>
+                  <zen-button appearance="plain" size="md">
+                    <button type="button" data-archive-delete-confirm>Delete</button>
+                  </zen-button>
+                </div>
+              </dialog>
+              <dialog
+                class="jrpg-archive-dialog"
+                data-archive-rename-dialog
+                aria-labelledby="archive-rename-title"
+                aria-modal="true"
+              >
+                <p id="archive-rename-title" class="jrpg-archive-dialog-title">Rename conversation</p>
+                <form data-archive-rename-form novalidate>
+                  <zen-field appearance="plain" size="md">
+                    <label for="archive-rename-input">New title</label>
+                    <input
+                      id="archive-rename-input"
+                      data-archive-rename-input
+                      type="text"
+                      maxlength="200"
+                      required
+                      autocomplete="off"
+                    >
+                  </zen-field>
+                  <div class="jrpg-archive-dialog-actions">
+                    <zen-button appearance="plain" size="md">
+                      <button type="button" data-archive-rename-cancel>Cancel</button>
+                    </zen-button>
+                    <zen-button appearance="plain" size="md">
+                      <button type="submit">Save</button>
+                    </zen-button>
+                  </div>
+                </form>
+              </dialog>
+            </mjj-conversation-archive>
           </section>
 
           <mjj-jrpg-composer>
@@ -251,11 +335,12 @@
                   </button>
                 </zen-button>
               </div>
+              <p class="jrpg-composer-quota" data-quota aria-live="polite" hidden></p>
               <p class="jrpg-composer-hint">Enter to send / Shift + Enter for a new line</p>
             </form>
           </mjj-jrpg-composer>
 
-          <mjj-jrpg-menu>
+          <mjj-jrpg-menu id="jrpg-destination-menu">
             <nav aria-label="Shiba Quest destinations">
               <p class="jrpg-menu-label">Menu</p>
               <ul>
@@ -283,9 +368,74 @@
                     </button>
                   </zen-button>
                 </li>
+                <li>
+                  <zen-button appearance="plain" size="sm">
+                    <button type="button" data-preview="conversations" aria-pressed="false">
+                      <zen-icon name="chevron-right"></zen-icon>
+                      Conversations
+                    </button>
+                  </zen-button>
+                </li>
               </ul>
             </nav>
           </mjj-jrpg-menu>
+
+          <zen-dialog class="jrpg-login-dialog-owner" data-login-dialog-owner>
+            <zen-button appearance="plain" size="md" hidden>
+              <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button>
+            </zen-button>
+            <dialog
+              class="jrpg-login-dialog"
+              data-login-dialog
+              aria-labelledby="jrpg-login-title"
+              aria-modal="true"
+            >
+              <div class="jrpg-login-dialog-header">
+                <h2 id="jrpg-login-title">SIGN IN</h2>
+                <zen-button appearance="plain" size="md">
+                  <button type="button" data-zen-close aria-label="Close sign-in dialog">
+                    <zen-icon name="close"></zen-icon>
+                  </button>
+                </zen-button>
+              </div>
+              <div role="alert" data-login-error aria-live="assertive" hidden></div>
+              <form data-login-form novalidate>
+                <zen-field appearance="plain" size="md">
+                  <label for="jrpg-login-username">Username</label>
+                  <input
+                    id="jrpg-login-username"
+                    name="username"
+                    type="text"
+                    autocomplete="username"
+                    autocapitalize="none"
+                    spellcheck="false"
+                    required
+                    maxlength="32"
+                  >
+                </zen-field>
+                <zen-field appearance="plain" size="md">
+                  <label for="jrpg-login-password">Password</label>
+                  <input
+                    id="jrpg-login-password"
+                    name="password"
+                    type="password"
+                    autocomplete="current-password"
+                    required
+                    minlength="12"
+                    maxlength="1024"
+                  >
+                </zen-field>
+                <div class="jrpg-login-dialog-actions">
+                  <zen-button appearance="plain" size="md">
+                    <button type="button" data-login-cancel data-zen-close>Cancel</button>
+                  </zen-button>
+                  <zen-button appearance="plain" size="md">
+                    <button type="submit" data-login-submit>Sign in</button>
+                  </zen-button>
+                </div>
+              </form>
+            </dialog>
+          </zen-dialog>
         </div>
       </mjj-jrpg-shell>
     </main>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/test/template_renderer_test.c	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,336 @@
+/*
+ * template_renderer_test.c — unit tests for the Mjj template renderer.
+ *
+ * Tests cover:
+ *   - Passthrough (no includes)
+ *   - Include expansion with real parts files
+ *   - Missing include (silently skipped, output is still valid)
+ *   - Missing file (Mjj_Template_Render_File returns FALSE)
+ *   - Capacity-overflow protection (returns FALSE)
+ *   - Page bodies contain no literal {{ / }} / <{{ markers
+ *   - Rendered pages contain expected design-system asset references
+ */
+
+#include "mrjunejune/template_renderer.h"
+
+#include "dowa/dowa.h"
+
+#include <assert.h>
+#include <stdio.h>
+#include <string.h>
+#include <stdlib.h>
+
+/* ------------------------------------------------------------------ */
+/* Utilities                                                            */
+/* ------------------------------------------------------------------ */
+
+#define ASSERT(cond) \
+  do { \
+    if (!(cond)) { \
+      fprintf(stderr, "FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
+      abort(); \
+    } \
+  } while (0)
+
+#define TEST(name) \
+  do { fprintf(stdout, "  %-60s", name); fflush(stdout); } while (0)
+
+#define PASS() \
+  do { fprintf(stdout, "PASS\n"); } while (0)
+
+/* Capacity used for page-level renders */
+#define PAGE_CAP (256 * 1024)
+
+/* ------------------------------------------------------------------ */
+/* Tests                                                                */
+/* ------------------------------------------------------------------ */
+
+static void test_passthrough(void)
+{
+  printf("\n[passthrough — no includes]\n");
+
+  Dowa_Arena *arena = Dowa_Arena_Create(4096);
+  ASSERT(arena);
+
+  TEST("plain text passes through unchanged");
+  {
+    char out[64];
+    boolean ok = Mjj_Template_Render(out, sizeof(out), "Hello, world!", arena);
+    ASSERT(ok);
+    ASSERT(strcmp(out, "Hello, world!") == 0);
+  }
+  PASS();
+
+  TEST("empty template produces empty string");
+  {
+    char out[8];
+    out[0] = 'x';
+    boolean ok = Mjj_Template_Render(out, sizeof(out), "", arena);
+    ASSERT(ok);
+    ASSERT(out[0] == '\0');
+  }
+  PASS();
+
+  TEST("NULL template returns FALSE");
+  {
+    char out[8];
+    boolean ok = Mjj_Template_Render(out, sizeof(out), NULL, arena);
+    ASSERT(!ok);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+}
+
+static void test_capacity_overflow(void)
+{
+  printf("\n[capacity-overflow protection]\n");
+
+  Dowa_Arena *arena = Dowa_Arena_Create(4096);
+  ASSERT(arena);
+
+  TEST("render into 1-byte buffer returns FALSE");
+  {
+    char out[1];
+    boolean ok = Mjj_Template_Render(out, 1, "Hello", arena);
+    ASSERT(!ok);
+    /* NUL-termination must still hold */
+    ASSERT(out[0] == '\0');
+  }
+  PASS();
+
+  TEST("render into exactly-fitting buffer (size = len+1) returns TRUE");
+  {
+    const char *src = "Hi";
+    char out[3]; /* 2 chars + NUL */
+    boolean ok = Mjj_Template_Render(out, sizeof(out), src, arena);
+    ASSERT(ok);
+    ASSERT(strcmp(out, "Hi") == 0);
+  }
+  PASS();
+
+  TEST("render into too-small buffer returns FALSE");
+  {
+    const char *src = "Hello world";
+    char out[5];
+    boolean ok = Mjj_Template_Render(out, sizeof(out), src, arena);
+    ASSERT(!ok);
+    /* Must still be NUL-terminated */
+    ASSERT(out[sizeof(out) - 1] == '\0');
+  }
+  PASS();
+
+  TEST("Render_File into tiny buffer returns FALSE");
+  {
+    char out[4];
+    boolean ok = Mjj_Template_Render_File(out, sizeof(out), "/login/index.html", arena);
+    ASSERT(!ok);
+    ASSERT(out[sizeof(out) - 1] == '\0' || out[0] == '\0');
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+}
+
+static void test_missing_file(void)
+{
+  printf("\n[missing file / missing include]\n");
+
+  Dowa_Arena *arena = Dowa_Arena_Create(4096);
+  ASSERT(arena);
+
+  TEST("Render_File on non-existent path returns FALSE");
+  {
+    char out[256];
+    boolean ok = Mjj_Template_Render_File(out, sizeof(out), "/does/not/exist.html", arena);
+    ASSERT(!ok);
+  }
+  PASS();
+
+  TEST("missing include in template is skipped silently");
+  {
+    char out[256];
+    boolean ok = Mjj_Template_Render(
+        out, sizeof(out),
+        "before{{/parts/this_does_not_exist.html}}after",
+        arena);
+    ASSERT(ok);
+    ASSERT(strcmp(out, "beforeafter") == 0);
+    /* No literal {{ or }} should remain */
+    ASSERT(strstr(out, "{{") == NULL);
+    ASSERT(strstr(out, "}}") == NULL);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+}
+
+static void test_include_expansion(void)
+{
+  printf("\n[include expansion — real parts files]\n");
+
+  Dowa_Arena *arena = Dowa_Arena_Create(8192);
+  ASSERT(arena);
+
+  TEST("{{/parts/base_head.html}} expands to non-empty content");
+  {
+    char *out = (char *)malloc(PAGE_CAP);
+    ASSERT(out);
+    boolean ok = Mjj_Template_Render(
+        out, PAGE_CAP,
+        "A{{/parts/base_head.html}}B",
+        arena);
+    ASSERT(ok);
+    /* Expansion must have happened: output is longer than "AB" */
+    ASSERT(strlen(out) > 2);
+    /* No literal markers must remain */
+    ASSERT(strstr(out, "{{") == NULL);
+    ASSERT(strstr(out, "}}") == NULL);
+    /* base_head includes charset and stylesheet references */
+    ASSERT(strstr(out, "charset") != NULL);
+    ASSERT(strstr(out, "design-system") != NULL);
+    free(out);
+  }
+  PASS();
+
+  TEST("{{/parts/header.html}} expands and contains MrJuneJune link");
+  {
+    char *out = (char *)malloc(PAGE_CAP);
+    ASSERT(out);
+    boolean ok = Mjj_Template_Render(
+        out, PAGE_CAP,
+        "{{/parts/header.html}}",
+        arena);
+    ASSERT(ok);
+    ASSERT(strlen(out) > 0);
+    ASSERT(strstr(out, "{{") == NULL);
+    ASSERT(strstr(out, "MrJuneJune") != NULL);
+    free(out);
+  }
+  PASS();
+
+  Dowa_Arena_Free(arena);
+}
+
+/* Check that a fully rendered page contains no literal template markers. */
+static void assert_page_clean(const char *path, char *buf)
+{
+  Dowa_Arena *arena = Dowa_Arena_Create(8192);
+  ASSERT(arena);
+
+  boolean ok = Mjj_Template_Render_File(buf, PAGE_CAP, path, arena);
+  if (!ok)
+  {
+    fprintf(stderr, "  FAIL: Mjj_Template_Render_File returned FALSE for '%s'\n", path);
+    abort();
+  }
+
+  if (strstr(buf, "{{"))
+  {
+    fprintf(stderr, "  FAIL: rendered '%s' contains '{{'\n", path);
+    abort();
+  }
+  if (strstr(buf, "}}"))
+  {
+    fprintf(stderr, "  FAIL: rendered '%s' contains '}}'\n", path);
+    abort();
+  }
+  if (strstr(buf, "<{{"))
+  {
+    fprintf(stderr, "  FAIL: rendered '%s' contains '<{{'\n", path);
+    abort();
+  }
+
+  Dowa_Arena_Free(arena);
+}
+
+static void test_page_bodies(void)
+{
+  printf("\n[page bodies — no literal markers, design-system assets present]\n");
+
+  char *buf = (char *)malloc(PAGE_CAP);
+  ASSERT(buf);
+
+  const char *pages[] = {
+    "/login/index.html",
+    "/account/password.html",
+    "/admin/users/index.html",
+    "/index.html",
+    "/resume/index.html",
+    "/tools/index.html",
+    "/blog/index.html",
+    "/talk/index.html",
+    "/notes/index.html",
+    NULL
+  };
+
+  for (int i = 0; pages[i]; i++)
+  {
+    char label[128];
+    snprintf(label, sizeof(label), "no {{ in rendered '%s'", pages[i]);
+    TEST(label);
+    assert_page_clean(pages[i], buf);
+    PASS();
+  }
+
+  /* Verify design-system stylesheet and component script are referenced
+     in the login page (canonical example that contains base_head). */
+  TEST("login page references design-system/styles/tokens.css");
+  {
+    Dowa_Arena *arena = Dowa_Arena_Create(4096);
+    ASSERT(arena);
+    boolean ok = Mjj_Template_Render_File(buf, PAGE_CAP, "/login/index.html", arena);
+    ASSERT(ok);
+    ASSERT(strstr(buf, "design-system") != NULL);
+    ASSERT(strstr(buf, "tokens.css") != NULL);
+    Dowa_Arena_Free(arena);
+  }
+  PASS();
+
+  TEST("admin page references design-system and header");
+  {
+    Dowa_Arena *arena = Dowa_Arena_Create(4096);
+    ASSERT(arena);
+    boolean ok = Mjj_Template_Render_File(buf, PAGE_CAP, "/admin/users/index.html", arena);
+    ASSERT(ok);
+    ASSERT(strstr(buf, "design-system") != NULL);
+    ASSERT(strstr(buf, "MrJuneJune") != NULL);
+    Dowa_Arena_Free(arena);
+  }
+  PASS();
+
+  TEST("password page references design-system and header");
+  {
+    Dowa_Arena *arena = Dowa_Arena_Create(4096);
+    ASSERT(arena);
+    boolean ok = Mjj_Template_Render_File(buf, PAGE_CAP, "/account/password.html", arena);
+    ASSERT(ok);
+    ASSERT(strstr(buf, "design-system") != NULL);
+    ASSERT(strstr(buf, "MrJuneJune") != NULL);
+    Dowa_Arena_Free(arena);
+  }
+  PASS();
+
+  free(buf);
+}
+
+/* ------------------------------------------------------------------ */
+/* main                                                                 */
+/* ------------------------------------------------------------------ */
+
+int main(void)
+{
+  printf("=== template_renderer_test ===\n");
+
+  /* The renderer defaults to "mrjunejune/src"; in Bazel test, CWD is the
+     runfiles root where mrjunejune/src/... is directly accessible. */
+
+  test_passthrough();
+  test_capacity_overflow();
+  test_missing_file();
+  test_include_expansion();
+  test_page_bodies();
+
+  printf("\nAll template_renderer tests passed.\n");
+  return 0;
+}
--- a/mrjunejune/test/theme_and_webp_test.js	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/test/theme_and_webp_test.js	Fri Aug 07 07:34:12 2026 -0700
@@ -15,6 +15,8 @@
   'hg-web/e2e/node_modules/playwright-core',
 );
 const { chromium } = require(playwrightPath);
+const browserSuite = process.env.MJJ_BROWSER_SUITE || 'all';
+const runsSuite = name => browserSuite === 'all' || browserSuite === name;
 
 function stopProcess(child) {
   if (!child || child.exitCode !== null) return Promise.resolve();
@@ -477,6 +479,10 @@
         request.failure()?.errorText === 'net::ERR_ABORTED') {
       return;
     }
+    if (request.url().includes('/api/auth/login') &&
+        request.failure()?.errorText === 'net::ERR_ABORTED') {
+      return;
+    }
     errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`);
   });
   page.on('response', response => {
@@ -486,6 +492,9 @@
         )) {
       return;
     }
+    if (response.url().includes('/api/auth/login') && response.status() >= 400) {
+      return; /* expected: login attempt with invalid credentials in modal test */
+    }
     if (response.status() >= 400) {
       errors.push(`response: ${response.status()} ${response.url()}`);
     }
@@ -707,6 +716,10 @@
       noOverflow: document.documentElement.scrollWidth <= innerWidth,
       nonPixelText,
       questLabelAbsent: !document.body.textContent.includes('QUEST 01'),
+      previewLabelsRemoved:
+        !/CHARACTER RECORD|ITEM INVENTORY|QUEST ARCHIVE/.test(
+          document.querySelector('.jrpg-preview-panel').textContent,
+        ),
       statsRemoved:
         !document.querySelector('.jrpg-preview-stats') &&
         !document.querySelector('[data-preview-type]'),
@@ -741,6 +754,8 @@
         ),
       shibaInFrontOfBar:
         sceneBarStyle.backgroundImage.includes('bar-ink.webp') &&
+        sceneBarStyle.backgroundSize === 'cover' &&
+        Math.abs(parseFloat(sceneBarStyle.height) - scene.height) < 1 &&
         Number(getComputedStyle(character.parentElement).zIndex) >
           Number(sceneBarStyle.zIndex) &&
         characterBox.bottom > scene.top + scene.height * 0.65,
@@ -789,6 +804,7 @@
   assert.equal(desktop.noOverflow, true);
   assert.deepEqual(desktop.nonPixelText, []);
   assert.equal(desktop.questLabelAbsent, true);
+  assert.equal(desktop.previewLabelsRemoved, true);
   assert.equal(desktop.statsRemoved, true);
   assert.equal(desktop.telemetryCovered, true);
   assert.equal(desktop.telemetryValuesAligned, true);
@@ -823,6 +839,149 @@
     await page.locator('.jrpg-frame-controls a').getAttribute('href'),
     '/',
   );
+
+  // Account slot: guest session shows a login button (modal trigger)
+  assert.ok(
+    await page.locator('[data-frame-account]').count() > 0,
+    'account slot exists in telemetry',
+  );
+  await page.waitForFunction(() =>
+    document.querySelector('[data-frame-account]')?.querySelector('button[data-login-open]') !== null
+  );
+  const loginBtnState = await page.locator('[data-frame-account] button[data-login-open]').evaluate(btn => ({
+    text: btn.textContent.trim(),
+    ownerCount: btn.closest('zen-button') ? 1 : 0,
+    appearance: btn.closest('zen-button')?.getAttribute('appearance'),
+    size: btn.closest('zen-button')?.getAttribute('size'),
+  }));
+  assert.match(loginBtnState.text, /LOGIN/i);
+  assert.equal(loginBtnState.ownerCount, 1, 'login button has zen-button owner');
+  assert.equal(loginBtnState.appearance, 'plain');
+  assert.equal(loginBtnState.size, 'xs');
+
+  // Login modal: opens on button click, focuses username, closes on cancel
+  await page.locator('[data-frame-account] button[data-login-open]').click();
+  await page.locator('[data-login-dialog]').waitFor({ state: 'visible' });
+  assert.equal(
+    await page.locator('[data-login-dialog]').getAttribute('aria-modal'),
+    'true',
+    'login dialog is modal',
+  );
+  // Username input should receive initial focus
+  await page.waitForFunction(() => document.activeElement?.id === 'jrpg-login-username');
+  // Cancel closes dialog and clears password
+  await page.locator('#jrpg-login-password').fill('dummypassword');
+  await page.locator('[data-login-cancel]').click();
+  await page.locator('[data-login-dialog]').waitFor({ state: 'hidden' });
+  await page.waitForFunction(() => !document.querySelector('#jrpg-login-password')?.value);
+  assert.equal(
+    await page.locator('#jrpg-login-password').inputValue(),
+    '',
+    'password cleared on close',
+  );
+
+  // Invalid credentials show error message
+  await page.locator('[data-frame-account] button[data-login-open]').click();
+  await page.locator('[data-login-dialog]').waitFor({ state: 'visible' });
+  await page.locator('#jrpg-login-username').fill('nosuchuser');
+  await page.locator('#jrpg-login-password').fill('nosuchpassword123');
+  let loginErrorShown = false;
+  const loginResponsePromise = page.waitForResponse(resp =>
+    resp.url().includes('/api/auth/login')
+  );
+  await page.locator('[data-login-submit]').click();
+  const loginResp = await loginResponsePromise;
+  if (loginResp.status() >= 400) {
+    await page.waitForFunction(() => {
+      const el = document.querySelector('[data-login-error]');
+      return el && !el.hidden && el.textContent.trim().length > 0;
+    });
+    loginErrorShown = true;
+  }
+  assert.equal(loginErrorShown, true, 'error element shown on bad credentials');
+  assert.equal(
+    await page.locator('#jrpg-login-password').inputValue(),
+    '',
+    'password cleared after failed login attempt',
+  );
+  // Close modal via X button (data-zen-close)
+  await page.getByRole('button', { name: 'Close sign-in dialog' }).click();
+  await page.locator('[data-login-dialog]').waitFor({ state: 'hidden' });
+
+  // Archive opens only from the bottom destination menu.
+  assert.equal(
+    await page.locator('[data-toggle-archive]').count(),
+    0,
+    'redundant chat-heading archive shortcut is removed',
+  );
+  await page.locator('button[data-preview="conversations"]').click();
+  await page.locator('.jrpg-utility mjj-conversation-archive').waitFor({ state: 'visible' });
+  const archiveGeometry = await page.evaluate(() => {
+    const utility = document.querySelector('.jrpg-utility').getBoundingClientRect();
+    const archive = document.querySelector(
+      '.jrpg-utility mjj-conversation-archive',
+    ).getBoundingClientRect();
+    return {
+      archive: {
+        bottom: archive.bottom,
+        height: archive.height,
+        left: archive.left,
+        right: archive.right,
+        top: archive.top,
+        width: archive.width,
+      },
+      fits: archive.left >= utility.left &&
+      archive.top >= utility.top &&
+      archive.right <= utility.right + 1 &&
+      archive.bottom <= utility.bottom + 1 &&
+      archive.width / utility.width > 0.85 &&
+      archive.height / utility.height > 0.85,
+      utility: {
+        bottom: utility.bottom,
+        height: utility.height,
+        left: utility.left,
+        right: utility.right,
+        top: utility.top,
+        width: utility.width,
+      },
+    };
+  });
+  assert.equal(
+    archiveGeometry.fits,
+    true,
+    `conversation archive fills the top-right utility aperture: ${JSON.stringify(archiveGeometry)}`,
+  );
+  // Archive status or empty message should be visible
+  await page.waitForFunction(() => {
+    const status = document.querySelector('[data-archive-status]');
+    return status && !status.hidden;
+  });
+  // New conversation button exists inside archive
+  assert.ok(
+    await page.locator('[data-archive-new]').isVisible(),
+    'archive new button visible',
+  );
+  // Conversations menu button should be pressed
+  assert.equal(
+    await page.locator('button[data-preview="conversations"]').getAttribute('aria-pressed'),
+    'true',
+  );
+
+  // Archive: close via close button, navigates back to resume panel
+  await page.locator('[data-archive-close]').click();
+  await page.locator('.jrpg-utility mjj-conversation-archive').waitFor({ state: 'hidden' });
+  // mjj-jrpg-chat must remain visible (always in scene)
+  assert.equal(
+    await page.locator('mjj-jrpg-chat').isVisible(),
+    true,
+  );
+
+  // Quota element is present (may be shown for guest session)
+  assert.ok(
+    await page.locator('[data-quota]').count() > 0,
+    'quota element exists',
+  );
+
   await page.getByRole('button', { name: 'Minimize interface' }).click();
   assert.equal(
     await page.locator('.jrpg-scene').evaluate(
@@ -837,7 +996,7 @@
     ),
     'visible',
   );
-  assert.equal(await page.locator('[data-work-showcase] a').count(), 7);
+  assert.equal(await page.locator('[data-work-showcase] a').count(), 8);
   assert.equal(
     await page.getByRole('button', { name: /Inspect/ }).count(),
     0,
@@ -848,7 +1007,7 @@
   );
   assert.match(
     await page.locator('[data-work-showcase]').textContent(),
-    /Copilot SuperApp/,
+    /AIX Harness \/ Copilot/,
   );
   assert.equal(
     await page.locator('[data-turn-position]').textContent(),
@@ -1284,32 +1443,213 @@
   });
   const mobilePage = await mobileContext.newPage();
   await mobilePage.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+  await mobilePage.waitForFunction(() =>
+    customElements.get('mjj-jrpg-composer') &&
+    customElements.get('mjj-jrpg-menu')
+  );
   const mobile = await mobilePage.evaluate(() => {
+    const workspace = document.querySelector('.jrpg-workspace')
+      .getBoundingClientRect();
     const scene = document.querySelector('.jrpg-scene').getBoundingClientRect();
     const utility = document.querySelector('.jrpg-utility').getBoundingClientRect();
+    const composer = document.querySelector('mjj-jrpg-composer')
+      .getBoundingClientRect();
+    const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect();
     const shell = document.querySelector('.jrpg-shell').getBoundingClientRect();
+    const hamburger = document.querySelector('[data-mobile-menu-toggle]');
+    const hamburgerBox = hamburger?.getBoundingClientRect();
     const menuButtons = [...document.querySelectorAll(
       'mjj-jrpg-menu button[data-preview]',
     )];
+    const telemetryItems = [
+      ...document.querySelectorAll('.jrpg-frame-telemetry > *'),
+    ];
+    const closeTo = (value, expected, tol = 0.025) =>
+      Math.abs(value - expected) < tol;
+    const wsW = workspace.width;
+    const wsH = workspace.height;
+    const mobileArt = getComputedStyle(
+      document.querySelector('.jrpg-workspace'),
+    ).backgroundImage;
+    const mobileArtIsCorrect = mobileArt.includes('background-frame-mobile.webp');
+    const aperturesTolerance = [
+      closeTo((scene.left - workspace.left) / wsW, 0.045),
+      closeTo((scene.top - workspace.top) / wsH, 0.075),
+      closeTo(scene.width / wsW, 0.91),
+      closeTo(scene.height / wsH, 0.455),
+      closeTo((utility.left - workspace.left) / wsW, 0.045),
+      closeTo((utility.top - workspace.top) / wsH, 0.547),
+      closeTo(utility.width / wsW, 0.91),
+      closeTo(utility.height / wsH, 0.171),
+      closeTo((composer.left - workspace.left) / wsW, 0.045),
+      closeTo((composer.top - workspace.top) / wsH, 0.728),
+      closeTo(composer.width / wsW, 0.603),
+      closeTo(composer.height / wsH, 0.192),
+      closeTo((menu.left - workspace.left) / wsW, 0.665),
+      closeTo((menu.top - workspace.top) / wsH, 0.728),
+      closeTo(menu.width / wsW, 0.29),
+      closeTo(menu.height / wsH, 0.192),
+    ].every(Boolean);
+    const hamburgerPresent = hamburger !== null &&
+      hamburger.getAttribute('aria-expanded') === 'true' &&
+      hamburgerBox !== null &&
+      hamburgerBox.width > 0 &&
+      hamburgerBox.height > 0;
+    const telemetryCount = telemetryItems.length === 4;
+    const composerVisible = composer.width > 0 && composer.height > 0;
+    const menuVisible = menu.width > 0 && menu.height > 0;
+    const conversationsButton = document.querySelector(
+      'button[data-preview="conversations"]',
+    );
     return {
+      aperturesTolerance,
+      conversationsButtonVisible: conversationsButton !== null &&
+        conversationsButton.offsetParent !== null,
       buttonsFit: menuButtons.every(button =>
-        button.getBoundingClientRect().right <= innerWidth
+        button.getBoundingClientRect().right <= innerWidth + 1
       ),
+      composerVisible,
+      fillsViewport: shell.left === 0 &&
+        shell.top === 0 &&
+        Math.abs(shell.right - innerWidth) < 1 &&
+        Math.abs(shell.bottom - innerHeight) < 1,
       fillsWidth: shell.left === 0 &&
         Math.abs(shell.right - innerWidth) < 1,
+      hamburgerPresent,
       initialScroll: scrollY,
+      mobileArtIsCorrect,
+      menuVisible,
       noOverflow: document.documentElement.scrollWidth <= innerWidth,
-      utilityBelowScene: utility.top >= scene.bottom,
+      telemetryCount,
+      utilityBelowScene: utility.top >= scene.bottom - 1,
+      workspaceFills: Math.abs(workspace.left) < 1 &&
+        Math.abs(workspace.top) < 1 &&
+        Math.abs(workspace.right - innerWidth) < 1 &&
+        Math.abs(workspace.bottom - innerHeight) < 1,
     };
   });
-  assert.deepEqual(mobile, {
-    buttonsFit: true,
-    fillsWidth: true,
-    initialScroll: 0,
-    noOverflow: true,
-    utilityBelowScene: true,
+  assert.equal(mobile.aperturesTolerance, true, 'mobile apertures within tolerance at 360x640');
+  assert.equal(
+    mobile.conversationsButtonVisible,
+    true,
+    'Conversations destination visible on mobile',
+  );
+  assert.equal(mobile.buttonsFit, true, 'menu buttons fit within viewport width');
+  assert.equal(mobile.composerVisible, true, 'composer visible');
+  assert.equal(mobile.fillsViewport, true, 'shell fills viewport');
+  assert.equal(mobile.fillsWidth, true, 'shell fills width');
+  assert.equal(mobile.hamburgerPresent, true, 'hamburger button present and has aria-expanded=true');
+  assert.equal(mobile.initialScroll, 0, 'no initial scroll');
+  assert.equal(mobile.mobileArtIsCorrect, true, 'mobile background URL is background-frame-mobile.webp');
+  assert.equal(mobile.menuVisible, true, 'menu visible');
+  assert.equal(mobile.noOverflow, true, 'no horizontal overflow at 360x640');
+  assert.equal(mobile.telemetryCount, true, 'four telemetry items present');
+  assert.equal(mobile.utilityBelowScene, true, 'utility aperture below scene');
+  assert.equal(mobile.workspaceFills, true, 'workspace fills full viewport');
+
+  // Test hamburger disclosure behavior.
+  await mobilePage.locator('[data-mobile-menu-toggle]').click();
+  assert.equal(
+    await mobilePage.locator('[data-mobile-menu-toggle]')
+      .getAttribute('aria-expanded'),
+    'false',
+    'hamburger collapses the destination menu',
+  );
+  assert.equal(
+    await mobilePage.locator('mjj-jrpg-menu')
+      .getAttribute('data-mobile-menu-collapsed'),
+    '',
+    'menu exposes its collapsed state',
+  );
+  // Second click expands.
+  await mobilePage.locator('[data-mobile-menu-toggle]').click();
+  assert.equal(
+    await mobilePage.locator('[data-mobile-menu-toggle]')
+      .getAttribute('aria-expanded'),
+    'true',
+    'hamburger expands the destination menu',
+  );
+  // Hamburger keyboard accessible
+  assert.ok(
+    await mobilePage.locator('[data-mobile-menu-toggle]').evaluate(el =>
+      el.tabIndex >= 0 || el.tabIndex === -1
+    ),
+    'hamburger button is in document',
+  );
+  await mobileContext.close();
+
+  // Test at 390x844
+  const mobile390Context = await browser.newContext({
+    colorScheme: 'light',
+    viewport: { width: 390, height: 844 },
   });
-  await mobileContext.close();
+  const mobile390Page = await mobile390Context.newPage();
+  await mobile390Page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+  const mobile390 = await mobile390Page.evaluate(() => {
+    const workspace = document.querySelector('.jrpg-workspace')
+      .getBoundingClientRect();
+    const shell = document.querySelector('.jrpg-shell').getBoundingClientRect();
+    const mobileArt = getComputedStyle(
+      document.querySelector('.jrpg-workspace'),
+    ).backgroundImage;
+    return {
+      fillsViewport: shell.left === 0 &&
+        shell.top === 0 &&
+        Math.abs(shell.right - innerWidth) < 1 &&
+        Math.abs(shell.bottom - innerHeight) < 1,
+      mobileArtIsCorrect: mobileArt.includes('background-frame-mobile.webp'),
+      noOverflow: document.documentElement.scrollWidth <= innerWidth,
+      workspaceFills: Math.abs(workspace.left) < 1 &&
+        Math.abs(workspace.top) < 1 &&
+        Math.abs(workspace.right - innerWidth) < 1 &&
+        Math.abs(workspace.bottom - innerHeight) < 1,
+    };
+  });
+  assert.equal(mobile390.fillsViewport, true, 'fills viewport at 390x844');
+  assert.equal(mobile390.mobileArtIsCorrect, true, 'mobile art at 390x844');
+  assert.equal(mobile390.noOverflow, true, 'no overflow at 390x844');
+  assert.equal(mobile390.workspaceFills, true, 'workspace fills at 390x844');
+  await mobile390Context.close();
+
+  // Test at 320x568 (narrow)
+  const mobile320Context = await browser.newContext({
+    colorScheme: 'light',
+    viewport: { width: 320, height: 568 },
+  });
+  const mobile320Page = await mobile320Context.newPage();
+  await mobile320Page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+  const mobile320 = await mobile320Page.evaluate(() => {
+    const workspace = document.querySelector('.jrpg-workspace')
+      .getBoundingClientRect();
+    const shell = document.querySelector('.jrpg-shell').getBoundingClientRect();
+    const composer = document.querySelector('mjj-jrpg-composer')
+      .getBoundingClientRect();
+    const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect();
+    const mobileArt = getComputedStyle(
+      document.querySelector('.jrpg-workspace'),
+    ).backgroundImage;
+    return {
+      composerPresent: composer.width > 0 && composer.height > 0,
+      fillsViewport: shell.left === 0 &&
+        shell.top === 0 &&
+        Math.abs(shell.right - innerWidth) < 1 &&
+        Math.abs(shell.bottom - innerHeight) < 1,
+      menuPresent: menu.width > 0 && menu.height > 0,
+      mobileArtIsCorrect: mobileArt.includes('background-frame-mobile.webp'),
+      noOverflow: document.documentElement.scrollWidth <= innerWidth,
+      workspaceFills: Math.abs(workspace.left) < 1 &&
+        Math.abs(workspace.top) < 1 &&
+        Math.abs(workspace.right - innerWidth) < 1 &&
+        Math.abs(workspace.bottom - innerHeight) < 1,
+    };
+  });
+  assert.equal(mobile320.composerPresent, true, 'composer present at 320x568');
+  assert.equal(mobile320.fillsViewport, true, 'fills viewport at 320x568');
+  assert.equal(mobile320.menuPresent, true, 'menu present at 320x568');
+  assert.equal(mobile320.mobileArtIsCorrect, true, 'mobile art at 320x568');
+  assert.equal(mobile320.noOverflow, true, 'no overflow at 320x568');
+  assert.equal(mobile320.workspaceFills, true, 'workspace fills at 320x568');
+  await mobile320Context.close();
 
   const shortContext = await browser.newContext({
     colorScheme: 'dark',
@@ -1598,6 +1938,373 @@
   });
 }
 
+/* ------------------------------------------------------------------ */
+/* Conversation URL routing regression tests (Fixes 1–4)              */
+/* ------------------------------------------------------------------ */
+
+async function testConversationRouting(browser) {
+  const CONV_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
+  const CONV_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
+  const GUEST_SESSION = {
+    kind: 'guest', csrfToken: 'test-csrf',
+    quota: {
+      turnsLimit: 10, turnsUsed: 0, turnsRemaining: 10,
+      outputTokensLimit: 10000, outputTokensUsed: 0,
+      outputTokensReserved: 0, outputTokensRemaining: 10000,
+      resetsAt: Math.floor(Date.now() / 1000) + 86400,
+    },
+  };
+
+  /* ---- Fix 3: malformed ?conversation param must not fall back to sessionStorage ---- */
+  {
+    const storedId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
+    const context = await browser.newContext({ serviceWorkers: 'block' });
+    await context.addInitScript(id => {
+      sessionStorage.setItem('mjj-jrpg-conversation-id', id);
+    }, storedId);
+    const page = await context.newPage();
+    const storedIdFetched = [];
+    await page.route('**/api/auth/session', route => route.fulfill({
+      status: 200, contentType: 'application/json',
+      body: JSON.stringify(GUEST_SESSION),
+    }));
+    await page.route('**/api/conversations**', route => {
+      const url = new URL(route.request().url());
+      if (url.pathname.endsWith(`/${storedId}`)) {
+        storedIdFetched.push(route.request().url());
+      }
+      if (url.pathname === '/api/conversations') {
+        return route.fulfill({
+          status: 200, contentType: 'application/json',
+          body: JSON.stringify({ conversations: [], cursor: null }),
+        });
+      }
+      return route.fulfill({
+        status: 404, contentType: 'application/json',
+        body: JSON.stringify({ error: { message: 'Not found' } }),
+      });
+    });
+    await page.route('**/api/conversations/claim', route => route.fulfill({
+      status: 503,
+      contentType: 'application/json',
+      body: JSON.stringify({ error: { message: 'Service unavailable' } }),
+    }));
+    await page.goto(`${baseUrl}/jrpg?conversation=not-a-valid-uuid`, {
+      waitUntil: 'networkidle',
+    });
+    assert.equal(
+      storedIdFetched.length, 0,
+      'Fix 3: malformed ?conversation param must not trigger sessionStorage fallback',
+    );
+    await context.close();
+  }
+
+  /* ---- Fix 4: legacy claim: key retained on 5xx, button re-renders and re-fires ---- */
+  {
+    const legacyStoredId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
+    const context = await browser.newContext({ serviceWorkers: 'block' });
+    /* Seed CONVERSATION_STORAGE_KEY so init migrates it to CONVERSATION_LEGACY_KEY */
+    await context.addInitScript(id => {
+      sessionStorage.setItem('mjj-jrpg-conversation-id', id);
+    }, legacyStoredId);
+    const page = await context.newPage();
+    const pageErrors = [];
+    page.on('pageerror', e => {
+      if (!e.message.startsWith('Failed to load resource:')) {
+        pageErrors.push(`pageerror: ${e.message}`);
+      }
+    });
+    await page.route('**/api/auth/session', route => route.fulfill({
+      status: 200, contentType: 'application/json',
+      body: JSON.stringify({
+        kind: 'user', username: 'tester', role: 'member',
+        csrfToken: 'test-csrf', quota: null,
+      }),
+    }));
+    await page.route('**/api/conversations**', route => {
+      const url = new URL(route.request().url());
+      const req = route.request();
+      if (url.pathname === '/api/conversations' && req.method() === 'GET') {
+        return route.fulfill({
+          status: 200, contentType: 'application/json',
+          body: JSON.stringify({ conversations: [], cursor: null }),
+        });
+      }
+      if (url.pathname === `/api/conversations/${legacyStoredId}` && req.method() === 'GET') {
+        return route.fulfill({
+          status: 404, contentType: 'application/json',
+          body: JSON.stringify({ error: { message: 'Not found' } }),
+        });
+      }
+      if (url.pathname === '/api/conversations/claim' && req.method() === 'POST') {
+        return route.fulfill({
+          status: 503, contentType: 'application/json',
+          body: JSON.stringify({ error: { message: 'Service unavailable' } }),
+        });
+      }
+      return route.fulfill({
+        status: 404, contentType: 'application/json',
+        body: JSON.stringify({ error: { message: 'Not found' } }),
+      });
+    });
+    await page.route('**/api/conversations/claim', route => route.fulfill({
+      status: 503,
+      contentType: 'application/json',
+      body: JSON.stringify({ error: { message: 'Service unavailable' } }),
+    }));
+    await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+
+    /* Open archive panel so the legacy claim status is visible */
+    await page.locator('button[data-preview="conversations"]').click();
+    await page.locator('mjj-conversation-archive').waitFor({ state: 'visible' });
+
+    /* Init should have called showLegacyClaim() — wait for the button */
+    await page.waitForFunction(
+      () => document.querySelector('[data-archive-claim]') !== null,
+      null, { timeout: 5000 },
+    );
+
+    /* First claim attempt — returns 503 */
+    const firstClaimResponse = page.waitForResponse(
+      response => response.url().includes('/api/conversations/claim'),
+    );
+    await page.locator('[data-archive-claim]').click();
+    const firstClaimStatus = (await firstClaimResponse).status();
+    assert.equal(
+      firstClaimStatus,
+      503,
+      `claim fixture returns the mocked service failure, got ${firstClaimStatus}`,
+    );
+
+    /* Wait for the retry state to appear: showLegacyClaim re-rendered with error msg */
+    await page.waitForFunction(
+      () => {
+        const status = document.querySelector('[data-archive-status]');
+        return status && !status.hidden && status.textContent.includes('Claim failed');
+      },
+      null, { timeout: 5000 },
+    );
+
+    /* Key must be retained in sessionStorage on 5xx */
+    const keyAfterFail = await page.evaluate(
+      () => sessionStorage.getItem('mjj-jrpg-legacy-claim'),
+    );
+    assert.equal(
+      keyAfterFail, legacyStoredId,
+      'Fix 4: CONVERSATION_LEGACY_KEY must be retained after 5xx failure',
+    );
+
+    /* Button must be present and enabled for retry */
+    await page.locator('[data-archive-claim]').waitFor({ state: 'visible' });
+
+    /* Tag the current button so we can detect when it's replaced by a second re-render */
+    await page.locator('[data-archive-claim]').evaluate(btn => {
+      btn.dataset.claimGen = '1';
+    });
+
+    /* Second click: must dispatch event again — proves no {once:true} on button */
+    await page.locator('[data-archive-claim]').click();
+
+    /* Wait for the button to be replaced (showLegacyClaim called again = handler ran) */
+    await page.waitForFunction(
+      () => {
+        const btn = document.querySelector('[data-archive-claim]');
+        return btn !== null && btn.dataset.claimGen !== '1';
+      },
+      null, { timeout: 5000 },
+    );
+
+    assert.deepEqual(pageErrors, [], 'no page errors in legacy claim test');
+    await context.close();
+  }
+
+  /* ---- Fix 1: popstate during active stream converges URL and UI ---- */
+  {
+    let releaseStream = () => {};
+    const streamGate = new Promise(resolve => { releaseStream = resolve; });
+
+    const context = await browser.newContext();
+    const page = await context.newPage();
+    const pageErrors = [];
+    page.on('pageerror', e => {
+      if (!e.message.startsWith('Failed to load resource:')) {
+        pageErrors.push(`pageerror: ${e.message}`);
+      }
+    });
+    /* Suppress expected abort-network errors from the held-then-aborted stream */
+    page.on('requestfailed', request => {
+      if (request.failure()?.errorText === 'net::ERR_ABORTED') return;
+      pageErrors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`);
+    });
+
+    await page.route('**/api/auth/session', route => route.fulfill({
+      status: 200, contentType: 'application/json',
+      body: JSON.stringify(GUEST_SESSION),
+    }));
+    await page.route('**/api/conversations**', async route => {
+      const url = new URL(route.request().url());
+      const req = route.request();
+      if (url.pathname === '/api/conversations' && req.method() === 'GET') {
+        return route.fulfill({
+          status: 200, contentType: 'application/json',
+          body: JSON.stringify({ conversations: [], cursor: null }),
+        });
+      }
+      if (url.pathname === '/api/conversations' && req.method() === 'POST') {
+        return route.fulfill({
+          status: 201, contentType: 'application/json',
+          body: JSON.stringify({ id: CONV_A }),
+        });
+      }
+      if (url.pathname.endsWith('/turns') && req.method() === 'POST') {
+        /* Hold the SSE stream until the test releases it */
+        await streamGate;
+        try { await route.abort(); } catch { /* request may already be aborted */ }
+        return;
+      }
+      return route.fulfill({
+        status: 404, contentType: 'application/json',
+        body: JSON.stringify({ error: { message: 'Not found' } }),
+      });
+    });
+
+    await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+    await page.waitForFunction(
+      () => !document.querySelector('mjj-jrpg-composer textarea')?.disabled,
+    );
+
+    /* Submit — creates conversation via pushState and starts stream */
+    await page.locator('#jrpg-message').fill('routing race test');
+    await page.locator('#jrpg-message').press('Enter');
+
+    /* Stream is now active */
+    await page.locator('[data-cancel-control]').waitFor({ state: 'visible' });
+
+    const urlDuringStream = page.url();
+    assert.ok(
+      urlDuringStream.includes('conversation='),
+      `Fix 1: URL must have ?conversation during stream: ${urlDuringStream}`,
+    );
+
+    /* Navigate back while stream is active — URL already changes here */
+    await page.goBack();
+
+    /* Stream abort + pending-popstate application: cancel control disappears */
+    await page.locator('[data-cancel-control]').waitFor({ state: 'hidden' });
+
+    /* URL and UI must converge to the pre-stream state */
+    const finalUrl = page.url();
+    assert.ok(
+      !finalUrl.includes('conversation='),
+      `Fix 1: URL must not have ?conversation after popstate back: ${finalUrl}`,
+    );
+    assert.equal(
+      await page.locator('.jrpg-message[data-streaming="true"]').count(), 0,
+      'Fix 1: no streaming message must remain after popstate back',
+    );
+
+    releaseStream(); /* clean up the held route */
+    assert.deepEqual(pageErrors, [], 'no page errors in popstate-during-stream test');
+    await context.close();
+  }
+
+  /* ---- Fix 2: openSeq incremented on no-conv popstate, invalidates pending open ---- */
+  {
+    let resolveConvBFetch = () => {};
+    const convBFetchGate = new Promise(resolve => { resolveConvBFetch = resolve; });
+
+    const context = await browser.newContext();
+    const page = await context.newPage();
+    const pageErrors = [];
+    page.on('pageerror', e => {
+      if (!e.message.startsWith('Failed to load resource:')) {
+        pageErrors.push(`pageerror: ${e.message}`);
+      }
+    });
+
+    await page.route('**/api/auth/session', route => route.fulfill({
+      status: 200, contentType: 'application/json',
+      body: JSON.stringify(GUEST_SESSION),
+    }));
+    await page.route('**/api/conversations**', async route => {
+      const url = new URL(route.request().url());
+      const req = route.request();
+      /* Empty archive: no auto-open, no gate deadlock */
+      if (url.pathname === '/api/conversations' && req.method() === 'GET') {
+        return route.fulfill({
+          status: 200, contentType: 'application/json',
+          body: JSON.stringify({ conversations: [], cursor: null }),
+        });
+      }
+      /* The archive-open click will trigger this; hold it with the gate */
+      if (url.pathname === `/api/conversations/${CONV_B}` && req.method() === 'GET') {
+        await convBFetchGate;
+        return route.fulfill({
+          status: 200, contentType: 'application/json',
+          body: JSON.stringify({ id: CONV_B, title: 'Conv B', turns: [] }),
+        });
+      }
+      return route.fulfill({
+        status: 404, contentType: 'application/json',
+        body: JSON.stringify({ error: { message: 'Not found' } }),
+      });
+    });
+
+    /* Empty archive so init completes without auto-opening any conversation */
+    await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+
+    /* Open archive panel — this pushes a history entry (conversations panel) */
+    await page.locator('button[data-preview="conversations"]').click();
+    await page.locator('mjj-conversation-archive').waitFor({ state: 'visible' });
+
+    /* Inject Conv B into the archive list without triggering an API fetch */
+    await page.evaluate(id => {
+      document.querySelector('mjj-conversation-archive')
+        ?.addConversation({ id, title: 'Conv B', turn_count: 0 });
+    }, CONV_B);
+
+    await page.waitForFunction(
+      id => [...document.querySelectorAll('[data-conv-item]')]
+        .some(item => item._convId === id),
+      CONV_B,
+    );
+
+    /* Click Conv B — archive-open fires, fetch held by convBFetchGate */
+    await page.evaluate(id => {
+      for (const item of document.querySelectorAll('[data-conv-item]')) {
+        if (item._convId === id) {
+          item.querySelector('[data-conv-open]')?.click();
+          break;
+        }
+      }
+    }, CONV_B);
+
+    /* Brief pause so archive-open can increment openSeq and start the fetch */
+    await page.waitForTimeout(80);
+
+    /* Navigate back — popstate fires with no-conv state, openSeq is incremented */
+    await page.goBack();
+    await page.waitForTimeout(80);
+
+    /* Release the held fetch — the archive-open handler should ignore it */
+    resolveConvBFetch();
+    await page.waitForTimeout(300);
+
+    /* currentConversationId must remain null (no aria-current item) */
+    const currentAriaItem = await page.evaluate(() => {
+      const btn = document.querySelector('[data-conv-open][aria-current="true"]');
+      return btn ? (btn.closest('[data-conv-item]')?._convId ?? null) : null;
+    });
+    assert.equal(
+      currentAriaItem, null,
+      'Fix 2: pending archive-open must not overwrite no-conv popstate',
+    );
+
+    assert.deepEqual(pageErrors, [], 'no page errors in openSeq test');
+    await context.close();
+  }
+}
+
 (async () => {
   assert.ok(RUNFILES);
   assert.ok(WORKSPACE);
@@ -1682,7 +2389,7 @@
     for (const source of [home, dogGame, manifest]) {
       assert.doesNotMatch(source, /\.png(?:["')]|$)/i);
     }
-    assert.match(serviceWorker, /v30-card-driven-details/);
+    assert.match(serviceWorker, /v34-login-modal/);
     assert.match(
       await (
         await fetch(`${baseUrl}/public/pwa-register.js`)
@@ -1711,44 +2418,47 @@
       headless: true,
       args: ['--no-sandbox'],
     });
-    const paper = await sampleTheme(browser, 'paper');
-    const ink = await sampleTheme(browser, 'ink');
-    const playful = await sampleTheme(browser, 'playful');
-    const automatic = await sampleTheme(browser, 'auto');
-    assert.equal(paper.rootTheme, 'paper');
-    assert.equal(ink.rootTheme, 'ink');
-    assert.equal(playful.rootTheme, 'playful');
-    assert.equal(automatic.rootTheme, 'auto');
-    assert.equal(paper.themeLabel, 'Paper');
-    assert.equal(ink.themeLabel, 'Ink');
-    assert.equal(playful.themeLabel, 'Playful');
-    assert.equal(automatic.themeLabel, 'Auto');
-    for (const sample of [paper, ink, playful, automatic]) {
-      assert.ok(sample.count > 0);
-      assert.equal(sample.backgroundRepeat, 'no-repeat');
-      assert.equal(sample.backgroundSize, 'cover');
-      assert.equal(sample.bodyCoversViewport, true);
-      assert.equal(sample.componentReady, true);
-      assert.ok(sample.links > 0);
-      assert.equal(sample.enhancedLinks, sample.pawLinks);
-      assert.match(sample.fontFamily, /More/);
-      assert.equal(sample.headerPaws, 0);
-      assert.ok(sample.textContrast >= 4.5, JSON.stringify(sample));
-      assert.equal(sample.mainBackground, 'rgba(0, 0, 0, 0)');
-      assert.equal(sample.themeButtonBackground, 'rgba(0, 0, 0, 0)');
-      assert.equal(sample.themeButtonBorder, '0px');
-      assert.equal(sample.themeButtonShadow, 'none');
+    if (runsSuite('core')) {
+      const paper = await sampleTheme(browser, 'paper');
+      const ink = await sampleTheme(browser, 'ink');
+      const playful = await sampleTheme(browser, 'playful');
+      const automatic = await sampleTheme(browser, 'auto');
+      assert.equal(paper.rootTheme, 'paper');
+      assert.equal(ink.rootTheme, 'ink');
+      assert.equal(playful.rootTheme, 'playful');
+      assert.equal(automatic.rootTheme, 'auto');
+      assert.equal(paper.themeLabel, 'Paper');
+      assert.equal(ink.themeLabel, 'Ink');
+      assert.equal(playful.themeLabel, 'Playful');
+      assert.equal(automatic.themeLabel, 'Auto');
+      for (const sample of [paper, ink, playful, automatic]) {
+        assert.ok(sample.count > 0);
+        assert.equal(sample.backgroundRepeat, 'no-repeat');
+        assert.equal(sample.backgroundSize, 'cover');
+        assert.equal(sample.bodyCoversViewport, true);
+        assert.equal(sample.componentReady, true);
+        assert.ok(sample.links > 0);
+        assert.equal(sample.enhancedLinks, sample.pawLinks);
+        assert.match(sample.fontFamily, /More/);
+        assert.equal(sample.headerPaws, 0);
+        assert.ok(sample.textContrast >= 4.5, JSON.stringify(sample));
+        assert.equal(sample.mainBackground, 'rgba(0, 0, 0, 0)');
+        assert.equal(sample.themeButtonBackground, 'rgba(0, 0, 0, 0)');
+        assert.equal(sample.themeButtonBorder, '0px');
+        assert.equal(sample.themeButtonShadow, 'none');
+      }
+      assert.ok(paper.luminance < 175, JSON.stringify(paper));
+      assert.ok(playful.luminance < 175, JSON.stringify(playful));
+      assert.ok(ink.luminance > 200, JSON.stringify(ink));
+      await testThemeCycle(browser);
+      await testPlainField(browser);
+      await testButtonScale(browser);
+      await testDynamicButtonOwnership(browser);
+      await testResumePrint(browser);
     }
-    assert.ok(paper.luminance < 175, JSON.stringify(paper));
-    assert.ok(playful.luminance < 175, JSON.stringify(playful));
-    assert.ok(ink.luminance > 200, JSON.stringify(ink));
-    await testThemeCycle(browser);
-    await testPlainField(browser);
-    await testButtonScale(browser);
-    await testDynamicButtonOwnership(browser);
-    await testResumePrint(browser);
-    await testJrpgPage(browser);
-    await testHlsPlayer(browser, siteRoot);
+    if (runsSuite('jrpg')) await testJrpgPage(browser);
+    if (runsSuite('routing')) await testConversationRouting(browser);
+    if (runsSuite('hls')) await testHlsPlayer(browser, siteRoot);
   } finally {
     if (browser) await browser.close();
     await stopProcess(server);
--- a/seobeo/s_sse.c	Thu Aug 06 11:31:30 2026 -0700
+++ b/seobeo/s_sse.c	Fri Aug 07 07:34:12 2026 -0700
@@ -407,11 +407,30 @@
   return p_stream;
 }
 
+void Seobeo_SSE_Set_Detach_Callback(
+    Seobeo_SSE_Stream *p_stream,
+    void (*cb)(Seobeo_SSE_Stream *, void *),
+    void *ctx)
+{
+  if (!p_stream)
+    return;
+  pthread_mutex_lock(&g_sse_mutex);
+  p_stream->detach_cb  = cb;
+  p_stream->detach_ctx = ctx;
+  pthread_mutex_unlock(&g_sse_mutex);
+}
+
 void Seobeo_SSE_Server_Detach_Handle(Seobeo_Handle *p_handle)
 {
   if (!p_handle)
     return;
   p_handle->is_sse = FALSE;
+
+  /* Save callback info before releasing the stream reference. */
+  void (*detach_cb)(Seobeo_SSE_Stream *, void *) = NULL;
+  void              *detach_ctx                   = NULL;
+  Seobeo_SSE_Stream *cb_stream                    = NULL;
+
   pthread_mutex_lock(&g_sse_mutex);
   size_t count = Dowa_Array_Length(g_sse_streams);
   for (size_t i = 0; i < count; i++)
@@ -422,11 +441,24 @@
     p_stream->closed = TRUE;
     p_stream->p_handle = NULL;
     sse_clear_pending_unlocked(p_stream);
+    detach_cb  = p_stream->detach_cb;
+    detach_ctx = p_stream->detach_ctx;
+    /* Keep a live reference for the callback; release afterward. */
+    cb_stream = p_stream;
     g_sse_streams[i] = Dowa_Array_Pop(g_sse_streams);
-    sse_release_unlocked(p_stream);
+    /* Do NOT call sse_release_unlocked here; do it after mutex released
+     * so the callback can safely acquire its own locks. */
     break;
   }
   pthread_mutex_unlock(&g_sse_mutex);
+
+  /* Fire callback outside the mutex so callers may acquire other locks. */
+  if (detach_cb && cb_stream)
+    detach_cb(cb_stream, detach_ctx);
+
+  /* Drop the server's reference (the callback may hold its own reference). */
+  if (cb_stream)
+    Seobeo_SSE_Release(cb_stream);
 }
 
 void Seobeo_SSE_Server_Destroy(void)
--- a/seobeo/s_web.c	Thu Aug 06 11:31:30 2026 -0700
+++ b/seobeo/s_web.c	Fri Aug 07 07:34:12 2026 -0700
@@ -42,6 +42,9 @@
   if (strcasecmp(header, "content-type") == 0) return "Content-Type";
   if (strcasecmp(header, "connection") == 0) return "Connection";
   if (strcasecmp(header, "authorization") == 0) return "Authorization";
+  if (strcasecmp(header, "cookie") == 0) return "Cookie";
+  if (strcasecmp(header, "origin") == 0) return "Origin";
+  if (strcasecmp(header, "x-csrf-token") == 0) return "X-CSRF-Token";
   if (strcasecmp(header, "host") == 0) return "Host";
   if (strcasecmp(header, "upgrade") == 0) return "Upgrade";
   if (strcasecmp(header, "x-real-ip") == 0) return "X-Real-IP";
@@ -204,11 +207,6 @@
   void *p_conn_kv = Dowa_HashMap_Get_Ptr(p_req_map, "Connection");
   const char *conn_header = p_conn_kv ? ((Seobeo_Request_Entry*)p_conn_kv)->value : NULL;
 
-  void *p_real_ip_kv = Dowa_HashMap_Get_Ptr(p_req_map, "X-Real-IP");
-  const char *real_ip = p_real_ip_kv ? ((Seobeo_Request_Entry*)p_real_ip_kv)->value : NULL;
-  if (!real_ip)
-    real_ip = p_cli_handle->host;
-
   if (conn_header)
   {
     if (connection_header_contains(conn_header, "close"))
@@ -449,11 +447,10 @@
   // This seems kinda bad ?
   char method[16], path[256], version[16];
   int scan_result = sscanf(buf, "%15s %255s %15s", method, path, version);
-  Seobeo_Log(SEOBEO_DEBUG, "sscanf returned %d (method='%s', path='%s', version='%s')\n",
+  Seobeo_Log(SEOBEO_DEBUG, "sscanf returned %d (method='%s', path_length=%zu)\n",
          scan_result,
          scan_result >= 1 ? method : "N/A",
-         scan_result >= 2 ? path : "N/A",
-         scan_result >= 3 ? version : "N/A");
+         scan_result >= 2 ? strlen(path) : (size_t)0);
 
   if (scan_result != 3)
   {
@@ -474,6 +471,14 @@
   Seobeo_Log(SEOBEO_DEBUG, "Pushing HTTP_Method and Version to map\n");
   Dowa_HashMap_Push_Arena(*pp_map, "HTTP_Method", method_copy, p_arena);
   Dowa_HashMap_Push_Arena(*pp_map, "Version", version_copy, p_arena);
+  if (p_handle->host)
+  {
+    char *remote_addr = Dowa_Arena_Allocate(p_arena, strlen(p_handle->host) + 1);
+    if (!remote_addr)
+      return -1;
+    strcpy(remote_addr, p_handle->host);
+    Dowa_HashMap_Push_Arena(*pp_map, "Remote-Addr", remote_addr, p_arena);
+  }
   Seobeo_Log(SEOBEO_DEBUG, "Map now has %zu entries\n", Dowa_Array_Length(*pp_map));
 
   char *raw_path = path;  
@@ -563,6 +568,12 @@
       memcpy(key, line, key_len);
       key[key_len] = '\0';
 
+      if (strcasecmp(key, "remote-addr") == 0)
+      {
+        line = next + 2;
+        continue;
+      }
+
       char *val = Dowa_Arena_Allocate(p_arena, value_len + 1);
       if (!val) return -1;
       memcpy(val, val_start, value_len);
--- a/seobeo/seobeo.h	Thu Aug 06 11:31:30 2026 -0700
+++ b/seobeo/seobeo.h	Fri Aug 07 07:34:12 2026 -0700
@@ -383,6 +383,17 @@
 extern void           Seobeo_SSE_Release(Seobeo_SSE_Stream *p_stream);
 /* Stop accepting records. The router retains ownership of the handle. */
 extern void           Seobeo_SSE_Close(Seobeo_SSE_Stream *p_stream);
+/*
+ * Register a callback invoked when the client disconnects (handle detached).
+ * The callback is called AFTER g_sse_mutex is released and may safely acquire
+ * other locks.  The stream pointer remains valid for the callback's duration
+ * (the caller's retained reference keeps it alive).
+ * cb may be NULL to clear a previously registered callback.
+ */
+extern void           Seobeo_SSE_Set_Detach_Callback(
+    Seobeo_SSE_Stream *p_stream,
+    void (*cb)(Seobeo_SSE_Stream *, void *),
+    void *ctx);
 
 // --- Helper functions --- //
 /* Destroy handle. It will handle all NULL poointers. */
--- a/seobeo/seobeo_internal.h	Thu Aug 06 11:31:30 2026 -0700
+++ b/seobeo/seobeo_internal.h	Fri Aug 07 07:34:12 2026 -0700
@@ -78,7 +78,7 @@
 // HTTP request map type: maps header names to header values
 typedef Dowa_KV(char*, char*) Seobeo_Request_Entry;
 
-typedef struct {
+typedef struct Seobeo_SSE_Stream {
   Seobeo_Handle *p_handle;
   char *path;
   void *p_pending_frames;
@@ -87,6 +87,11 @@
   boolean started;
   boolean closed;
   boolean managed;
+  /* Optional client-disconnect callback.  Called from
+   * Seobeo_SSE_Server_Detach_Handle AFTER g_sse_mutex is released so that
+   * the callback may safely acquire its own locks. */
+  void (*detach_cb)(struct Seobeo_SSE_Stream *, void *);
+  void  *detach_ctx;
 } Seobeo_SSE_Stream;
 
 typedef struct {
--- a/seobeo/tests/BUILD	Thu Aug 06 11:31:30 2026 -0700
+++ b/seobeo/tests/BUILD	Fri Aug 07 07:34:12 2026 -0700
@@ -39,6 +39,15 @@
 )
 
 cc_test(
+  name = "seobeo_request_context_test",
+  srcs = ["seobeo_request_context_test.c"],
+  deps = ["//seobeo:seobeo"],
+  size = "small",
+  timeout = "short",
+  visibility = ["//visibility:public"],
+)
+
+cc_test(
   name = "seobeo_sigpipe_test",
   srcs = ["seobeo_sigpipe_test.c"],
   deps = ["//seobeo:seobeo"],
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/seobeo/tests/seobeo_request_context_test.c	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,73 @@
+#include "seobeo/seobeo.h"
+#include "seobeo/seobeo_internal.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+static const char *request_value(
+    Seobeo_Request_Entry *request,
+    const char *key)
+{
+  void *entry = Dowa_HashMap_Get_Ptr(request, key);
+  return entry ? ((Seobeo_Request_Entry *)entry)->value : NULL;
+}
+
+int main(void)
+{
+  int sockets[2];
+  if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0)
+    return 1;
+
+  const char raw_request[] =
+      "GET / HTTP/1.1\r\n"
+      "Host: example.test\r\n"
+      "cookie: mjj_session=session-token\r\n"
+      "origin: https://example.test\r\n"
+      "x-csrf-token: csrf-token\r\n"
+      "Remote-Addr: 198.51.100.99\r\n"
+      "X-Real-IP: 203.0.113.45\r\n"
+      "\r\n";
+  if (write(sockets[1], raw_request, sizeof(raw_request) - 1) < 0)
+    return 1;
+
+  Seobeo_Handle handle = {0};
+  handle.socket = sockets[0];
+  handle.host = strdup("2001:db8::1234");
+  handle.read_buffer_capacity = 4096;
+  handle.read_buffer = malloc(handle.read_buffer_capacity);
+
+  Dowa_Arena *arena = Dowa_Arena_Create(16 * 1024);
+  Seobeo_Request_Entry *request = NULL;
+  int failed = Seobeo_Web_Header_Parse(&handle, &request, arena) != 0;
+
+  const char *remote_addr = request_value(request, "Remote-Addr");
+  const char *forwarded_addr = request_value(request, "X-Real-IP");
+  const char *cookie = request_value(request, "Cookie");
+  const char *origin = request_value(request, "Origin");
+  const char *csrf = request_value(request, "X-CSRF-Token");
+  if (!remote_addr || strcmp(remote_addr, "2001:db8::1234") != 0)
+    failed = 1;
+  if (!forwarded_addr || strcmp(forwarded_addr, "203.0.113.45") != 0)
+    failed = 1;
+  if (!cookie || strcmp(cookie, "mjj_session=session-token") != 0)
+    failed = 1;
+  if (!origin || strcmp(origin, "https://example.test") != 0)
+    failed = 1;
+  if (!csrf || strcmp(csrf, "csrf-token") != 0)
+    failed = 1;
+  if (remote_addr == handle.host)
+    failed = 1;
+
+  if (failed)
+    fprintf(stderr, "Request context did not preserve peer identity or canonical headers\n");
+
+  close(sockets[0]);
+  close(sockets[1]);
+  free(handle.host);
+  free(handle.read_buffer);
+  Dowa_Arena_Free(arena);
+  return failed;
+}
--- a/seobeo/tests/seobeo_sse_test.c	Thu Aug 06 11:31:30 2026 -0700
+++ b/seobeo/tests/seobeo_sse_test.c	Fri Aug 07 07:34:12 2026 -0700
@@ -8,6 +8,17 @@
 #include <sys/socket.h>
 #include <unistd.h>
 
+/* Counter incremented by the detach callback. */
+static _Atomic int g_detach_count = 0;
+static Seobeo_SSE_Stream *g_detach_stream = NULL;
+
+static void test_detach_cb(Seobeo_SSE_Stream *p_stream, void *ctx)
+{
+  (void)ctx;
+  g_detach_stream = p_stream;
+  atomic_fetch_add(&g_detach_count, 1);
+}
+
 static void read_expected(int socket_fd, const char *expected)
 {
   size_t expected_length = strlen(expected);
@@ -186,6 +197,45 @@
   close(sockets[0]);
   close(sockets[1]);
 
+  /*
+   * Detach callback test: verify callback fires after handle detach and
+   * that the callback is called outside g_sse_mutex (no deadlock).
+   */
+  {
+    int cbs[2];
+    assert(socketpair(AF_UNIX, SOCK_STREAM, 0, cbs) == 0);
+    uint8 cb_write_buf[4096] = {0};
+    Seobeo_Handle cb_handle;
+    initialize_handle(&cb_handle, cbs[0], cb_write_buf, sizeof(cb_write_buf));
+    Seobeo_SSE_Stream *p_cb_stream = Seobeo_SSE_Server_Attach(&cb_handle, "/cb");
+    assert(p_cb_stream);
+    assert(Seobeo_SSE_Retain(p_cb_stream)); /* extra reference */
+
+    /* Register detach callback. */
+    atomic_store(&g_detach_count, 0);
+    g_detach_stream = NULL;
+    Seobeo_SSE_Set_Detach_Callback(p_cb_stream, test_detach_cb, NULL);
+
+    /* Detach: fires callback outside the mutex. */
+    Seobeo_SSE_Server_Detach_Handle(&cb_handle);
+
+    assert(atomic_load(&g_detach_count) == 1);
+    assert(g_detach_stream == p_cb_stream);
+    assert(!Seobeo_SSE_Is_Open(p_cb_stream));
+
+    /* Second detach: no stream found, callback NOT called again. */
+    Seobeo_SSE_Server_Detach_Handle(&cb_handle);
+    assert(atomic_load(&g_detach_count) == 1);
+
+    /* Clear the callback and verify it isn't called on a subsequent release. */
+    Seobeo_SSE_Set_Detach_Callback(p_cb_stream, NULL, NULL);
+    Seobeo_SSE_Release(p_cb_stream); /* release extra reference */
+    Seobeo_SSE_Server_Destroy();
+    close(cbs[0]);
+    close(cbs[1]);
+    printf("  detach callback fires once, outside mutex             PASS\n");
+  }
+
   Seobeo_Router_Init();
   Seobeo_Router_Register_SSE("/events/:topic", route_handler);
   Seobeo_Router_Register_SSE("/events/:topic/fixed", route_handler);
@@ -193,8 +243,7 @@
       "POST",
       "/events/:topic",
       post_route_handler);
-  Dowa_Arena *p_arena = Dowa_Arena_Create(4096);
-  Seobeo_Request_Entry *p_request = NULL;
+  Dowa_Arena *p_arena = Dowa_Arena_Create(4096);  Seobeo_Request_Entry *p_request = NULL;
   Seobeo_SSE_Handler handler = Seobeo_Router_Find_SSE_Handler(
       "GET",
       "/events/builds",