comparison 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
comparison
equal deleted inserted replaced
263:ee04e4e69fed 264:04fee26ecce0
1 #include "mrjunejune/conversation_api.h" 1 #include "mrjunejune/conversation_api.h"
2 2
3 #include "mrjunejune/auth_api.h"
3 #include "mrjunejune/inference_bridge.h" 4 #include "mrjunejune/inference_bridge.h"
4 #include "mrjunejune/conversation_store.h" 5 #include "mrjunejune/conversation_store.h"
6 #include "auth/auth_store.h"
5 #include "seobeo/seobeo.h" 7 #include "seobeo/seobeo.h"
6 8
7 #include <pthread.h> 9 #include <pthread.h>
8 #include <stdio.h> 10 #include <stdio.h>
9 #include <stdarg.h> 11 #include <stdarg.h>
18 #define CONVERSATION_HISTORY_MAX (512 * 1024) 20 #define CONVERSATION_HISTORY_MAX (512 * 1024)
19 #define CONVERSATION_CUSTOM_EVENT_MAX (3 * 1024) 21 #define CONVERSATION_CUSTOM_EVENT_MAX (3 * 1024)
20 #define CONVERSATION_EVENT_NAME_MAX 64 22 #define CONVERSATION_EVENT_NAME_MAX 64
21 #define CONVERSATION_ACTIVE_MAX 4 23 #define CONVERSATION_ACTIVE_MAX 4
22 #define CONVERSATION_TURNS_PER_MINUTE 60 24 #define CONVERSATION_TURNS_PER_MINUTE 60
25 /* Buffer large enough for a guest Set-Cookie directive */
26 #define CONV_GUEST_COOKIE_CAPACITY (AUTH_CRYPTO_GUEST_COOKIE_SIZE + 256)
27
28 /* Quota policy — read from env at init, validated at startup. */
29 #define CONV_GUEST_DAILY_TURNS_DEFAULT 10
30 #define CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT 20000
31 #define CONV_GUEST_DAILY_TURNS_MIN 1
32 #define CONV_GUEST_DAILY_TURNS_MAX 10000
33 #define CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN 1
34 #define CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX 1000000
35 #define CONV_GUEST_REQUEST_OUTPUT_TOKENS_MIN 1
36 /* Default per-request reservation (tokens); capped to daily at init. */
37 #define CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT 2048
23 38
24 static Conversation_Store *g_conversation_store = NULL; 39 static Conversation_Store *g_conversation_store = NULL;
25 static Inference_Bridge *g_inference_bridge = NULL; 40 static Inference_Bridge *g_inference_bridge = NULL;
26 static boolean g_anonymous_inference_enabled = FALSE; 41 static boolean g_guest_inference_enabled = FALSE;
27 static pthread_mutex_t g_pending_mutex = PTHREAD_MUTEX_INITIALIZER; 42 static pthread_mutex_t g_pending_mutex = PTHREAD_MUTEX_INITIALIZER;
28 static pthread_mutex_t g_admission_mutex = PTHREAD_MUTEX_INITIALIZER; 43 static pthread_mutex_t g_admission_mutex = PTHREAD_MUTEX_INITIALIZER;
29 static time_t g_admission_window = 0; 44 static time_t g_admission_window = 0;
30 static uint32 g_admission_turns = 0; 45 static uint32 g_admission_turns = 0;
31 static uint32 g_active_turns = 0; 46 static uint32 g_active_turns = 0;
32 47
48 /* Quota policy (validated at init). */
49 static int64 g_guest_daily_turns = CONV_GUEST_DAILY_TURNS_DEFAULT;
50 static int64 g_guest_daily_output_tokens = CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT;
51 static int64 g_guest_request_output_tokens = CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT;
52
33 typedef struct Pending_Turn { 53 typedef struct Pending_Turn {
34 char request_id[37]; 54 char request_id[37];
35 char conversation_id[37]; 55 char conversation_id[37];
56 /* owner identity copied before request arena expires (req 8) */
57 Conversation_Owner owner;
36 Seobeo_SSE_Stream *p_stream; 58 Seobeo_SSE_Stream *p_stream;
37 char *content; 59 char *content;
38 size_t content_length; 60 size_t content_length;
39 size_t content_capacity; 61 size_t content_capacity;
40 int64 input_tokens; 62 int64 input_tokens;
41 int64 output_tokens; 63 int64 output_tokens;
42 boolean failed; 64 boolean failed;
43 boolean aborted; 65 boolean aborted;
66 boolean has_usage_event; /* TRUE once assistant.usage received */
67 boolean finalized; /* TRUE once Finalize_Pending has run (once-only) */
44 char error_message[512]; 68 char error_message[512];
69 /* Guest quota fields — copied before arena expires. */
70 boolean is_guest_reserved; /* TRUE if quota was reserved for this turn */
71 int64 reserved_output_tokens;
72 char guest_id[37]; /* guest_id from principal (safe copy) */
45 struct Pending_Turn *p_next; 73 struct Pending_Turn *p_next;
46 } Pending_Turn; 74 } Pending_Turn;
47 75
48 static Pending_Turn *g_pending_turns = NULL; 76 static Pending_Turn *g_pending_turns = NULL;
49 77
108 if (g_active_turns > 0) 136 if (g_active_turns > 0)
109 g_active_turns--; 137 g_active_turns--;
110 pthread_mutex_unlock(&g_admission_mutex); 138 pthread_mutex_unlock(&g_admission_mutex);
111 } 139 }
112 140
113 static Seobeo_Request_Entry *Conversation_API_JSON_Response( 141 static const char *Conversation_API_Request_Value(
142 Seobeo_Request_Entry *p_request,
143 const char *key)
144 {
145 void *p_value = Dowa_HashMap_Get_Ptr(p_request, (char *)key);
146 return p_value ? ((Seobeo_Request_Entry *)p_value)->value : NULL;
147 }
148
149 static boolean Conversation_API_Is_Inference_Ready(void)
150 {
151 return g_guest_inference_enabled;
152 }
153
154 /* UTC midnight for a Unix timestamp. */
155 static int64 conv__utc_window_start(int64 unix_ts)
156 {
157 return (unix_ts / 86400LL) * 86400LL;
158 }
159
160 /* Build guest quota JSON into a fixed buffer (null-terminated). */
161 static boolean conv_guest_quota_cb(
162 const char *guest_id,
163 int64 current_unix,
164 char *json_out,
165 size_t json_capacity)
166 {
167 Auth_Store *p_store = Auth_API_Get_Store();
168 if (!p_store || !guest_id || !json_out || json_capacity == 0)
169 {
170 if (json_out && json_capacity > 0)
171 strncpy(json_out, "null", json_capacity);
172 return TRUE;
173 }
174
175 int64 window_start = conv__utc_window_start(current_unix);
176 Auth_Store_Guest_Usage usage;
177 memset(&usage, 0, sizeof(usage));
178 Auth_Store_Guest_Get_Usage(p_store, guest_id, window_start, &usage);
179
180 int64 turns_remaining =
181 g_guest_daily_turns - usage.turns_used;
182 if (turns_remaining < 0) turns_remaining = 0;
183 int64 tokens_remaining =
184 g_guest_daily_output_tokens - usage.output_tokens_used - usage.output_tokens_reserved;
185 if (tokens_remaining < 0) tokens_remaining = 0;
186 int64 resets_at = window_start + 86400LL;
187
188 snprintf(
189 json_out, json_capacity,
190 "{\"turnsLimit\":%lld,\"turnsUsed\":%lld,\"turnsRemaining\":%lld,"
191 "\"outputTokensLimit\":%lld,\"outputTokensUsed\":%lld,"
192 "\"outputTokensReserved\":%lld,\"outputTokensRemaining\":%lld,"
193 "\"resetsAt\":%lld}",
194 (long long)g_guest_daily_turns,
195 (long long)usage.turns_used,
196 (long long)turns_remaining,
197 (long long)g_guest_daily_output_tokens,
198 (long long)usage.output_tokens_used,
199 (long long)usage.output_tokens_reserved,
200 (long long)tokens_remaining,
201 (long long)resets_at);
202 return TRUE;
203 }
204
205 static Dowa_JSON_Entry *Conversation_API_Parse_Body(
206 Seobeo_Request_Entry *p_request,
207 Dowa_Arena *p_arena)
208 {
209 const char *body = Conversation_API_Request_Value(p_request, "Body");
210 if (!body)
211 return NULL;
212 Dowa_JSON_Value value = Dowa_JSON_Parse(
213 body, (int32)strlen(body), p_arena);
214 return value.type == DOWA_JSON_OBJECT
215 ? (Dowa_JSON_Entry *)value.object_val
216 : NULL;
217 }
218
219 static const char *Conversation_API_Query_Param(
220 Seobeo_Request_Entry *p_request,
221 const char *param_name,
222 char *output,
223 size_t output_capacity)
224 {
225 const char *qs = Conversation_API_Request_Value(p_request, "QueryString");
226 if (!qs || !param_name || !output || output_capacity == 0)
227 return NULL;
228 output[0] = '\0';
229 size_t name_len = strlen(param_name);
230 const char *p = qs;
231 while (*p)
232 {
233 if (strncmp(p, param_name, name_len) == 0 && p[name_len] == '=')
234 {
235 p += name_len + 1;
236 size_t i = 0;
237 while (*p && *p != '&' && i < output_capacity - 1)
238 {
239 if (p[0] == '%' && p[1] && p[2])
240 {
241 char hex[3] = {p[1], p[2], '\0'};
242 output[i++] = (char)(int)strtol(hex, NULL, 16);
243 p += 3;
244 }
245 else if (*p == '+')
246 {
247 output[i++] = ' ';
248 p++;
249 }
250 else
251 {
252 output[i++] = *p++;
253 }
254 }
255 output[i] = '\0';
256 return output[0] != '\0' ? output : NULL;
257 }
258 while (*p && *p != '&') p++;
259 if (*p == '&') p++;
260 }
261 return NULL;
262 }
263
264 /*
265 * Build a JSON response, optionally setting a guest cookie when one was
266 * freshly issued by Auth_API_Resolve_Principal.
267 */
268 static Seobeo_Request_Entry *Conversation_API_JSON_Response_With_Cookie(
114 Dowa_Arena *p_arena, 269 Dowa_Arena *p_arena,
115 const char *status, 270 const char *status,
116 const char *body) 271 const char *body,
272 const char *new_guest_cookie)
117 { 273 {
118 Seobeo_Request_Entry *p_response = NULL; 274 Seobeo_Request_Entry *p_response = NULL;
119 Dowa_HashMap_Push_Arena(p_response, "status", (char *)status, p_arena); 275 Dowa_HashMap_Push_Arena(p_response, "status", (char *)status, p_arena);
120 Dowa_HashMap_Push_Arena( 276 Dowa_HashMap_Push_Arena(
121 p_response, "content-type", "application/json; charset=utf-8", p_arena); 277 p_response, "content-type", "application/json; charset=utf-8", p_arena);
122 Dowa_HashMap_Push_Arena(p_response, "cache-control", "no-store", p_arena); 278 Dowa_HashMap_Push_Arena(p_response, "cache-control", "no-store", p_arena);
123 Dowa_HashMap_Push_Arena(p_response, "body", (char *)body, p_arena); 279 Dowa_HashMap_Push_Arena(p_response, "body", (char *)body, p_arena);
280 if (new_guest_cookie && new_guest_cookie[0] != '\0')
281 Dowa_HashMap_Push_Arena(
282 p_response, "Set-Cookie", (char *)new_guest_cookie, p_arena);
124 return p_response; 283 return p_response;
284 }
285
286 static Seobeo_Request_Entry *Conversation_API_JSON_Response(
287 Dowa_Arena *p_arena,
288 const char *status,
289 const char *body)
290 {
291 return Conversation_API_JSON_Response_With_Cookie(p_arena, status, body, NULL);
125 } 292 }
126 293
127 static Seobeo_Request_Entry *Conversation_API_Error( 294 static Seobeo_Request_Entry *Conversation_API_Error(
128 Dowa_Arena *p_arena, 295 Dowa_Arena *p_arena,
129 const char *status, 296 const char *status,
140 code, 307 code,
141 escaped); 308 escaped);
142 return Conversation_API_JSON_Response(p_arena, status, body); 309 return Conversation_API_JSON_Response(p_arena, status, body);
143 } 310 }
144 311
145 static const char *Conversation_API_Request_Value( 312 static Seobeo_Request_Entry *Conversation_API_Error_With_Cookie(
313 Dowa_Arena *p_arena,
314 const char *status,
315 const char *code,
316 const char *message,
317 const char *new_guest_cookie)
318 {
319 char *escaped = Dowa_JSON_Escape_String(message, 0, p_arena);
320 size_t capacity = strlen(code) + strlen(escaped) + 64;
321 char *body = Dowa_Arena_Allocate(p_arena, capacity);
322 snprintf(
323 body,
324 capacity,
325 "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
326 code,
327 escaped);
328 return Conversation_API_JSON_Response_With_Cookie(p_arena, status, body, new_guest_cookie);
329 }
330
331 /*
332 * Resolve the principal for this request, optionally setting a new guest
333 * cookie on the response when returned.
334 * Returns FALSE only on internal error (treat as 500).
335 */
336 static boolean Conversation_API_Resolve(
146 Seobeo_Request_Entry *p_request, 337 Seobeo_Request_Entry *p_request,
147 const char *key) 338 Auth_Principal *p_principal,
148 { 339 char *new_guest_cookie,
149 void *p_value = Dowa_HashMap_Get_Ptr(p_request, (char *)key); 340 Dowa_Arena *p_arena)
150 return p_value ? ((Seobeo_Request_Entry *)p_value)->value : NULL; 341 {
151 } 342 return Auth_API_Resolve_Principal(
152 343 p_request, p_principal, p_arena,
153 static boolean Conversation_API_Is_Same_Origin( 344 new_guest_cookie, CONV_GUEST_COOKIE_CAPACITY);
154 Seobeo_Request_Entry *p_request) 345 }
155 { 346
156 const char *host = Conversation_API_Request_Value(p_request, "Host"); 347 /*
157 const char *origin = Conversation_API_Request_Value(p_request, "Origin"); 348 * Resolve an existing principal for mutation requests.
158 if (!host || !origin) 349 * Does NOT create a new guest identity.
159 return FALSE; 350 * Returns FALSE on internal error (500); sets *p_found=FALSE when no
160 const char *origin_host = strstr(origin, "://"); 351 * existing session/guest is present (caller must return 401).
161 if (!origin_host) 352 */
162 return FALSE; 353 static boolean Conversation_API_Resolve_Existing(
163 origin_host += 3;
164 const char *end = strchr(origin_host, '/');
165 size_t length = end ? (size_t)(end - origin_host) : strlen(origin_host);
166 return strlen(host) == length && strncmp(host, origin_host, length) == 0;
167 }
168
169 static boolean Conversation_API_Is_Enabled(void)
170 {
171 return g_anonymous_inference_enabled;
172 }
173
174 static Dowa_JSON_Entry *Conversation_API_Parse_Body(
175 Seobeo_Request_Entry *p_request, 354 Seobeo_Request_Entry *p_request,
176 Dowa_Arena *p_arena) 355 Auth_Principal *p_principal,
177 { 356 Dowa_Arena *p_arena,
178 const char *body = Conversation_API_Request_Value(p_request, "Body"); 357 boolean *p_found)
179 if (!body) 358 {
180 return NULL; 359 return Auth_API_Resolve_Existing_Principal(
181 Dowa_JSON_Value value = Dowa_JSON_Parse( 360 p_request, p_principal, p_arena, p_found);
182 body, (int32)strlen(body), p_arena); 361 }
183 return value.type == DOWA_JSON_OBJECT 362
184 ? (Dowa_JSON_Entry *)value.object_val 363 /*
185 : NULL; 364 * Map a resolved principal to a Conversation_Owner.
365 * Always succeeds for USER and GUEST principals.
366 */
367 static void Conversation_API_Owner_From_Principal(
368 const Auth_Principal *p_principal,
369 Conversation_Owner *p_owner)
370 {
371 if (p_principal->kind == AUTH_PRINCIPAL_USER)
372 {
373 p_owner->kind = CONVERSATION_OWNER_KIND_USER;
374 strncpy(p_owner->id, p_principal->user_id, sizeof(p_owner->id) - 1);
375 p_owner->id[sizeof(p_owner->id) - 1] = '\0';
376 }
377 else
378 {
379 p_owner->kind = CONVERSATION_OWNER_KIND_GUEST;
380 strncpy(p_owner->id, p_principal->guest_id, sizeof(p_owner->id) - 1);
381 p_owner->id[sizeof(p_owner->id) - 1] = '\0';
382 }
186 } 383 }
187 384
188 static void Conversation_API_Send_Stream_Error( 385 static void Conversation_API_Send_Stream_Error(
189 Seobeo_Handle *p_handle, 386 Seobeo_Handle *p_handle,
190 int status, 387 int status,
253 } 450 }
254 } 451 }
255 452
256 static void Conversation_API_Finalize_Pending(Pending_Turn *p_turn) 453 static void Conversation_API_Finalize_Pending(Pending_Turn *p_turn)
257 { 454 {
455 /* Idempotence guard — called with g_pending_mutex held. */
456 if (p_turn->finalized)
457 return;
458 p_turn->finalized = TRUE;
459
460 /* Reconcile or release guest quota reservation before persistence. */
461 if (p_turn->is_guest_reserved)
462 {
463 Auth_Store *p_auth_store = Auth_API_Get_Store();
464 if (p_auth_store)
465 {
466 if (!p_turn->failed && !p_turn->aborted)
467 {
468 int64 actual = p_turn->has_usage_event
469 ? p_turn->output_tokens
470 : p_turn->reserved_output_tokens;
471 Auth_Store_Guest_Reconcile(
472 p_auth_store, p_turn->request_id, actual);
473 }
474 else
475 Auth_Store_Guest_Release(p_auth_store, p_turn->request_id);
476 }
477 }
478
258 Conversation_Store_Result persistence; 479 Conversation_Store_Result persistence;
259 if (p_turn->failed || p_turn->aborted) 480 if (p_turn->failed || p_turn->aborted)
260 { 481 {
261 persistence = Conversation_Store_Fail_Turn( 482 persistence = Conversation_Store_Fail_Turn(
262 g_conversation_store, 483 g_conversation_store,
621 } 842 }
622 else if (strcmp(p_event->type, "assistant.usage") == 0) 843 else if (strcmp(p_event->type, "assistant.usage") == 0)
623 { 844 {
624 p_turn->input_tokens = p_event->input_tokens; 845 p_turn->input_tokens = p_event->input_tokens;
625 p_turn->output_tokens = p_event->output_tokens; 846 p_turn->output_tokens = p_event->output_tokens;
847 p_turn->has_usage_event = TRUE;
626 char usage[256]; 848 char usage[256];
627 snprintf( 849 snprintf(
628 usage, 850 usage,
629 sizeof(usage), 851 sizeof(usage),
630 "{\"request_id\":\"%s\",\"input_tokens\":%lld," 852 "{\"request_id\":\"%s\",\"input_tokens\":%lld,"
684 if (should_abort) 906 if (should_abort)
685 Inference_Bridge_Abort_Turn( 907 Inference_Bridge_Abort_Turn(
686 g_inference_bridge, abort_request_id, abort_conversation_id); 908 g_inference_bridge, abort_request_id, abort_conversation_id);
687 } 909 }
688 910
689 static Seobeo_Request_Entry *Conversation_API_Create( 911 /* ------------------------------------------------------------------ */
690 Seobeo_Request_Entry *p_request, 912 /* HTTP handler helpers */
691 Dowa_Arena *p_arena) 913 /* ------------------------------------------------------------------ */
692 {
693 if (!Conversation_API_Is_Enabled())
694 return Conversation_API_Error(
695 p_arena, "503", "inference_disabled", "Inference API is disabled");
696 if (!Conversation_API_Is_Same_Origin(p_request))
697 return Conversation_API_Error(
698 p_arena, "403", "origin_rejected", "Same-origin request required");
699 if (!g_conversation_store)
700 return Conversation_API_Error(
701 p_arena, "503", "store_unavailable", "Conversation store unavailable");
702
703 const char *title = "";
704 const char *body = Conversation_API_Request_Value(p_request, "Body");
705 if (body && body[0] != '\0')
706 {
707 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
708 if (!object)
709 return Conversation_API_Error(
710 p_arena, "400", "invalid_json", "Request body must be a JSON object");
711 char *parsed_title = Dowa_JSON_Get_String(object, "title");
712 if (parsed_title)
713 title = parsed_title;
714 }
715 if (strlen(title) > CONVERSATION_TITLE_MAX)
716 return Conversation_API_Error(
717 p_arena, "400", "invalid_title", "Title exceeds 200 bytes");
718
719 char conversation_id[37];
720 if (Conversation_Store_Create_Conversation(
721 g_conversation_store,
722 title,
723 conversation_id) != CONVERSATION_STORE_OK)
724 return Conversation_API_Error(
725 p_arena, "500", "create_failed", "Unable to create conversation");
726
727 char *response_body = Dowa_Arena_Allocate(p_arena, 64);
728 snprintf(response_body, 64, "{\"id\":\"%s\"}", conversation_id);
729 return Conversation_API_JSON_Response(p_arena, "201", response_body);
730 }
731 914
732 static boolean Conversation_API_Append( 915 static boolean Conversation_API_Append(
733 char *output, 916 char *output,
734 size_t capacity, 917 size_t capacity,
735 size_t *p_offset, 918 size_t *p_offset,
747 return FALSE; 930 return FALSE;
748 *p_offset += (size_t)written; 931 *p_offset += (size_t)written;
749 return TRUE; 932 return TRUE;
750 } 933 }
751 934
935 /* ------------------------------------------------------------------ */
936 /* GET /api/conversations?cursor=<ts>_<id>&limit=20 */
937 /* ------------------------------------------------------------------ */
938
939 static Seobeo_Request_Entry *Conversation_API_List(
940 Seobeo_Request_Entry *p_request,
941 Dowa_Arena *p_arena)
942 {
943 if (!g_conversation_store)
944 return Conversation_API_Error(
945 p_arena, "503", "store_unavailable", "Conversation store unavailable");
946
947 Auth_Principal principal;
948 char new_guest_cookie[CONV_GUEST_COOKIE_CAPACITY] = {0};
949 if (!Conversation_API_Resolve(p_request, &principal, new_guest_cookie, p_arena))
950 return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
951 if (principal.must_change_password)
952 return Conversation_API_Error_With_Cookie(
953 p_arena, "403", "password_change_required",
954 "Password change required", new_guest_cookie);
955
956 Conversation_Owner owner;
957 Conversation_API_Owner_From_Principal(&principal, &owner);
958
959 /* Parse cursor and limit from query string */
960 char cursor_buf[80] = {0};
961 char limit_buf[8] = {0};
962 Conversation_API_Query_Param(p_request, "cursor", cursor_buf, sizeof(cursor_buf));
963 Conversation_API_Query_Param(p_request, "limit", limit_buf, sizeof(limit_buf));
964
965 int64 cursor_updated_at = 0;
966 char cursor_id[37] = {0};
967 if (cursor_buf[0] != '\0')
968 {
969 /* cursor format: <updated_at>_<uuid> */
970 const char *underscore = strchr(cursor_buf, '_');
971 if (!underscore || underscore == cursor_buf ||
972 strlen(underscore + 1) != 36)
973 return Conversation_API_Error_With_Cookie(
974 p_arena, "400", "invalid_cursor", "Cursor format invalid",
975 new_guest_cookie);
976 char ts_part[32] = {0};
977 size_t ts_len = (size_t)(underscore - cursor_buf);
978 if (ts_len >= sizeof(ts_part))
979 return Conversation_API_Error_With_Cookie(
980 p_arena, "400", "invalid_cursor", "Cursor format invalid",
981 new_guest_cookie);
982 memcpy(ts_part, cursor_buf, ts_len);
983 cursor_updated_at = (int64)atoll(ts_part);
984 if (cursor_updated_at <= 0)
985 return Conversation_API_Error_With_Cookie(
986 p_arena, "400", "invalid_cursor", "Cursor timestamp invalid",
987 new_guest_cookie);
988 strncpy(cursor_id, underscore + 1, 36);
989 cursor_id[36] = '\0';
990 }
991
992 int32 limit = 20;
993 if (limit_buf[0] != '\0')
994 {
995 int parsed = atoi(limit_buf);
996 if (parsed >= 1 && parsed <= 50)
997 limit = parsed;
998 else if (parsed > 50)
999 limit = 50;
1000 }
1001
1002 Conversation_Summary *summaries = NULL;
1003 int32 count = 0;
1004 Conversation_Store_Result result = Conversation_Store_List(
1005 g_conversation_store, &owner,
1006 cursor_updated_at, cursor_id[0] ? cursor_id : NULL,
1007 limit, &summaries, &count, p_arena);
1008 if (result != CONVERSATION_STORE_OK)
1009 return Conversation_API_Error_With_Cookie(
1010 p_arena, "500", "list_failed", "Unable to list conversations",
1011 new_guest_cookie);
1012
1013 /* Estimate response size */
1014 size_t capacity = 128;
1015 for (int32 i = 0; i < count; i++)
1016 {
1017 Conversation_Summary *s = &summaries[i];
1018 capacity += (s->title ? strlen(s->title) : 0) * 6 +
1019 (s->last_message_preview ? strlen(s->last_message_preview) : 0) * 6 +
1020 256;
1021 }
1022 char *body = Dowa_Arena_Allocate(p_arena, capacity);
1023 if (!body)
1024 return Conversation_API_Error_With_Cookie(
1025 p_arena, "500", "serialize_failed", "Response exceeds memory limit",
1026 new_guest_cookie);
1027
1028 size_t offset = 0;
1029 if (!Conversation_API_Append(body, capacity, &offset,
1030 "{\"conversations\":["))
1031 return Conversation_API_Error_With_Cookie(
1032 p_arena, "500", "serialize_failed", "Response too large",
1033 new_guest_cookie);
1034
1035 for (int32 i = 0; i < count; i++)
1036 {
1037 Conversation_Summary *s = &summaries[i];
1038 char *esc_title = Dowa_JSON_Escape_String(
1039 s->title ? s->title : "", 0, p_arena);
1040 char *esc_preview = Dowa_JSON_Escape_String(
1041 s->last_message_preview ? s->last_message_preview : "", 0, p_arena);
1042 if (!Conversation_API_Append(
1043 body, capacity, &offset,
1044 "%s{\"id\":\"%s\",\"title\":\"%s\",\"status\":\"%s\","
1045 "\"created_at\":%lld,\"updated_at\":%lld,"
1046 "\"turn_count\":%lld,\"last_message_preview\":\"%s\"}",
1047 i == 0 ? "" : ",",
1048 s->id ? s->id : "",
1049 esc_title ? esc_title : "",
1050 s->status ? s->status : "",
1051 (long long)s->created_at,
1052 (long long)s->updated_at,
1053 (long long)s->turn_count,
1054 esc_preview ? esc_preview : ""))
1055 return Conversation_API_Error_With_Cookie(
1056 p_arena, "500", "serialize_failed", "Response too large",
1057 new_guest_cookie);
1058 }
1059
1060 /* Next cursor: last item's (updated_at, id) */
1061 char cursor_out[80] = "null";
1062 if (count == limit && count > 0)
1063 {
1064 Conversation_Summary *last = &summaries[count - 1];
1065 if (last->id)
1066 snprintf(cursor_out, sizeof(cursor_out), "\"%lld_%s\"",
1067 (long long)last->updated_at, last->id);
1068 }
1069 if (!Conversation_API_Append(body, capacity, &offset,
1070 "],\"cursor\":%s}", cursor_out))
1071 return Conversation_API_Error_With_Cookie(
1072 p_arena, "500", "serialize_failed", "Response too large",
1073 new_guest_cookie);
1074
1075 return Conversation_API_JSON_Response_With_Cookie(
1076 p_arena, "200", body, new_guest_cookie);
1077 }
1078
1079 /* ------------------------------------------------------------------ */
1080 /* POST /api/conversations */
1081 /* ------------------------------------------------------------------ */
1082
1083 static Seobeo_Request_Entry *Conversation_API_Create(
1084 Seobeo_Request_Entry *p_request,
1085 Dowa_Arena *p_arena)
1086 {
1087 if (!g_conversation_store)
1088 return Conversation_API_Error(
1089 p_arena, "503", "store_unavailable", "Conversation store unavailable");
1090
1091 Auth_Principal principal;
1092 boolean found = FALSE;
1093 if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
1094 return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
1095 if (!found)
1096 return Conversation_API_Error(
1097 p_arena, "401", "auth_required", "Bootstrap session first");
1098 if (principal.must_change_password)
1099 return Conversation_API_Error(
1100 p_arena, "403", "password_change_required",
1101 "Password change required");
1102 if (!Auth_API_Verify_CSRF(p_request, &principal))
1103 return Conversation_API_Error(
1104 p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");
1105
1106 Conversation_Owner owner;
1107 Conversation_API_Owner_From_Principal(&principal, &owner);
1108
1109 const char *title = "";
1110 const char *body = Conversation_API_Request_Value(p_request, "Body");
1111 if (body && body[0] != '\0')
1112 {
1113 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
1114 if (!object)
1115 return Conversation_API_Error(
1116 p_arena, "400", "invalid_json", "Request body must be a JSON object");
1117 char *parsed_title = Dowa_JSON_Get_String(object, "title");
1118 if (parsed_title)
1119 title = parsed_title;
1120 }
1121 if (strlen(title) > CONVERSATION_TITLE_MAX)
1122 return Conversation_API_Error(
1123 p_arena, "400", "invalid_title", "Title exceeds 200 bytes");
1124
1125 char conversation_id[37];
1126 if (Conversation_Store_Create_Owned(
1127 g_conversation_store, title, &owner,
1128 conversation_id) != CONVERSATION_STORE_OK)
1129 return Conversation_API_Error(
1130 p_arena, "500", "create_failed", "Unable to create conversation");
1131
1132 char *response_body = Dowa_Arena_Allocate(p_arena, 64);
1133 snprintf(response_body, 64, "{\"id\":\"%s\"}", conversation_id);
1134 return Conversation_API_JSON_Response(p_arena, "201", response_body);
1135 }
1136
1137 /* ------------------------------------------------------------------ */
1138 /* GET /api/conversations/:conversation_id */
1139 /* ------------------------------------------------------------------ */
1140
752 static Seobeo_Request_Entry *Conversation_API_Get( 1141 static Seobeo_Request_Entry *Conversation_API_Get(
753 Seobeo_Request_Entry *p_request, 1142 Seobeo_Request_Entry *p_request,
754 Dowa_Arena *p_arena) 1143 Dowa_Arena *p_arena)
755 { 1144 {
756 if (!Conversation_API_Is_Enabled()) 1145 if (!g_conversation_store)
757 return Conversation_API_Error( 1146 return Conversation_API_Error(
758 p_arena, "503", "inference_disabled", "Inference API is disabled"); 1147 p_arena, "503", "store_unavailable", "Conversation store unavailable");
1148
1149 Auth_Principal principal;
1150 char new_guest_cookie[CONV_GUEST_COOKIE_CAPACITY] = {0};
1151 if (!Conversation_API_Resolve(p_request, &principal, new_guest_cookie, p_arena))
1152 return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
1153 if (principal.must_change_password)
1154 return Conversation_API_Error_With_Cookie(
1155 p_arena, "403", "password_change_required",
1156 "Password change required", new_guest_cookie);
1157
1158 Conversation_Owner owner;
1159 Conversation_API_Owner_From_Principal(&principal, &owner);
1160
759 const char *conversation_id = 1161 const char *conversation_id =
760 Conversation_API_Request_Value(p_request, ":conversation_id"); 1162 Conversation_API_Request_Value(p_request, ":conversation_id");
761 if (!conversation_id) 1163 if (!conversation_id)
762 return Conversation_API_Error( 1164 return Conversation_API_Error_With_Cookie(
763 p_arena, "400", "missing_id", "Conversation ID is required"); 1165 p_arena, "400", "missing_id", "Conversation ID is required",
1166 new_guest_cookie);
764 1167
765 Conversation_Record record; 1168 Conversation_Record record;
766 Conversation_Store_Result result = Conversation_Store_Get( 1169 Conversation_Store_Result result = Conversation_Store_Get_Owned(
767 g_conversation_store, conversation_id, &record, p_arena); 1170 g_conversation_store, conversation_id, &owner, &record, p_arena);
768 if (result == CONVERSATION_STORE_NOT_FOUND) 1171 if (result == CONVERSATION_STORE_NOT_FOUND)
769 return Conversation_API_Error( 1172 return Conversation_API_Error_With_Cookie(
770 p_arena, "404", "not_found", "Conversation not found"); 1173 p_arena, "404", "not_found", "Conversation not found",
1174 new_guest_cookie);
771 if (result != CONVERSATION_STORE_OK) 1175 if (result != CONVERSATION_STORE_OK)
772 return Conversation_API_Error( 1176 return Conversation_API_Error_With_Cookie(
773 p_arena, "500", "load_failed", "Unable to load conversation"); 1177 p_arena, "500", "load_failed", "Unable to load conversation",
1178 new_guest_cookie);
774 1179
775 if (!record.id || !record.title || !record.status) 1180 if (!record.id || !record.title || !record.status)
776 return Conversation_API_Error( 1181 return Conversation_API_Error_With_Cookie(
777 p_arena, "500", "load_failed", "Conversation data exceeded limits"); 1182 p_arena, "500", "load_failed", "Conversation data exceeded limits",
1183 new_guest_cookie);
778 size_t history_size = strlen(record.title) + strlen(record.status); 1184 size_t history_size = strlen(record.title) + strlen(record.status);
779 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++) 1185 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
780 { 1186 {
781 Conversation_Turn *p_turn = &record.turns[i]; 1187 Conversation_Turn *p_turn = &record.turns[i];
782 if (!p_turn->role || !p_turn->content || !p_turn->status || 1188 if (!p_turn->role || !p_turn->content || !p_turn->status ||
783 !p_turn->request_id || !p_turn->error_message) 1189 !p_turn->request_id || !p_turn->error_message)
784 return Conversation_API_Error( 1190 return Conversation_API_Error_With_Cookie(
785 p_arena, "500", "load_failed", "Conversation data exceeded limits"); 1191 p_arena, "500", "load_failed", "Conversation data exceeded limits",
1192 new_guest_cookie);
786 history_size += 1193 history_size +=
787 strlen(p_turn->role) + strlen(p_turn->content) + 1194 strlen(p_turn->role) + strlen(p_turn->content) +
788 strlen(p_turn->status) + strlen(p_turn->request_id) + 1195 strlen(p_turn->status) + strlen(p_turn->request_id) +
789 strlen(p_turn->error_message); 1196 strlen(p_turn->error_message);
790 if (history_size > CONVERSATION_HISTORY_MAX) 1197 if (history_size > CONVERSATION_HISTORY_MAX)
791 return Conversation_API_Error( 1198 return Conversation_API_Error_With_Cookie(
792 p_arena, 1199 p_arena,
793 "413", 1200 "413",
794 "history_too_large", 1201 "history_too_large",
795 "Conversation history exceeds response limit"); 1202 "Conversation history exceeds response limit",
1203 new_guest_cookie);
796 } 1204 }
797 char *escaped_title = Dowa_JSON_Escape_String(record.title, 0, p_arena); 1205 char *escaped_title = Dowa_JSON_Escape_String(record.title, 0, p_arena);
798 if (!escaped_title) 1206 if (!escaped_title)
799 return Conversation_API_Error( 1207 return Conversation_API_Error_With_Cookie(
800 p_arena, "500", "serialize_failed", "Unable to serialize conversation"); 1208 p_arena, "500", "serialize_failed", "Unable to serialize conversation",
1209 new_guest_cookie);
801 size_t capacity = strlen(escaped_title) + 512; 1210 size_t capacity = strlen(escaped_title) + 512;
802 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++) 1211 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
803 { 1212 {
804 Conversation_Turn *p_turn = &record.turns[i]; 1213 Conversation_Turn *p_turn = &record.turns[i];
805 capacity += 1214 capacity +=
807 strlen(p_turn->status) + strlen(p_turn->request_id) + 1216 strlen(p_turn->status) + strlen(p_turn->request_id) +
808 strlen(p_turn->error_message)) * 6 + 320; 1217 strlen(p_turn->error_message)) * 6 + 320;
809 } 1218 }
810 char *body = Dowa_Arena_Allocate(p_arena, capacity); 1219 char *body = Dowa_Arena_Allocate(p_arena, capacity);
811 if (!body) 1220 if (!body)
812 return Conversation_API_Error( 1221 return Conversation_API_Error_With_Cookie(
813 p_arena, "500", "serialize_failed", "Response exceeds memory limit"); 1222 p_arena, "500", "serialize_failed", "Response exceeds memory limit",
1223 new_guest_cookie);
814 size_t offset = 0; 1224 size_t offset = 0;
815 if (!Conversation_API_Append( 1225 if (!Conversation_API_Append(
816 body, 1226 body,
817 capacity, 1227 capacity,
818 &offset, 1228 &offset,
821 record.id, 1231 record.id,
822 escaped_title, 1232 escaped_title,
823 record.status, 1233 record.status,
824 (long long)record.created_at, 1234 (long long)record.created_at,
825 (long long)record.updated_at)) 1235 (long long)record.updated_at))
826 return Conversation_API_Error( 1236 return Conversation_API_Error_With_Cookie(
827 p_arena, "500", "serialize_failed", "Response too large"); 1237 p_arena, "500", "serialize_failed", "Response too large",
1238 new_guest_cookie);
828 1239
829 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++) 1240 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
830 { 1241 {
831 Conversation_Turn *p_turn = &record.turns[i]; 1242 Conversation_Turn *p_turn = &record.turns[i];
832 char *role = Dowa_JSON_Escape_String(p_turn->role, 0, p_arena); 1243 char *role = Dowa_JSON_Escape_String(p_turn->role, 0, p_arena);
855 error, 1266 error,
856 (long long)p_turn->input_tokens, 1267 (long long)p_turn->input_tokens,
857 (long long)p_turn->output_tokens, 1268 (long long)p_turn->output_tokens,
858 (long long)p_turn->created_at, 1269 (long long)p_turn->created_at,
859 (long long)p_turn->completed_at)) 1270 (long long)p_turn->completed_at))
860 return Conversation_API_Error( 1271 return Conversation_API_Error_With_Cookie(
861 p_arena, "500", "serialize_failed", "Response too large"); 1272 p_arena, "500", "serialize_failed", "Response too large",
1273 new_guest_cookie);
862 } 1274 }
863 if (!Conversation_API_Append(body, capacity, &offset, "]}")) 1275 if (!Conversation_API_Append(body, capacity, &offset, "]}"))
864 return Conversation_API_Error( 1276 return Conversation_API_Error_With_Cookie(
865 p_arena, "500", "serialize_failed", "Response too large"); 1277 p_arena, "500", "serialize_failed", "Response too large",
866 return Conversation_API_JSON_Response(p_arena, "200", body); 1278 new_guest_cookie);
867 } 1279 return Conversation_API_JSON_Response_With_Cookie(
1280 p_arena, "200", body, new_guest_cookie);
1281 }
1282
1283 /* ------------------------------------------------------------------ */
1284 /* PATCH /api/conversations/:conversation_id */
1285 /* ------------------------------------------------------------------ */
868 1286
869 static Seobeo_Request_Entry *Conversation_API_Update( 1287 static Seobeo_Request_Entry *Conversation_API_Update(
870 Seobeo_Request_Entry *p_request, 1288 Seobeo_Request_Entry *p_request,
871 Dowa_Arena *p_arena) 1289 Dowa_Arena *p_arena)
872 { 1290 {
873 if (!Conversation_API_Is_Enabled()) 1291 if (!g_conversation_store)
874 return Conversation_API_Error( 1292 return Conversation_API_Error(
875 p_arena, "503", "inference_disabled", "Inference API is disabled"); 1293 p_arena, "503", "store_unavailable", "Conversation store unavailable");
876 if (!Conversation_API_Is_Same_Origin(p_request)) 1294
877 return Conversation_API_Error( 1295 Auth_Principal principal;
878 p_arena, "403", "origin_rejected", "Same-origin request required"); 1296 boolean found = FALSE;
1297 if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
1298 return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
1299 if (!found)
1300 return Conversation_API_Error(
1301 p_arena, "401", "auth_required", "Bootstrap session first");
1302 if (principal.must_change_password)
1303 return Conversation_API_Error(
1304 p_arena, "403", "password_change_required",
1305 "Password change required");
1306 if (!Auth_API_Verify_CSRF(p_request, &principal))
1307 return Conversation_API_Error(
1308 p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");
1309
1310 Conversation_Owner owner;
1311 Conversation_API_Owner_From_Principal(&principal, &owner);
1312
879 const char *conversation_id = 1313 const char *conversation_id =
880 Conversation_API_Request_Value(p_request, ":conversation_id"); 1314 Conversation_API_Request_Value(p_request, ":conversation_id");
881 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena); 1315 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
882 char *title = object ? Dowa_JSON_Get_String(object, "title") : NULL; 1316 char *title = object ? Dowa_JSON_Get_String(object, "title") : NULL;
883 if (!conversation_id || !title) 1317 if (!conversation_id || !title)
885 p_arena, "400", "invalid_request", "Conversation ID and title required"); 1319 p_arena, "400", "invalid_request", "Conversation ID and title required");
886 if (strlen(title) > CONVERSATION_TITLE_MAX) 1320 if (strlen(title) > CONVERSATION_TITLE_MAX)
887 return Conversation_API_Error( 1321 return Conversation_API_Error(
888 p_arena, "400", "invalid_title", "Title exceeds 200 bytes"); 1322 p_arena, "400", "invalid_title", "Title exceeds 200 bytes");
889 1323
890 Conversation_Store_Result result = Conversation_Store_Update_Title( 1324 Conversation_Store_Result result = Conversation_Store_Update_Title_Owned(
891 g_conversation_store, conversation_id, title); 1325 g_conversation_store, conversation_id, &owner, title);
892 if (result == CONVERSATION_STORE_NOT_FOUND) 1326 if (result == CONVERSATION_STORE_NOT_FOUND)
893 return Conversation_API_Error( 1327 return Conversation_API_Error(
894 p_arena, "404", "not_found", "Conversation not found"); 1328 p_arena, "404", "not_found", "Conversation not found");
895 if (result != CONVERSATION_STORE_OK) 1329 if (result != CONVERSATION_STORE_OK)
896 return Conversation_API_Error( 1330 return Conversation_API_Error(
897 p_arena, "500", "update_failed", "Unable to update conversation"); 1331 p_arena, "500", "update_failed", "Unable to update conversation");
898 return Conversation_API_JSON_Response(p_arena, "200", "{\"ok\":true}"); 1332 return Conversation_API_JSON_Response(p_arena, "200", "{\"ok\":true}");
899 } 1333 }
1334
1335 /* ------------------------------------------------------------------ */
1336 /* DELETE /api/conversations/:conversation_id */
1337 /* ------------------------------------------------------------------ */
900 1338
901 static Seobeo_Request_Entry *Conversation_API_Delete( 1339 static Seobeo_Request_Entry *Conversation_API_Delete(
902 Seobeo_Request_Entry *p_request, 1340 Seobeo_Request_Entry *p_request,
903 Dowa_Arena *p_arena) 1341 Dowa_Arena *p_arena)
904 { 1342 {
905 if (!Conversation_API_Is_Enabled()) 1343 if (!g_conversation_store)
906 return Conversation_API_Error( 1344 return Conversation_API_Error(
907 p_arena, "503", "inference_disabled", "Inference API is disabled"); 1345 p_arena, "503", "store_unavailable", "Conversation store unavailable");
908 if (!Conversation_API_Is_Same_Origin(p_request)) 1346
909 return Conversation_API_Error( 1347 Auth_Principal principal;
910 p_arena, "403", "origin_rejected", "Same-origin request required"); 1348 boolean found = FALSE;
1349 if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
1350 return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
1351 if (!found)
1352 return Conversation_API_Error(
1353 p_arena, "401", "auth_required", "Bootstrap session first");
1354 if (principal.must_change_password)
1355 return Conversation_API_Error(
1356 p_arena, "403", "password_change_required",
1357 "Password change required");
1358 if (!Auth_API_Verify_CSRF(p_request, &principal))
1359 return Conversation_API_Error(
1360 p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");
1361
1362 Conversation_Owner owner;
1363 Conversation_API_Owner_From_Principal(&principal, &owner);
1364
911 const char *conversation_id = 1365 const char *conversation_id =
912 Conversation_API_Request_Value(p_request, ":conversation_id"); 1366 Conversation_API_Request_Value(p_request, ":conversation_id");
913 if (!conversation_id) 1367 if (!conversation_id)
914 return Conversation_API_Error( 1368 return Conversation_API_Error(
915 p_arena, "400", "missing_id", "Conversation ID is required"); 1369 p_arena, "400", "missing_id", "Conversation ID is required");
916 Conversation_Store_Result result = Conversation_Store_Delete( 1370
917 g_conversation_store, conversation_id); 1371 Conversation_Store_Result result = Conversation_Store_Delete_Owned(
1372 g_conversation_store, conversation_id, &owner);
918 if (result == CONVERSATION_STORE_NOT_FOUND) 1373 if (result == CONVERSATION_STORE_NOT_FOUND)
919 return Conversation_API_Error( 1374 return Conversation_API_Error(
920 p_arena, "404", "not_found", "Conversation not found"); 1375 p_arena, "404", "not_found", "Conversation not found");
921 if (result != CONVERSATION_STORE_OK) 1376 if (result != CONVERSATION_STORE_OK)
922 return Conversation_API_Error( 1377 return Conversation_API_Error(
923 p_arena, "500", "delete_failed", "Unable to delete conversation"); 1378 p_arena, "500", "delete_failed", "Unable to delete conversation");
1379
924 if (Inference_Bridge_Is_Ready(g_inference_bridge)) 1380 if (Inference_Bridge_Is_Ready(g_inference_bridge))
925 { 1381 {
926 char request_id[37]; 1382 char request_id[37];
927 if (Conversation_Store_Generate_UUID(request_id)) 1383 if (Conversation_Store_Generate_UUID(request_id))
928 Inference_Bridge_Delete_Conversation( 1384 Inference_Bridge_Delete_Conversation(
933 Dowa_HashMap_Push_Arena(p_response, "status", "204", p_arena); 1389 Dowa_HashMap_Push_Arena(p_response, "status", "204", p_arena);
934 Dowa_HashMap_Push_Arena(p_response, "body", "", p_arena); 1390 Dowa_HashMap_Push_Arena(p_response, "body", "", p_arena);
935 return p_response; 1391 return p_response;
936 } 1392 }
937 1393
1394 /* ------------------------------------------------------------------ */
1395 /* POST /api/conversations/claim (body: {"conversationId":"<uuid>"}) */
1396 /* ID stays in request body — never appears in URL or request log. */
1397 /* ------------------------------------------------------------------ */
1398
1399 static Seobeo_Request_Entry *Conversation_API_Claim_Body(
1400 Seobeo_Request_Entry *p_request,
1401 Dowa_Arena *p_arena)
1402 {
1403 if (!g_conversation_store)
1404 return Conversation_API_Error(
1405 p_arena, "503", "store_unavailable", "Conversation store unavailable");
1406
1407 Auth_Principal principal;
1408 boolean found = FALSE;
1409 if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
1410 return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
1411 if (!found)
1412 return Conversation_API_Error(
1413 p_arena, "401", "auth_required", "Bootstrap session first");
1414 /* Guests may not claim; forced-password-change users blocked */
1415 if (principal.kind != AUTH_PRINCIPAL_USER)
1416 return Conversation_API_Error(
1417 p_arena, "403", "forbidden", "Must be authenticated to claim");
1418 if (principal.must_change_password)
1419 return Conversation_API_Error(
1420 p_arena, "403", "password_change_required",
1421 "Password change required");
1422 if (!Auth_API_Verify_CSRF(p_request, &principal))
1423 return Conversation_API_Error(
1424 p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");
1425
1426 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
1427 if (!object)
1428 return Conversation_API_Error(
1429 p_arena, "400", "invalid_json", "Request body must be a JSON object");
1430 const char *conversation_id = Dowa_JSON_Get_String(object, "conversationId");
1431 if (!conversation_id || conversation_id[0] == '\0')
1432 return Conversation_API_Error(
1433 p_arena, "400", "missing_id", "conversationId is required");
1434 if (strlen(conversation_id) > 36)
1435 return Conversation_API_Error(
1436 p_arena, "400", "invalid_id", "conversationId is invalid");
1437
1438 Conversation_Store_Result result = Conversation_Store_Claim_Legacy(
1439 g_conversation_store, conversation_id, principal.user_id);
1440 if (result == CONVERSATION_STORE_NOT_FOUND)
1441 return Conversation_API_Error(
1442 p_arena, "404", "not_found", "Conversation not found");
1443 if (result == CONVERSATION_STORE_CONFLICT)
1444 return Conversation_API_Error(
1445 p_arena, "409", "already_owned", "Conversation is already owned");
1446 if (result != CONVERSATION_STORE_OK)
1447 return Conversation_API_Error(
1448 p_arena, "500", "claim_failed", "Unable to claim conversation");
1449
1450 return Conversation_API_JSON_Response(p_arena, "200", "{\"ok\":true}");
1451 }
1452
1453 /* ------------------------------------------------------------------ */
1454 /* GET /api/inference/health */
1455 /* ------------------------------------------------------------------ */
1456
938 static Seobeo_Request_Entry *Conversation_API_Health( 1457 static Seobeo_Request_Entry *Conversation_API_Health(
939 Seobeo_Request_Entry *p_request, 1458 Seobeo_Request_Entry *p_request,
940 Dowa_Arena *p_arena) 1459 Dowa_Arena *p_arena)
941 { 1460 {
942 (void)p_request; 1461 (void)p_request;
943 boolean ready = 1462 boolean ready =
944 Conversation_API_Is_Enabled() && 1463 Conversation_API_Is_Inference_Ready() &&
945 g_conversation_store && 1464 g_conversation_store &&
946 Inference_Bridge_Is_Ready(g_inference_bridge); 1465 Inference_Bridge_Is_Ready(g_inference_bridge);
947 return Conversation_API_JSON_Response( 1466 return Conversation_API_JSON_Response(
948 p_arena, 1467 p_arena,
949 ready ? "200" : "503", 1468 ready ? "200" : "503",
950 ready ? "{\"status\":\"ready\"}" : "{\"status\":\"unavailable\"}"); 1469 ready ? "{\"status\":\"ready\"}" : "{\"status\":\"unavailable\"}");
951 } 1470 }
952 1471
1472 /* ------------------------------------------------------------------ */
1473 /* POST /api/conversations/:conversation_id/turns (streaming) */
1474 /* ------------------------------------------------------------------ */
1475
1476 /*
1477 * SSE client-disconnect callback.
1478 * Called by Seobeo_SSE_Server_Detach_Handle after g_sse_mutex is released.
1479 * Finds the pending turn for the disconnected stream and finalizes it once.
1480 * Must NOT hold g_sse_mutex when called; acquires g_pending_mutex.
1481 */
1482 static void Conversation_API_On_SSE_Detach(
1483 Seobeo_SSE_Stream *p_stream,
1484 void *ctx)
1485 {
1486 (void)ctx;
1487 pthread_mutex_lock(&g_pending_mutex);
1488 for (Pending_Turn *p_turn = g_pending_turns;
1489 p_turn;
1490 p_turn = p_turn->p_next)
1491 {
1492 if (p_turn->p_stream == p_stream && !p_turn->finalized)
1493 {
1494 p_turn->aborted = TRUE;
1495 snprintf(p_turn->error_message, sizeof(p_turn->error_message),
1496 "Client disconnected");
1497 Conversation_API_Finalize_Pending(p_turn);
1498 break;
1499 }
1500 }
1501 pthread_mutex_unlock(&g_pending_mutex);
1502 }
1503
953 static void Conversation_API_Turn_Stream( 1504 static void Conversation_API_Turn_Stream(
954 Seobeo_Handle *p_handle, 1505 Seobeo_Handle *p_handle,
955 Seobeo_Request_Entry *p_request, 1506 Seobeo_Request_Entry *p_request,
956 Dowa_Arena *p_arena) 1507 Dowa_Arena *p_arena)
957 { 1508 {
958 if (!Conversation_API_Is_Enabled()) 1509 /* Resolve existing identity first so we can apply controls per-principal. */
1510 Auth_Principal principal;
1511 boolean found = FALSE;
1512 if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
1513 {
1514 Conversation_API_Send_Stream_Error(
1515 p_handle, 500, "internal_error", "Session error");
1516 return;
1517 }
1518 if (!found)
1519 {
1520 Conversation_API_Send_Stream_Error(
1521 p_handle, 401, "auth_required", "Bootstrap session first");
1522 return;
1523 }
1524 if (principal.must_change_password)
1525 {
1526 Conversation_API_Send_Stream_Error(
1527 p_handle, 403, "password_change_required",
1528 "Password change required");
1529 return;
1530 }
1531 if (!Auth_API_Verify_CSRF(p_request, &principal))
1532 {
1533 Conversation_API_Send_Stream_Error(
1534 p_handle, 403, "csrf_invalid", "Same-origin and CSRF token required");
1535 return;
1536 }
1537
1538 /* Guests require inference to be explicitly enabled; authenticated users
1539 * always proceed subject to the global bridge-ready check below. */
1540 if (principal.kind == AUTH_PRINCIPAL_GUEST &&
1541 !Conversation_API_Is_Inference_Ready())
959 { 1542 {
960 Conversation_API_Send_Stream_Error( 1543 Conversation_API_Send_Stream_Error(
961 p_handle, 503, "inference_disabled", "Inference API is disabled"); 1544 p_handle, 503, "inference_disabled", "Inference API is disabled");
962 return; 1545 return;
963 } 1546 }
964 if (!Conversation_API_Is_Same_Origin(p_request))
965 {
966 Conversation_API_Send_Stream_Error(
967 p_handle, 403, "origin_rejected", "Same-origin request required");
968 return;
969 }
970 if (!g_conversation_store || !Inference_Bridge_Is_Ready(g_inference_bridge)) 1547 if (!g_conversation_store || !Inference_Bridge_Is_Ready(g_inference_bridge))
971 { 1548 {
972 Conversation_API_Send_Stream_Error( 1549 Conversation_API_Send_Stream_Error(
973 p_handle, 503, "inference_unavailable", "Inference runtime unavailable"); 1550 p_handle, 503, "inference_unavailable", "Inference runtime unavailable");
974 return; 1551 return;
975 } 1552 }
1553
1554 /* Map principal to owner — copy IDs before arena may expire (req 8) */
1555 Conversation_Owner owner;
1556 Conversation_API_Owner_From_Principal(&principal, &owner);
1557
976 const char *conversation_id = 1558 const char *conversation_id =
977 Conversation_API_Request_Value(p_request, ":conversation_id"); 1559 Conversation_API_Request_Value(p_request, ":conversation_id");
978 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena); 1560 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
979 char *prompt = object ? Dowa_JSON_Get_String(object, "prompt") : NULL; 1561 char *prompt = object ? Dowa_JSON_Get_String(object, "prompt") : NULL;
980 if (!conversation_id || !prompt || prompt[0] == '\0') 1562 if (!conversation_id || !prompt || prompt[0] == '\0')
990 p_handle, 413, "prompt_too_large", "Prompt exceeds 32 KiB"); 1572 p_handle, 413, "prompt_too_large", "Prompt exceeds 32 KiB");
991 return; 1573 return;
992 } 1574 }
993 if (!Conversation_API_Acquire_Turn_Slot()) 1575 if (!Conversation_API_Acquire_Turn_Slot())
994 { 1576 {
995 Conversation_API_Send_Stream_Error( 1577 /* Temporary capacity limit — advise client to retry in 5 s. */
996 p_handle, 429, "rate_limited", "Inference capacity exhausted"); 1578 static const char rate_body[] =
1579 "{\"error\":{\"code\":\"rate_limited\","
1580 "\"message\":\"Inference capacity exhausted\"}}";
1581 char rate_header[512];
1582 snprintf(rate_header, sizeof(rate_header),
1583 "HTTP/1.1 429 Too Many Requests\r\n"
1584 "Content-Type: application/json; charset=utf-8\r\n"
1585 "Content-Length: %zu\r\n"
1586 "Retry-After: 5\r\n"
1587 "Connection: close\r\n"
1588 "\r\n",
1589 sizeof(rate_body) - 1);
1590 Seobeo_Handle_Queue(
1591 p_handle, (const uint8 *)rate_header, (uint32)strlen(rate_header));
1592 Seobeo_Handle_Queue(
1593 p_handle, (const uint8 *)rate_body,
1594 (uint32)(sizeof(rate_body) - 1));
1595 Seobeo_Handle_Flush(p_handle);
997 return; 1596 return;
998 } 1597 }
999 1598
1000 char request_id[37]; 1599 char request_id[37];
1001 if (!Conversation_Store_Generate_UUID(request_id)) 1600 if (!Conversation_Store_Generate_UUID(request_id))
1003 Conversation_API_Release_Turn_Slot(); 1602 Conversation_API_Release_Turn_Slot();
1004 Conversation_API_Send_Stream_Error( 1603 Conversation_API_Send_Stream_Error(
1005 p_handle, 500, "id_failed", "Unable to create request ID"); 1604 p_handle, 500, "id_failed", "Unable to create request ID");
1006 return; 1605 return;
1007 } 1606 }
1008 Conversation_Store_Result result = Conversation_Store_Begin_Turn( 1607
1009 g_conversation_store, 1608 /* --- Guest quota reservation (before persisting turn) --- */
1010 conversation_id, 1609 boolean is_guest_reserved = FALSE;
1011 request_id, 1610 char quota_guest_id[37] = {0};
1012 prompt); 1611 if (principal.kind == AUTH_PRINCIPAL_GUEST)
1612 {
1613 Auth_Store *p_auth_store = Auth_API_Get_Store();
1614 if (!p_auth_store)
1615 {
1616 Conversation_API_Release_Turn_Slot();
1617 Conversation_API_Send_Stream_Error(
1618 p_handle, 503, "store_unavailable", "Auth store unavailable");
1619 return;
1620 }
1621
1622 int64 now_unix = (int64)time(NULL);
1623 int64 window_start = conv__utc_window_start(now_unix);
1624 int64 resets_at = window_start + 86400LL;
1625
1626 Auth_Store_Guest_Quota_Result quota_result = Auth_Store_Guest_Reserve(
1627 p_auth_store,
1628 principal.guest_id,
1629 request_id,
1630 window_start,
1631 g_guest_request_output_tokens,
1632 g_guest_daily_turns,
1633 g_guest_daily_output_tokens,
1634 now_unix + 3600);
1635
1636 if (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED ||
1637 quota_result == AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED)
1638 {
1639 /* Fetch current usage for 429 payload. */
1640 Auth_Store_Guest_Usage usage;
1641 memset(&usage, 0, sizeof(usage));
1642 Auth_Store_Guest_Get_Usage(
1643 p_auth_store, principal.guest_id, window_start, &usage);
1644
1645 int64 turns_remaining =
1646 g_guest_daily_turns - usage.turns_used;
1647 if (turns_remaining < 0) turns_remaining = 0;
1648 int64 tokens_remaining =
1649 g_guest_daily_output_tokens
1650 - usage.output_tokens_used
1651 - usage.output_tokens_reserved;
1652 if (tokens_remaining < 0) tokens_remaining = 0;
1653
1654 const char *code = (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED)
1655 ? "guest_quota_turns_exhausted"
1656 : "guest_quota_tokens_exhausted";
1657 const char *message = (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED)
1658 ? "Daily turn limit reached"
1659 : "Daily output token limit reached";
1660
1661 char quota_body[1024];
1662 int qlen = snprintf(
1663 quota_body, sizeof(quota_body),
1664 "{\"error\":{\"code\":\"%s\",\"message\":\"%s\","
1665 "\"quota\":{\"turnsLimit\":%lld,\"turnsUsed\":%lld,"
1666 "\"turnsRemaining\":%lld,\"outputTokensLimit\":%lld,"
1667 "\"outputTokensUsed\":%lld,\"outputTokensReserved\":%lld,"
1668 "\"outputTokensRemaining\":%lld,\"resetsAt\":%lld}}}",
1669 code, message,
1670 (long long)g_guest_daily_turns,
1671 (long long)usage.turns_used,
1672 (long long)turns_remaining,
1673 (long long)g_guest_daily_output_tokens,
1674 (long long)usage.output_tokens_used,
1675 (long long)usage.output_tokens_reserved,
1676 (long long)tokens_remaining,
1677 (long long)resets_at);
1678 if (qlen <= 0 || (size_t)qlen >= sizeof(quota_body))
1679 {
1680 Conversation_API_Release_Turn_Slot();
1681 Conversation_API_Send_Stream_Error(
1682 p_handle, 429, code, message);
1683 return;
1684 }
1685 char header[512];
1686 Seobeo_Web_Header_Generate(header, 429,
1687 "application/json; charset=utf-8", qlen);
1688 Seobeo_Handle_Queue(
1689 p_handle, (const uint8 *)header, (uint32)strlen(header));
1690 Seobeo_Handle_Queue(
1691 p_handle, (const uint8 *)quota_body, (uint32)qlen);
1692 Seobeo_Handle_Flush(p_handle);
1693 Conversation_API_Release_Turn_Slot();
1694 return;
1695 }
1696 if (quota_result != AUTH_STORE_GUEST_QUOTA_OK)
1697 {
1698 Conversation_API_Release_Turn_Slot();
1699 Conversation_API_Send_Stream_Error(
1700 p_handle, 500, "quota_error", "Unable to check guest quota");
1701 return;
1702 }
1703 is_guest_reserved = TRUE;
1704 snprintf(quota_guest_id, sizeof(quota_guest_id), "%s",
1705 principal.guest_id);
1706 }
1707
1708 Conversation_Store_Result result = Conversation_Store_Begin_Turn_Owned(
1709 g_conversation_store, conversation_id, &owner, request_id, prompt);
1013 if (result == CONVERSATION_STORE_NOT_FOUND) 1710 if (result == CONVERSATION_STORE_NOT_FOUND)
1014 { 1711 {
1712 if (is_guest_reserved)
1713 Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
1015 Conversation_API_Release_Turn_Slot(); 1714 Conversation_API_Release_Turn_Slot();
1016 Conversation_API_Send_Stream_Error( 1715 Conversation_API_Send_Stream_Error(
1017 p_handle, 404, "not_found", "Conversation not found"); 1716 p_handle, 404, "not_found", "Conversation not found");
1018 return; 1717 return;
1019 } 1718 }
1020 if (result == CONVERSATION_STORE_CONFLICT) 1719 if (result == CONVERSATION_STORE_CONFLICT)
1021 { 1720 {
1721 if (is_guest_reserved)
1722 Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
1022 Conversation_API_Release_Turn_Slot(); 1723 Conversation_API_Release_Turn_Slot();
1023 Conversation_API_Send_Stream_Error( 1724 Conversation_API_Send_Stream_Error(
1024 p_handle, 409, "turn_in_progress", "Conversation already has an active turn"); 1725 p_handle, 409, "turn_in_progress", "Conversation already has an active turn");
1025 return; 1726 return;
1026 } 1727 }
1027 if (result != CONVERSATION_STORE_OK) 1728 if (result != CONVERSATION_STORE_OK)
1028 { 1729 {
1730 if (is_guest_reserved)
1731 Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
1029 Conversation_API_Release_Turn_Slot(); 1732 Conversation_API_Release_Turn_Slot();
1030 Conversation_API_Send_Stream_Error( 1733 Conversation_API_Send_Stream_Error(
1031 p_handle, 500, "turn_failed", "Unable to persist turn"); 1734 p_handle, 500, "turn_failed", "Unable to persist turn");
1032 return; 1735 return;
1033 } 1736 }
1035 Seobeo_SSE_Stream *p_stream = Seobeo_SSE_Server_Attach( 1738 Seobeo_SSE_Stream *p_stream = Seobeo_SSE_Server_Attach(
1036 p_handle, 1739 p_handle,
1037 "/api/conversations/turns"); 1740 "/api/conversations/turns");
1038 if (!p_stream || !Seobeo_SSE_Retain(p_stream)) 1741 if (!p_stream || !Seobeo_SSE_Retain(p_stream))
1039 { 1742 {
1743 if (is_guest_reserved)
1744 Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
1040 Conversation_API_Release_Turn_Slot(); 1745 Conversation_API_Release_Turn_Slot();
1041 Conversation_Store_Fail_Turn( 1746 Conversation_Store_Fail_Turn(
1042 g_conversation_store, 1747 g_conversation_store,
1043 conversation_id, 1748 conversation_id,
1044 request_id, 1749 request_id,
1051 } 1756 }
1052 1757
1053 Pending_Turn *p_turn = calloc(1, sizeof(*p_turn)); 1758 Pending_Turn *p_turn = calloc(1, sizeof(*p_turn));
1054 if (!p_turn) 1759 if (!p_turn)
1055 { 1760 {
1761 if (is_guest_reserved)
1762 Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
1056 Conversation_API_Release_Turn_Slot(); 1763 Conversation_API_Release_Turn_Slot();
1057 Conversation_Store_Fail_Turn( 1764 Conversation_Store_Fail_Turn(
1058 g_conversation_store, 1765 g_conversation_store,
1059 conversation_id, 1766 conversation_id,
1060 request_id, 1767 request_id,
1071 snprintf( 1778 snprintf(
1072 p_turn->conversation_id, 1779 p_turn->conversation_id,
1073 sizeof(p_turn->conversation_id), 1780 sizeof(p_turn->conversation_id),
1074 "%s", 1781 "%s",
1075 conversation_id); 1782 conversation_id);
1783 /* Copy owner and quota fields before arena expires (req 8) */
1784 p_turn->owner = owner;
1785 p_turn->is_guest_reserved = is_guest_reserved;
1786 p_turn->reserved_output_tokens =
1787 is_guest_reserved ? g_guest_request_output_tokens : 0;
1788 snprintf(p_turn->guest_id, sizeof(p_turn->guest_id), "%s", quota_guest_id);
1076 p_turn->p_stream = p_stream; 1789 p_turn->p_stream = p_stream;
1790
1791 /* Register detach callback so client disconnect triggers finalization. */
1792 Seobeo_SSE_Set_Detach_Callback(p_stream, Conversation_API_On_SSE_Detach, NULL);
1077 1793
1078 pthread_mutex_lock(&g_pending_mutex); 1794 pthread_mutex_lock(&g_pending_mutex);
1079 p_turn->p_next = g_pending_turns; 1795 p_turn->p_next = g_pending_turns;
1080 g_pending_turns = p_turn; 1796 g_pending_turns = p_turn;
1081 pthread_mutex_unlock(&g_pending_mutex); 1797 pthread_mutex_unlock(&g_pending_mutex);
1101 } 1817 }
1102 pthread_mutex_unlock(&g_pending_mutex); 1818 pthread_mutex_unlock(&g_pending_mutex);
1103 } 1819 }
1104 } 1820 }
1105 1821
1106 boolean Conversation_API_Init(const char *database_path) 1822 boolean Conversation_API_Init(
1823 const char *database_path,
1824 const Conversation_API_Guest_Policy *p_policy)
1107 { 1825 {
1108 if (g_conversation_store) 1826 if (g_conversation_store)
1109 return TRUE; 1827 return TRUE;
1110 const char *allow_anonymous = getenv( 1828
1111 "MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE"); 1829 if (p_policy)
1112 g_anonymous_inference_enabled = 1830 {
1113 allow_anonymous && 1831 /* Validate policy ranges. */
1114 (strcmp(allow_anonymous, "1") == 0 || 1832 if (p_policy->daily_turns < CONV_GUEST_DAILY_TURNS_MIN ||
1115 strcasecmp(allow_anonymous, "true") == 0); 1833 p_policy->daily_turns > CONV_GUEST_DAILY_TURNS_MAX)
1834 {
1835 Seobeo_Log(SEOBEO_ERROR,
1836 "[CONV] daily_turns must be %d..%d\n",
1837 CONV_GUEST_DAILY_TURNS_MIN, CONV_GUEST_DAILY_TURNS_MAX);
1838 return FALSE;
1839 }
1840 if (p_policy->daily_output_tokens < CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN ||
1841 p_policy->daily_output_tokens > CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX)
1842 {
1843 Seobeo_Log(SEOBEO_ERROR,
1844 "[CONV] daily_output_tokens must be %d..%d\n",
1845 CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN,
1846 CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX);
1847 return FALSE;
1848 }
1849 if (p_policy->request_output_tokens < CONV_GUEST_REQUEST_OUTPUT_TOKENS_MIN ||
1850 p_policy->request_output_tokens > p_policy->daily_output_tokens)
1851 {
1852 Seobeo_Log(SEOBEO_ERROR,
1853 "[CONV] request_output_tokens must be 1..%lld\n",
1854 (long long)p_policy->daily_output_tokens);
1855 return FALSE;
1856 }
1857 g_guest_inference_enabled = p_policy->guest_inference_enabled;
1858 g_guest_daily_turns = p_policy->daily_turns;
1859 g_guest_daily_output_tokens = p_policy->daily_output_tokens;
1860 g_guest_request_output_tokens = p_policy->request_output_tokens;
1861 }
1862 else
1863 {
1864 /* No explicit policy: read from environment (backwards compat). */
1865 const char *allow_guest = getenv("MRJUNEJUNE_ALLOW_GUEST_INFERENCE");
1866 if (!allow_guest)
1867 {
1868 const char *allow_anon = getenv("MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE");
1869 if (allow_anon)
1870 {
1871 Seobeo_Log(SEOBEO_WARNING,
1872 "[CONV] MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE is deprecated;"
1873 " use MRJUNEJUNE_ALLOW_GUEST_INFERENCE\n");
1874 allow_guest = allow_anon;
1875 }
1876 }
1877 g_guest_inference_enabled =
1878 allow_guest &&
1879 (strcmp(allow_guest, "1") == 0 ||
1880 strcasecmp(allow_guest, "true") == 0);
1881
1882 g_guest_request_output_tokens =
1883 g_guest_daily_output_tokens < CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT
1884 ? g_guest_daily_output_tokens
1885 : CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT;
1886 }
1887
1116 g_conversation_store = Conversation_Store_Create(database_path); 1888 g_conversation_store = Conversation_Store_Create(database_path);
1117 return g_conversation_store != NULL; 1889 return g_conversation_store != NULL;
1118 } 1890 }
1119 1891
1120 boolean Conversation_API_Enable_Inference( 1892 boolean Conversation_API_Enable_Inference(
1139 return FALSE; 1911 return FALSE;
1140 } 1912 }
1141 return TRUE; 1913 return TRUE;
1142 } 1914 }
1143 1915
1916 /* Guest-to-user transfer hook registered with Auth_API on init */
1917 static boolean Conversation_API_Guest_Transfer_Hook(
1918 const char *guest_id,
1919 const char *user_id,
1920 void *context)
1921 {
1922 (void)context;
1923 if (!g_conversation_store)
1924 return FALSE;
1925
1926 /* Atomically transfer conversation ownership AND clear outstanding quota
1927 * reservations in one transaction (auth and conv tables share the same
1928 * SQLite file, so the write lock covers both). */
1929 return Conversation_Store_Transfer_Guest_To_User_Atomic(
1930 g_conversation_store, guest_id, user_id) == CONVERSATION_STORE_OK;
1931 }
1932
1144 void Conversation_API_Register_Routes(void) 1933 void Conversation_API_Register_Routes(void)
1145 { 1934 {
1935 /* Wire guest-to-user transfer on login */
1936 Auth_API_Register_Guest_Transfer_Hook(
1937 Conversation_API_Guest_Transfer_Hook, NULL);
1938
1939 /* Wire quota callback for the session endpoint. */
1940 Auth_API_Register_Guest_Quota_Cb(conv_guest_quota_cb);
1941
1146 Seobeo_Router_Register( 1942 Seobeo_Router_Register(
1147 "GET", "/api/inference/health", Conversation_API_Health); 1943 "GET", "/api/inference/health", Conversation_API_Health);
1944 Seobeo_Router_Register(
1945 "GET", "/api/conversations", Conversation_API_List);
1148 Seobeo_Router_Register("POST", "/api/conversations", Conversation_API_Create); 1946 Seobeo_Router_Register("POST", "/api/conversations", Conversation_API_Create);
1947 /* Body-based claim: ID stays in request body, not in URL or logs. */
1948 Seobeo_Router_Register(
1949 "POST", "/api/conversations/claim", Conversation_API_Claim_Body);
1149 Seobeo_Router_Register( 1950 Seobeo_Router_Register(
1150 "GET", "/api/conversations/:conversation_id", Conversation_API_Get); 1951 "GET", "/api/conversations/:conversation_id", Conversation_API_Get);
1151 Seobeo_Router_Register( 1952 Seobeo_Router_Register(
1152 "PATCH", "/api/conversations/:conversation_id", Conversation_API_Update); 1953 "PATCH", "/api/conversations/:conversation_id", Conversation_API_Update);
1153 Seobeo_Router_Register( 1954 Seobeo_Router_Register(
1165 pthread_mutex_lock(&g_pending_mutex); 1966 pthread_mutex_lock(&g_pending_mutex);
1166 while (g_pending_turns) 1967 while (g_pending_turns)
1167 { 1968 {
1168 Pending_Turn *p_turn = g_pending_turns; 1969 Pending_Turn *p_turn = g_pending_turns;
1169 g_pending_turns = p_turn->p_next; 1970 g_pending_turns = p_turn->p_next;
1971 /* Release guest quota reservation on server shutdown (keep turn charge). */
1972 if (p_turn->is_guest_reserved)
1973 {
1974 Auth_Store *p_auth_store = Auth_API_Get_Store();
1975 if (p_auth_store)
1976 Auth_Store_Guest_Release(p_auth_store, p_turn->request_id);
1977 }
1170 Conversation_Store_Fail_Turn( 1978 Conversation_Store_Fail_Turn(
1171 g_conversation_store, 1979 g_conversation_store,
1172 p_turn->conversation_id, 1980 p_turn->conversation_id,
1173 p_turn->request_id, 1981 p_turn->request_id,
1174 "Server shutdown", 1982 "Server shutdown",
1180 Conversation_API_Release_Turn_Slot(); 1988 Conversation_API_Release_Turn_Slot();
1181 } 1989 }
1182 pthread_mutex_unlock(&g_pending_mutex); 1990 pthread_mutex_unlock(&g_pending_mutex);
1183 Conversation_Store_Destroy(g_conversation_store); 1991 Conversation_Store_Destroy(g_conversation_store);
1184 g_conversation_store = NULL; 1992 g_conversation_store = NULL;
1185 g_anonymous_inference_enabled = FALSE; 1993 g_guest_inference_enabled = FALSE;
1186 } 1994 g_guest_daily_turns = CONV_GUEST_DAILY_TURNS_DEFAULT;
1995 g_guest_daily_output_tokens = CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT;
1996 g_guest_request_output_tokens = CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT;
1997 }