Mercurial
diff mrjunejune/conversation_api.c @ 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 | b401627fc49e |
| children | 056790c4fb0d |
line wrap: on
line diff
--- 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; }