1use std::sync::Arc;
32
33use axum::{
34 Extension, Json, Router,
35 extract::{Path, Query},
36 http::StatusCode,
37 response::{IntoResponse, Response},
38 routing::{get, post},
39};
40use serde::{Deserialize, Serialize};
41
42#[cfg(feature = "mining")]
43use ijima_core::capabilities::MINING_TRIGGER;
44use ijima_core::{
45 AcceptedExtraction, DiaryEntry, Embedder, EntityId, KnowledgeGraph, Memory, MemoryId,
46 NamespaceCount, NamespaceId, PalaceGraph, ProjectTaxon, QueuedExtraction, RepoDirectory, Room,
47 SearchHit, Session, SessionId, SessionTurn, Store, TunnelTraversal,
48 capabilities::{
49 ADMIN, KNOWLEDGE_READ, MEMORY_READ, MEMORY_WRITE, MINING_REVIEW, SESSION_INGEST,
50 TRUST_PROMOTE,
51 },
52 harness::Harness,
53};
54
55use crate::extractor::AuthPrincipal;
56use crate::redaction::Redactor;
57
58pub fn app(
63 auth: Arc<crate::IjimaAuth>,
64 store: Arc<dyn Store>,
65 kg: Arc<dyn KnowledgeGraph>,
66 embedder: Option<Arc<dyn Embedder>>,
67 redactor: Arc<Redactor>,
68 #[cfg(feature = "rate-limit")] rate_limiter: Option<crate::rate_limit::RateLimitState>,
69) -> Router {
70 let router = Router::new()
71 .route("/health", get(health))
72 .route("/status", get(status))
73 .route("/memories", get(browse_memories).post(store_memory))
74 .route("/memories/check", post(check_duplicate))
75 .route("/memories/search", post(search_memories))
76 .route("/memories/stats", get(memory_stats))
77 .route("/memories/{id}", get(recall_memory).delete(delete_memory))
78 .route("/memories/{id}/promote", post(promote_memory))
79 .route("/rooms", get(list_rooms))
80 .route("/taxonomy", get(taxonomy))
81 .route("/palace/graph", get(palace_graph))
82 .route("/palace/tunnel", get(traverse_tunnel))
83 .route("/diaries", post(write_diary))
84 .route("/diaries/{agent}", get(read_diary))
85 .route("/repos", get(list_repos).post(register_repo))
86 .route("/repos/resolve", get(resolve_repo))
87 .route("/doctrine", post(ingest_doctrine))
88 .route("/wakeup", get(wakeup))
89 .route("/kg/triples", post(add_triple).get(find_triples))
90 .route("/kg/entities/{id}", get(query_entity))
91 .route("/kg/triples/{id}/invalidate", post(invalidate_triple))
92 .route("/kg/timeline", get(kg_timeline))
93 .route("/kg/stats", get(kg_stats))
94 .route(
95 "/sessions/{session_id}/turns",
96 post(ingest_turn).get(session_turns),
97 )
98 .route("/sessions", post(create_session).get(list_sessions))
99 .route("/sessions/{session_id}/end", post(end_session))
100 .route("/mining/queue", get(list_pending))
101 .route("/mining/queue/{id}/accept", post(accept_extraction))
102 .route("/mining/queue/{id}/reject", post(reject_extraction));
103 #[cfg(feature = "mining")]
104 let router = router.route("/sessions/{session_id}/mine", post(trigger_mine));
105 let router = router
106 .layer(Extension(auth))
107 .layer(Extension(store))
108 .layer(Extension(kg))
109 .layer(Extension(embedder))
110 .layer(Extension(redactor));
111
112 #[cfg(feature = "rate-limit")]
113 let router = match rate_limiter {
114 Some(rl) => router.layer(Extension(rl)),
115 None => router,
116 };
117 #[cfg(not(feature = "rate-limit"))]
118 let router = router;
119
120 router
121}
122
123#[derive(Debug)]
127pub enum ApiError {
128 Forbidden,
130 NotFound,
132 BadRequest(String),
134 Conflict(String),
136 Internal(String),
138}
139
140impl IntoResponse for ApiError {
141 fn into_response(self) -> Response {
142 let (status, msg): (StatusCode, String) = match self {
143 ApiError::Forbidden => (StatusCode::FORBIDDEN, "forbidden".into()),
144 ApiError::NotFound => (StatusCode::NOT_FOUND, "not found".into()),
145 ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
146 ApiError::Conflict(m) => (StatusCode::CONFLICT, m),
147 ApiError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m),
148 };
149 (status, msg).into_response()
150 }
151}
152
153fn internal(e: ijima_core::IjimaError) -> ApiError {
154 match e {
155 ijima_core::IjimaError::Duplicate { detail } => ApiError::Conflict(detail),
156 other => ApiError::Internal(other.to_string()),
157 }
158}
159
160#[derive(Deserialize, Default)]
162struct NsQuery {
163 namespace: Option<String>,
167 limit: Option<usize>,
168}
169
170fn resolve_ns(
178 principal: &AuthPrincipal,
179 requested: Option<&str>,
180) -> Result<ijima_core::NamespaceId, ApiError> {
181 let own = format!("ns_{}_private", principal.0.principal.as_str());
182 match requested {
183 None => Ok(ijima_core::NamespaceId::new(own)),
184 Some(ns) if ns == own => Ok(ijima_core::NamespaceId::new(ns)),
185 Some(ns) if ns.ends_with("_private") => Err(ApiError::Forbidden),
186 Some(ns) => Ok(ijima_core::NamespaceId::new(ns)),
187 }
188}
189
190async fn health() -> impl IntoResponse {
193 Json(serde_json::json!({ "status": "ok" }))
194}
195
196#[derive(Serialize)]
197struct StatusResponse {
198 memories: usize,
199 namespaces: Vec<NamespaceCount>,
200 entities: usize,
201 triples: usize,
202}
203
204async fn status(
208 principal: AuthPrincipal,
209 Extension(store): Extension<Arc<dyn Store>>,
210 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
211) -> Result<Json<StatusResponse>, ApiError> {
212 if !principal.0.may(ijima_core::capabilities::ADMIN) {
213 return Err(ApiError::Forbidden);
214 }
215 let store_stats = store.store_stats().await.map_err(internal)?;
216 let kg_stats = kg.kg_global_stats().await.map_err(internal)?;
217 Ok(Json(StatusResponse {
218 memories: store_stats.total_memories,
219 namespaces: store_stats.namespaces,
220 entities: kg_stats.entities,
221 triples: kg_stats.triples,
222 }))
223}
224
225#[derive(Serialize)]
226struct IdResponse {
227 id: String,
228}
229
230async fn store_memory(
231 principal: AuthPrincipal,
232 Extension(store): Extension<Arc<dyn Store>>,
233 Json(memory): Json<Memory>,
234) -> Result<Json<IdResponse>, ApiError> {
235 if !principal.0.may(MEMORY_WRITE) {
236 return Err(ApiError::Forbidden);
237 }
238 let ns = principal.0.personal_namespace();
239 let mut memory = memory;
240 if memory.created_at.is_empty() {
241 memory.created_at = std::time::SystemTime::now()
242 .duration_since(std::time::UNIX_EPOCH)
243 .map(|d| d.as_secs().to_string())
244 .unwrap_or_default();
245 }
246 let id = store.store_memory(&ns, memory).await.map_err(internal)?;
247 Ok(Json(IdResponse { id: id.0 }))
248}
249
250#[derive(Deserialize)]
251struct CheckDuplicateRequest {
252 content: String,
253}
254
255#[derive(Serialize)]
256struct CheckDuplicateResponse {
257 duplicate: Option<String>,
259}
260
261async fn check_duplicate(
265 principal: AuthPrincipal,
266 Extension(store): Extension<Arc<dyn Store>>,
267 Query(q): Query<NsQuery>,
268 Json(req): Json<CheckDuplicateRequest>,
269) -> Result<Json<CheckDuplicateResponse>, ApiError> {
270 if !principal.0.may(MEMORY_READ) {
271 return Err(ApiError::Forbidden);
272 }
273 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
274 let dup = store
275 .check_duplicate(&ns, &req.content)
276 .await
277 .map_err(internal)?;
278 Ok(Json(CheckDuplicateResponse {
279 duplicate: dup.map(|id| id.0),
280 }))
281}
282
283async fn recall_memory(
284 principal: AuthPrincipal,
285 Extension(store): Extension<Arc<dyn Store>>,
286 Path(id): Path<String>,
287 Query(q): Query<NsQuery>,
288) -> Result<Json<Memory>, ApiError> {
289 if !principal.0.may(MEMORY_READ) {
290 return Err(ApiError::Forbidden);
291 }
292 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
293 match store
294 .recall_memory(&ns, &MemoryId(id))
295 .await
296 .map_err(internal)?
297 {
298 Some(memory) => Ok(Json(memory)),
299 None => Err(ApiError::NotFound),
300 }
301}
302
303async fn delete_memory(
304 principal: AuthPrincipal,
305 Extension(store): Extension<Arc<dyn Store>>,
306 Path(id): Path<String>,
307 Query(q): Query<NsQuery>,
308) -> Result<StatusCode, ApiError> {
309 if !principal.0.may(MEMORY_WRITE) {
310 return Err(ApiError::Forbidden);
311 }
312 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
313 store
314 .delete_memory(&ns, &MemoryId(id))
315 .await
316 .map_err(internal)?;
317 Ok(StatusCode::NO_CONTENT)
318}
319
320#[derive(Deserialize)]
321struct SearchRequest {
322 text: String,
326 limit: Option<usize>,
327 scope: Option<String>,
332}
333
334#[derive(Serialize)]
335struct SearchResponse {
336 memories: Vec<SearchHit>,
337}
338
339async fn search_memories(
340 principal: AuthPrincipal,
341 Extension(store): Extension<Arc<dyn Store>>,
342 Extension(embedder): Extension<Option<Arc<dyn Embedder>>>,
343 Query(q): Query<NsQuery>,
344 Json(req): Json<SearchRequest>,
345) -> Result<Json<SearchResponse>, ApiError> {
346 if !principal.0.may(MEMORY_READ) {
347 return Err(ApiError::Forbidden);
348 }
349 let embedder = embedder
350 .ok_or_else(|| ApiError::Internal("search unavailable: daemon has no embedder".into()))?;
351 let query = embedder.embed(&req.text).map_err(internal)?;
352 let limit = req.limit.unwrap_or(10);
353
354 let hits = if req.scope.as_deref() == Some("visible") {
358 let own_ns = principal.0.personal_namespace();
359 let global_ns = NamespaceId::new("global");
360 let own_hits = store
361 .search_memories(&own_ns, &query, limit)
362 .await
363 .map_err(internal)?;
364 let global_hits = if own_ns == global_ns {
365 Vec::new()
366 } else {
367 store
368 .search_memories(&global_ns, &query, limit)
369 .await
370 .map_err(internal)?
371 };
372 merge_search_hits(own_hits, global_hits, limit)
373 } else {
374 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
375 store
376 .search_memories(&ns, &query, limit)
377 .await
378 .map_err(internal)?
379 };
380 Ok(Json(SearchResponse { memories: hits }))
381}
382
383fn merge_search_hits(a: Vec<SearchHit>, b: Vec<SearchHit>, limit: usize) -> Vec<SearchHit> {
388 use std::collections::HashSet;
389 let mut all: Vec<SearchHit> = a.into_iter().chain(b).collect();
390 all.sort_by(|x, y| {
391 y.similarity
392 .partial_cmp(&x.similarity)
393 .unwrap_or(std::cmp::Ordering::Equal)
394 });
395 let mut seen: HashSet<String> = HashSet::new();
397 all.retain(|h| seen.insert(h.memory.id.0.clone()));
398 all.truncate(limit);
399 all
400}
401
402#[derive(Deserialize)]
405struct PromoteRequest {
406 target_namespace: String,
409 new_id: Option<String>,
412}
413
414#[derive(Serialize)]
415struct PromoteResponse {
416 id: String,
417 original_id: String,
418 target_namespace: String,
419 redactions: Vec<crate::redaction::Redaction>,
420}
421
422async fn promote_memory(
427 principal: AuthPrincipal,
428 Extension(store): Extension<Arc<dyn Store>>,
429 Extension(redactor): Extension<Arc<Redactor>>,
430 Path(id): Path<String>,
431 Json(req): Json<PromoteRequest>,
432) -> Result<Json<PromoteResponse>, ApiError> {
433 if !principal.0.may(TRUST_PROMOTE) {
434 return Err(ApiError::Forbidden);
435 }
436 let personal_ns = principal.0.personal_namespace();
437
438 let memory = store
440 .recall_memory(&personal_ns, &MemoryId(id.clone()))
441 .await
442 .map_err(internal)?
443 .ok_or(ApiError::NotFound)?;
444
445 let scrubbed = redactor.redact(&memory.content);
447
448 let new_id = req
450 .new_id
451 .clone()
452 .unwrap_or_else(|| format!("{id}__shared"));
453 let promoted = Memory {
454 id: MemoryId(new_id.clone()),
455 content: scrubbed.text,
456 project: memory.project,
457 topic: memory.topic,
458 source: ijima_core::memory::MemorySource::Explicit,
459 harness: memory.harness,
460 session_id: Some(id.clone()),
462 origin: memory.origin.clone(),
464 authority: memory.authority.clone(),
465 importance: memory.importance,
466 created_at: memory.created_at.clone(),
467 };
468 let target_ns = ijima_core::NamespaceId::new(&req.target_namespace);
469 store
470 .store_memory(&target_ns, promoted)
471 .await
472 .map_err(internal)?;
473
474 Ok(Json(PromoteResponse {
475 id: new_id,
476 original_id: id,
477 target_namespace: req.target_namespace,
478 redactions: scrubbed.redactions,
479 }))
480}
481
482#[derive(Deserialize)]
485struct DoctrineRequest {
486 id: String,
487 content: String,
488 project: String,
489 topic: String,
490}
491
492async fn ingest_doctrine(
497 principal: AuthPrincipal,
498 Extension(store): Extension<Arc<dyn Store>>,
499 Json(req): Json<DoctrineRequest>,
500) -> Result<Json<IdResponse>, ApiError> {
501 if !principal.0.may(ijima_core::capabilities::ADMIN) {
502 return Err(ApiError::Forbidden);
503 }
504 let ns = ijima_core::NamespaceId::new(ijima_core::namespace::DOCTRINE_NAMESPACE);
505 store
507 .delete_memory(&ns, &MemoryId(req.id.clone()))
508 .await
509 .map_err(internal)?;
510 let memory = Memory {
511 id: MemoryId(req.id.clone()),
512 content: req.content,
513 project: req.project,
514 topic: req.topic,
515 source: ijima_core::memory::MemorySource::Doctrine,
516 harness: ijima_core::harness::Harness::Other,
517 session_id: None,
518 origin: ijima_core::InstanceId::local(),
520 authority: ijima_core::AuthorityScope::local(),
521 importance: 1.0,
522 created_at: std::time::SystemTime::now()
523 .duration_since(std::time::UNIX_EPOCH)
524 .map(|d| d.as_secs().to_string())
525 .unwrap_or_default(),
526 };
527 store.store_memory(&ns, memory).await.map_err(internal)?;
528 Ok(Json(IdResponse { id: req.id }))
529}
530
531const WAKEUP_PERSONAL_LIMIT: usize = 20;
535const WAKEUP_DOCTRINE_LIMIT: usize = 50;
537
538#[derive(Serialize)]
539struct WakeupResponse {
540 identity: serde_json::Value,
542 personal_essentials: Vec<Memory>,
544 doctrine: Vec<Memory>,
546}
547
548async fn wakeup(
552 principal: AuthPrincipal,
553 Extension(store): Extension<Arc<dyn Store>>,
554) -> Result<Json<WakeupResponse>, ApiError> {
555 if !principal.0.may(MEMORY_READ) {
556 return Err(ApiError::Forbidden);
557 }
558 let personal_ns = principal.0.personal_namespace();
559 let doctrine_ns = ijima_core::NamespaceId::new(ijima_core::namespace::DOCTRINE_NAMESPACE);
560
561 let (personal_essentials, doctrine) = tokio::join!(
562 store.list_memories(&personal_ns, WAKEUP_PERSONAL_LIMIT),
563 store.list_memories(&doctrine_ns, WAKEUP_DOCTRINE_LIMIT),
564 );
565
566 Ok(Json(WakeupResponse {
567 identity: serde_json::json!({ "principal": principal.0.principal.as_str() }),
568 personal_essentials: personal_essentials.map_err(internal)?,
569 doctrine: doctrine.map_err(internal)?,
570 }))
571}
572
573#[derive(Deserialize)]
576struct AddTripleRequest {
577 subject: String,
578 predicate: String,
579 object: String,
580 valid_from: Option<String>,
581 confidence: Option<f32>,
582 source_memory_id: Option<String>,
583}
584
585async fn add_triple(
586 principal: AuthPrincipal,
587 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
588 Extension(store): Extension<Arc<dyn Store>>,
589 Json(req): Json<AddTripleRequest>,
590) -> Result<Json<ijima_core::Triple>, ApiError> {
591 if !principal.0.may(ijima_core::capabilities::KNOWLEDGE_WRITE) {
592 return Err(ApiError::Forbidden);
593 }
594 let ns = resolve_ns(&principal, None)?;
595 let triple = kg
596 .add_triple(
597 &ns,
598 EntityId::new(req.subject),
599 &req.predicate,
600 EntityId::new(req.object),
601 req.valid_from.as_deref(),
602 req.confidence.unwrap_or(1.0),
603 req.source_memory_id.as_deref(),
604 )
605 .await
606 .map_err(internal)?;
607 let _ = store;
609 Ok(Json(triple))
610}
611
612async fn query_entity(
613 principal: AuthPrincipal,
614 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
615 Path(id): Path<String>,
616 Query(q): Query<NsQuery>,
617) -> Result<Json<ijima_core::EntityRecord>, ApiError> {
618 if !principal.0.may(KNOWLEDGE_READ) {
619 return Err(ApiError::Forbidden);
620 }
621 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
622 let rec = kg
623 .query_entity(&ns, &EntityId::new(id))
624 .await
625 .map_err(internal)?;
626 Ok(Json(rec))
627}
628
629async fn invalidate_triple(
630 principal: AuthPrincipal,
631 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
632 Path(id): Path<String>,
633 Query(q): Query<NsQuery>,
634) -> Result<StatusCode, ApiError> {
635 if !principal.0.may(ijima_core::capabilities::KNOWLEDGE_WRITE) {
636 return Err(ApiError::Forbidden);
637 }
638 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
639 kg.invalidate_triple(&ns, &id).await.map_err(internal)?;
640 Ok(StatusCode::NO_CONTENT)
641}
642
643#[derive(Deserialize, Default)]
644struct FindTriplesQuery {
645 namespace: Option<String>,
646 subject: Option<String>,
647 predicate: Option<String>,
648 object: Option<String>,
649}
650
651async fn find_triples(
652 principal: AuthPrincipal,
653 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
654 Query(q): Query<FindTriplesQuery>,
655) -> Result<Json<Vec<ijima_core::Triple>>, ApiError> {
656 if !principal.0.may(KNOWLEDGE_READ) {
657 return Err(ApiError::Forbidden);
658 }
659 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
660 let triples = kg
661 .find_triples(
662 &ns,
663 q.subject.as_deref().map(EntityId::new).as_ref(),
664 q.predicate.as_deref(),
665 q.object.as_deref().map(EntityId::new).as_ref(),
666 )
667 .await
668 .map_err(internal)?;
669 Ok(Json(triples))
670}
671
672async fn kg_timeline(
673 principal: AuthPrincipal,
674 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
675 Query(q): Query<NsQuery>,
676) -> Result<Json<Vec<ijima_core::Triple>>, ApiError> {
677 if !principal.0.may(KNOWLEDGE_READ) {
678 return Err(ApiError::Forbidden);
679 }
680 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
681 let triples = kg
682 .kg_timeline(&ns, q.limit.unwrap_or(50))
683 .await
684 .map_err(internal)?;
685 Ok(Json(triples))
686}
687
688async fn kg_stats(
689 principal: AuthPrincipal,
690 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
691 Query(q): Query<NsQuery>,
692) -> Result<Json<ijima_core::KgStats>, ApiError> {
693 if !principal.0.may(KNOWLEDGE_READ) {
694 return Err(ApiError::Forbidden);
695 }
696 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
697 let stats = kg.knowledge_stats(&ns).await.map_err(internal)?;
698 Ok(Json(stats))
699}
700
701async fn ingest_turn(
702 principal: AuthPrincipal,
703 Extension(store): Extension<Arc<dyn Store>>,
704 Path(session_id): Path<String>,
705 Json(mut turn): Json<SessionTurn>,
706) -> Result<StatusCode, ApiError> {
707 if !principal.0.may(SESSION_INGEST) {
708 return Err(ApiError::Forbidden);
709 }
710 let ns = principal.0.personal_namespace();
711 turn.session_id = SessionId::new(session_id);
712 store.ingest_turn(&ns, turn).await.map_err(internal)?;
713 Ok(StatusCode::NO_CONTENT)
714}
715
716#[derive(Serialize)]
719struct TurnsResponse {
720 turns: Vec<SessionTurn>,
721}
722
723async fn session_turns(
724 principal: AuthPrincipal,
725 Extension(store): Extension<Arc<dyn Store>>,
726 Path(session_id): Path<String>,
727 Query(q): Query<NsQuery>,
728) -> Result<Json<TurnsResponse>, ApiError> {
729 if !principal.0.may(MEMORY_READ) {
730 return Err(ApiError::Forbidden);
731 }
732 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
733 let turns = store
734 .session_turns(&ns, &SessionId::new(session_id), q.limit.unwrap_or(50))
735 .await
736 .map_err(internal)?;
737 Ok(Json(TurnsResponse { turns }))
738}
739
740async fn create_session(
745 principal: AuthPrincipal,
746 Extension(store): Extension<Arc<dyn Store>>,
747 Json(mut session): Json<Session>,
748) -> Result<Json<IdResponse>, ApiError> {
749 if !principal.0.may(SESSION_INGEST) {
750 return Err(ApiError::Forbidden);
751 }
752 let ns = principal.0.personal_namespace();
753 if session.started_at.is_empty() {
754 session.started_at = std::time::SystemTime::now()
755 .duration_since(std::time::UNIX_EPOCH)
756 .map(|d| d.as_secs().to_string())
757 .unwrap_or_default();
758 }
759 session.ended_at = None;
760 let id = store.create_session(&ns, session).await.map_err(internal)?;
761 Ok(Json(IdResponse { id: id.0 }))
762}
763
764#[derive(Deserialize)]
765struct SessionListQuery {
766 namespace: Option<String>,
767 harness: Option<String>,
769 limit: Option<usize>,
770}
771
772async fn list_sessions(
776 principal: AuthPrincipal,
777 Extension(store): Extension<Arc<dyn Store>>,
778 Query(q): Query<SessionListQuery>,
779) -> Result<Json<Vec<Session>>, ApiError> {
780 if !principal.0.may(MEMORY_READ) {
781 return Err(ApiError::Forbidden);
782 }
783 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
784 let harness = q.harness.as_deref().map(Harness::from_wire_str);
785 let limit = q.limit.unwrap_or(50).min(500);
786 let sessions = store
787 .list_sessions(&ns, harness.as_ref(), limit)
788 .await
789 .map_err(internal)?;
790 Ok(Json(sessions))
791}
792
793#[derive(Deserialize)]
794struct EndSessionRequest {
795 ended_at: String,
796}
797
798async fn end_session(
801 principal: AuthPrincipal,
802 Extension(store): Extension<Arc<dyn Store>>,
803 Path(session_id): Path<String>,
804 Json(req): Json<EndSessionRequest>,
805) -> Result<StatusCode, ApiError> {
806 if !principal.0.may(SESSION_INGEST) {
807 return Err(ApiError::Forbidden);
808 }
809 let ns = principal.0.personal_namespace();
810 store
811 .end_session(&ns, &SessionId::new(session_id), req.ended_at)
812 .await
813 .map_err(internal)?;
814 Ok(StatusCode::NO_CONTENT)
815}
816
817async fn list_pending(
822 principal: AuthPrincipal,
823 Extension(store): Extension<Arc<dyn Store>>,
824 Query(q): Query<NsQuery>,
825) -> Result<Json<Vec<QueuedExtraction>>, ApiError> {
826 if !principal.0.may(MINING_REVIEW) {
827 return Err(ApiError::Forbidden);
828 }
829 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
830 let limit = q.limit.unwrap_or(50).min(500);
831 let pending = store.list_pending(&ns, limit).await.map_err(internal)?;
832 Ok(Json(pending))
833}
834
835async fn accept_extraction(
838 principal: AuthPrincipal,
839 Extension(store): Extension<Arc<dyn Store>>,
840 Path(id): Path<String>,
841) -> Result<Json<AcceptedExtraction>, ApiError> {
842 if !principal.0.may(MINING_REVIEW) {
843 return Err(ApiError::Forbidden);
844 }
845 let ns = principal.0.personal_namespace();
846 let accepted = store.accept_extraction(&ns, &id).await.map_err(internal)?;
847 Ok(Json(accepted))
848}
849
850async fn reject_extraction(
853 principal: AuthPrincipal,
854 Extension(store): Extension<Arc<dyn Store>>,
855 Path(id): Path<String>,
856) -> Result<StatusCode, ApiError> {
857 if !principal.0.may(MINING_REVIEW) {
858 return Err(ApiError::Forbidden);
859 }
860 let ns = principal.0.personal_namespace();
861 store.reject_extraction(&ns, &id).await.map_err(internal)?;
862 Ok(StatusCode::NO_CONTENT)
863}
864
865#[cfg(feature = "mining")]
879async fn trigger_mine(
880 principal: AuthPrincipal,
881 Extension(store): Extension<Arc<dyn Store>>,
882 Path(session_id): Path<String>,
883 Query(q): Query<NsQuery>,
884) -> Result<Json<crate::mining_pipeline::MiningReport>, ApiError> {
885 use proserpina::backend::http::HttpAgent;
886
887 if !principal.0.may(MINING_TRIGGER) {
888 return Err(ApiError::Forbidden);
889 }
890 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
891
892 let turns = store
894 .session_turns(&ns, &SessionId::new(session_id.clone()), 10_000)
895 .await
896 .map_err(internal)?;
897 let turn_texts: Vec<String> = turns.into_iter().map(|t| t.content).collect();
898 let ctx = crate::mining_pipeline::mining_context(&session_id, "general", Harness::Other);
899
900 let extractions = tokio::task::spawn_blocking(move || {
907 let mut agent: Option<HttpAgent> = build_mining_agent();
908 let agent_dyn: Option<&mut dyn proserpina::Agent> =
909 agent.as_mut().map(|a| a as &mut dyn proserpina::Agent);
910 ijima_miner::mine_all(&turn_texts, &ctx, agent_dyn)
911 })
912 .await
913 .map_err(|e| {
914 internal(ijima_core::IjimaError::Mining {
915 detail: format!("extraction task failed: {e}"),
916 })
917 })?
918 .map_err(internal)?;
919
920 let report = crate::mining_pipeline::ingest_extractions(store.as_ref(), &ns, extractions)
921 .await
922 .map_err(internal)?;
923 Ok(Json(report))
924}
925
926#[cfg(feature = "mining")]
936fn build_mining_agent() -> Option<proserpina::backend::http::HttpAgent> {
937 use proserpina::{
938 AgentId, Persona,
939 backend::http::{HttpAgent, HttpConfig},
940 };
941
942 let base_url = std::env::var("IJIMA_LLM_BASE_URL")
943 .unwrap_or_else(|_| "https://api.deepseek.com/v1".to_string());
944 let model = std::env::var("IJIMA_LLM_MODEL").ok()?;
945 let api_key = std::env::var("IJIMA_LLM_API_KEY").ok()?;
946
947 let persona = Persona::new("Session Mining Extractor")
948 .with_framing(
949 "You mine session transcripts for durable facts and recurring \
950 patterns. Output one JSON object per line, each \
951 {\"content\",\"project\",\"topic\",\"confidence\"}. Omit all \
952 preamble. If nothing worth extracting, output nothing.",
953 )
954 .with_focus(
955 "decisions, chosen tools, stated constraints, measurements, recurring workflows",
956 );
957
958 Some(HttpAgent::new(
959 AgentId::new("ijima-miner"),
960 persona,
961 HttpConfig {
962 base_url,
963 model,
964 api_key,
965 },
966 ))
967}
968
969#[derive(Deserialize)]
972struct NamespaceQuery {
973 namespace: Option<String>,
974}
975
976#[derive(Deserialize)]
977struct RoomsQuery {
978 namespace: Option<String>,
979 project: Option<String>,
980 limit: Option<usize>,
981}
982
983#[derive(Deserialize)]
984struct TunnelQuery {
985 namespace: Option<String>,
986 topic: String,
987 project_a: String,
988 project_b: String,
989 limit: Option<usize>,
990}
991
992#[derive(Deserialize)]
993struct DiaryQuery {
994 namespace: Option<String>,
995 limit: Option<usize>,
996}
997
998#[derive(Deserialize)]
999struct MemoryBrowseQuery {
1000 namespace: Option<String>,
1001 project: Option<String>,
1002 topic: Option<String>,
1003 limit: Option<usize>,
1004}
1005
1006#[derive(Deserialize)]
1007struct ResolveRepoQuery {
1008 cwd: String,
1009}
1010
1011async fn list_rooms(
1013 principal: AuthPrincipal,
1014 Extension(store): Extension<Arc<dyn Store>>,
1015 Query(q): Query<RoomsQuery>,
1016) -> Result<Json<Vec<Room>>, ApiError> {
1017 if !principal.0.may(MEMORY_READ) {
1018 return Err(ApiError::Forbidden);
1019 }
1020 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1021 let limit = q.limit.unwrap_or(50).min(500);
1022 let rooms = store
1023 .list_rooms(&ns, q.project.as_deref(), limit)
1024 .await
1025 .map_err(internal)?;
1026 Ok(Json(rooms))
1027}
1028
1029async fn taxonomy(
1031 principal: AuthPrincipal,
1032 Extension(store): Extension<Arc<dyn Store>>,
1033 Query(q): Query<NamespaceQuery>,
1034) -> Result<Json<Vec<ProjectTaxon>>, ApiError> {
1035 if !principal.0.may(MEMORY_READ) {
1036 return Err(ApiError::Forbidden);
1037 }
1038 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1039 Ok(Json(store.taxonomy(&ns).await.map_err(internal)?))
1040}
1041
1042async fn palace_graph(
1044 principal: AuthPrincipal,
1045 Extension(store): Extension<Arc<dyn Store>>,
1046 Query(q): Query<NamespaceQuery>,
1047) -> Result<Json<PalaceGraph>, ApiError> {
1048 if !principal.0.may(MEMORY_READ) {
1049 return Err(ApiError::Forbidden);
1050 }
1051 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1052 Ok(Json(store.palace_graph(&ns).await.map_err(internal)?))
1053}
1054
1055async fn traverse_tunnel(
1057 principal: AuthPrincipal,
1058 Extension(store): Extension<Arc<dyn Store>>,
1059 Query(q): Query<TunnelQuery>,
1060) -> Result<Json<TunnelTraversal>, ApiError> {
1061 if !principal.0.may(MEMORY_READ) {
1062 return Err(ApiError::Forbidden);
1063 }
1064 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1065 let limit = q.limit.unwrap_or(50).min(500);
1066 Ok(Json(
1067 store
1068 .traverse_tunnel(&ns, &q.topic, &q.project_a, &q.project_b, limit)
1069 .await
1070 .map_err(internal)?,
1071 ))
1072}
1073
1074async fn write_diary(
1076 principal: AuthPrincipal,
1077 Extension(store): Extension<Arc<dyn Store>>,
1078 Json(entry): Json<DiaryEntry>,
1079) -> Result<StatusCode, ApiError> {
1080 if !principal.0.may(MEMORY_WRITE) {
1081 return Err(ApiError::Forbidden);
1082 }
1083 let ns = principal.0.personal_namespace();
1084 store.write_diary(&ns, entry).await.map_err(internal)?;
1085 Ok(StatusCode::NO_CONTENT)
1086}
1087
1088async fn read_diary(
1090 principal: AuthPrincipal,
1091 Extension(store): Extension<Arc<dyn Store>>,
1092 Path(agent): Path<String>,
1093 Query(q): Query<DiaryQuery>,
1094) -> Result<Json<Vec<DiaryEntry>>, ApiError> {
1095 if !principal.0.may(MEMORY_READ) {
1096 return Err(ApiError::Forbidden);
1097 }
1098 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1099 let limit = q.limit.unwrap_or(50).min(500);
1100 Ok(Json(
1101 store
1102 .read_diary(&ns, &agent, limit)
1103 .await
1104 .map_err(internal)?,
1105 ))
1106}
1107
1108async fn browse_memories(
1111 principal: AuthPrincipal,
1112 Extension(store): Extension<Arc<dyn Store>>,
1113 Query(q): Query<MemoryBrowseQuery>,
1114) -> Result<Json<Vec<Memory>>, ApiError> {
1115 if !principal.0.may(MEMORY_READ) {
1116 return Err(ApiError::Forbidden);
1117 }
1118 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1119 let limit = q.limit.unwrap_or(50).min(500);
1120 Ok(Json(
1121 store
1122 .list_memories_filtered(&ns, q.project.as_deref(), q.topic.as_deref(), limit)
1123 .await
1124 .map_err(internal)?,
1125 ))
1126}
1127
1128#[derive(Serialize)]
1129struct NamespaceStats {
1130 total: usize,
1131 projects: Vec<ProjectCount>,
1132}
1133
1134#[derive(Serialize)]
1135struct ProjectCount {
1136 project: String,
1137 count: usize,
1138}
1139
1140async fn memory_stats(
1143 principal: AuthPrincipal,
1144 Extension(store): Extension<Arc<dyn Store>>,
1145 Query(q): Query<NamespaceQuery>,
1146) -> Result<Json<NamespaceStats>, ApiError> {
1147 if !principal.0.may(MEMORY_READ) {
1148 return Err(ApiError::Forbidden);
1149 }
1150 let ns = resolve_ns(&principal, q.namespace.as_deref())?;
1151 let rooms = store.list_rooms(&ns, None, 1000).await.map_err(internal)?;
1152 let total: usize = rooms.iter().map(|r| r.count).sum();
1153 let mut by_project: std::collections::BTreeMap<String, usize> =
1154 std::collections::BTreeMap::new();
1155 for r in &rooms {
1156 *by_project.entry(r.project.clone()).or_default() += r.count;
1157 }
1158 let projects = by_project
1159 .into_iter()
1160 .map(|(project, count)| ProjectCount { project, count })
1161 .collect();
1162 Ok(Json(NamespaceStats { total, projects }))
1163}
1164
1165async fn register_repo(
1169 principal: AuthPrincipal,
1170 Extension(store): Extension<Arc<dyn Store>>,
1171 Json(repo): Json<RepoDirectory>,
1172) -> Result<StatusCode, ApiError> {
1173 if !principal.0.may(ADMIN) {
1174 return Err(ApiError::Forbidden);
1175 }
1176 store.register_repo(repo).await.map_err(internal)?;
1177 Ok(StatusCode::NO_CONTENT)
1178}
1179
1180async fn list_repos(
1182 principal: AuthPrincipal,
1183 Extension(store): Extension<Arc<dyn Store>>,
1184) -> Result<Json<Vec<RepoDirectory>>, ApiError> {
1185 if !principal.0.may(MEMORY_READ) {
1186 return Err(ApiError::Forbidden);
1187 }
1188 Ok(Json(store.list_repos().await.map_err(internal)?))
1189}
1190
1191async fn resolve_repo(
1193 principal: AuthPrincipal,
1194 Extension(store): Extension<Arc<dyn Store>>,
1195 Query(q): Query<ResolveRepoQuery>,
1196) -> Result<Json<RepoDirectory>, ApiError> {
1197 if !principal.0.may(MEMORY_READ) {
1198 return Err(ApiError::Forbidden);
1199 }
1200 match store.resolve_repo(&q.cwd).await.map_err(internal)? {
1201 Some(repo) => Ok(Json(repo)),
1202 None => Err(ApiError::NotFound),
1203 }
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::*;
1209 use crate::IjimaAuth;
1210 use axum::body::Body;
1211 use axum::http::{Request, StatusCode};
1212 use ijima_core::{harness::Harness, memory::MemorySource};
1213 use tower::ServiceExt;
1214
1215 async fn app_with_store() -> (Router, Arc<IjimaAuth>) {
1216 let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy"));
1217 let store_inner = Arc::new(crate::SurrealStore::open_embedded().await.expect("open"));
1218 let store: Arc<dyn Store> = store_inner.clone();
1219 let kg: Arc<dyn KnowledgeGraph> = store_inner;
1220 (
1221 app(
1222 auth.clone(),
1223 store,
1224 kg,
1225 None,
1226 Arc::new(crate::redaction::Redactor::new()),
1227 #[cfg(feature = "rate-limit")]
1228 None,
1229 ),
1230 auth,
1231 )
1232 }
1233
1234 fn bearer(auth: &IjimaAuth, principal: &str, cap: &str) -> String {
1235 format!(
1236 "Bearer {}",
1237 auth.issue_bearer(principal, cap).expect("issue")
1238 )
1239 }
1240
1241 fn sample_memory_json(id: &str) -> String {
1242 serde_json::json!({
1243 "id": id,
1244 "content": "decided to wire the daemon",
1245 "project": "ijima",
1246 "topic": "api",
1247 "source": "Explicit",
1248 "harness": "Pi",
1249 "session_id": "sess_1",
1250 "importance": 0.5,
1251 "created_at": "0",
1252 })
1253 .to_string()
1254 }
1255
1256 #[tokio::test]
1257 async fn health_is_public() {
1258 let (app, _) = app_with_store().await;
1259 let res = app
1260 .oneshot(
1261 Request::builder()
1262 .uri("/health")
1263 .body(Body::empty())
1264 .unwrap(),
1265 )
1266 .await
1267 .unwrap();
1268 assert_eq!(res.status(), StatusCode::OK);
1269 }
1270
1271 #[tokio::test]
1272 async fn recall_without_auth_is_401() {
1273 let (app, _) = app_with_store().await;
1274 let res = app
1275 .oneshot(
1276 Request::builder()
1277 .uri("/memories/mem_1")
1278 .body(Body::empty())
1279 .unwrap(),
1280 )
1281 .await
1282 .unwrap();
1283 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
1284 }
1285
1286 #[tokio::test]
1287 async fn store_then_recall_round_trips() {
1288 let (app, auth) = app_with_store().await;
1289 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1290 let read = bearer(&auth, "elliott", MEMORY_READ);
1291
1292 let res = app
1294 .clone()
1295 .oneshot(
1296 Request::builder()
1297 .method("POST")
1298 .uri("/memories")
1299 .header("authorization", &write)
1300 .header("content-type", "application/json")
1301 .body(Body::from(sample_memory_json("mem_1")))
1302 .unwrap(),
1303 )
1304 .await
1305 .unwrap();
1306 assert_eq!(res.status(), StatusCode::OK);
1307
1308 let res = app
1310 .oneshot(
1311 Request::builder()
1312 .uri("/memories/mem_1")
1313 .header("authorization", &read)
1314 .body(Body::empty())
1315 .unwrap(),
1316 )
1317 .await
1318 .unwrap();
1319 assert_eq!(res.status(), StatusCode::OK);
1320 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
1321 .await
1322 .unwrap();
1323 let mem: Memory = serde_json::from_slice(&body).unwrap();
1324 assert_eq!(mem.content, "decided to wire the daemon");
1325 assert_eq!(mem.harness, Harness::Pi);
1326 assert_eq!(mem.source, MemorySource::Explicit);
1327 }
1328
1329 #[tokio::test]
1330 async fn store_with_read_only_token_is_403() {
1331 let (app, auth) = app_with_store().await;
1332 let read = bearer(&auth, "elliott", MEMORY_READ);
1333 let res = app
1334 .oneshot(
1335 Request::builder()
1336 .method("POST")
1337 .uri("/memories")
1338 .header("authorization", &read)
1339 .header("content-type", "application/json")
1340 .body(Body::from(sample_memory_json("mem_x")))
1341 .unwrap(),
1342 )
1343 .await
1344 .unwrap();
1345 assert_eq!(res.status(), StatusCode::FORBIDDEN);
1346 }
1347
1348 #[tokio::test]
1349 async fn namespace_isolation_across_principals() {
1350 let (app, auth) = app_with_store().await;
1351 let alice_write = bearer(&auth, "alice", MEMORY_WRITE);
1353 let _ = app
1354 .clone()
1355 .oneshot(
1356 Request::builder()
1357 .method("POST")
1358 .uri("/memories")
1359 .header("authorization", &alice_write)
1360 .header("content-type", "application/json")
1361 .body(Body::from(sample_memory_json("mem_a")))
1362 .unwrap(),
1363 )
1364 .await
1365 .unwrap();
1366 let bob_read = bearer(&auth, "bob", MEMORY_READ);
1368 let res = app
1369 .oneshot(
1370 Request::builder()
1371 .uri("/memories/mem_a")
1372 .header("authorization", &bob_read)
1373 .body(Body::empty())
1374 .unwrap(),
1375 )
1376 .await
1377 .unwrap();
1378 assert_eq!(res.status(), StatusCode::NOT_FOUND);
1379 }
1380
1381 #[tokio::test]
1382 async fn promote_redacts_secrets_and_leaves_original_intact() {
1383 let (app, auth) = app_with_store().await;
1384 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1385 let read = bearer(&auth, "elliott", MEMORY_READ);
1386 let promote = bearer(&auth, "elliott", TRUST_PROMOTE);
1387
1388 let body = serde_json::json!({
1390 "id": "mem_secret",
1391 "content": "deploy key sk-abcdefghijklmnopqrstuvwxyz1234567890 contact ops@test.com",
1392 "project": "ijima",
1393 "topic": "ops",
1394 "source": "Explicit",
1395 "harness": "Pi",
1396 })
1397 .to_string();
1398 let res = app
1399 .clone()
1400 .oneshot(
1401 Request::builder()
1402 .method("POST")
1403 .uri("/memories")
1404 .header("authorization", &write)
1405 .header("content-type", "application/json")
1406 .body(Body::from(body))
1407 .unwrap(),
1408 )
1409 .await
1410 .unwrap();
1411 assert_eq!(res.status(), StatusCode::OK);
1412
1413 let promote_body = serde_json::json!({
1415 "target_namespace": "ns_team_shared",
1416 })
1417 .to_string();
1418 let res = app
1419 .clone()
1420 .oneshot(
1421 Request::builder()
1422 .method("POST")
1423 .uri("/memories/mem_secret/promote")
1424 .header("authorization", &promote)
1425 .header("content-type", "application/json")
1426 .body(Body::from(promote_body))
1427 .unwrap(),
1428 )
1429 .await
1430 .unwrap();
1431 assert_eq!(res.status(), StatusCode::OK);
1432 let resp: serde_json::Value = serde_json::from_slice(
1433 &axum::body::to_bytes(res.into_body(), usize::MAX)
1434 .await
1435 .unwrap(),
1436 )
1437 .unwrap();
1438 let new_id = resp["id"].as_str().unwrap();
1439 assert_eq!(new_id, "mem_secret__shared");
1440 let cats: Vec<&str> = resp["redactions"]
1441 .as_array()
1442 .unwrap()
1443 .iter()
1444 .map(|r| r["category"].as_str().unwrap())
1445 .collect();
1446 assert!(cats.contains(&"api_key"));
1447 assert!(cats.contains(&"email"));
1448
1449 let res = app
1451 .clone()
1452 .oneshot(
1453 Request::builder()
1454 .uri("/memories/mem_secret")
1455 .header("authorization", &read)
1456 .body(Body::empty())
1457 .unwrap(),
1458 )
1459 .await
1460 .unwrap();
1461 let orig: Memory = serde_json::from_slice(
1462 &axum::body::to_bytes(res.into_body(), usize::MAX)
1463 .await
1464 .unwrap(),
1465 )
1466 .unwrap();
1467 assert!(orig.content.contains("sk-abcdef"));
1468 assert!(orig.content.contains("ops@test.com"));
1469
1470 let res = app
1473 .oneshot(
1474 Request::builder()
1475 .uri("/memories/mem_secret__shared?namespace=ns_team_shared")
1476 .header("authorization", &read)
1477 .body(Body::empty())
1478 .unwrap(),
1479 )
1480 .await
1481 .unwrap();
1482 assert_eq!(res.status(), StatusCode::OK);
1483 let shared: Memory = serde_json::from_slice(
1484 &axum::body::to_bytes(res.into_body(), usize::MAX)
1485 .await
1486 .unwrap(),
1487 )
1488 .unwrap();
1489 assert!(shared.content.contains("[REDACTED:api_key]"));
1490 assert!(shared.content.contains("[REDACTED:email]"));
1491 assert!(!shared.content.contains("sk-abcdef"));
1492 assert!(!shared.content.contains("ops@test.com"));
1493 assert_eq!(shared.session_id.as_deref(), Some("mem_secret"));
1495 }
1496
1497 #[tokio::test]
1498 async fn promote_requires_trust_promote_not_memory_write() {
1499 let (app, auth) = app_with_store().await;
1503 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1504 let body = serde_json::json!({
1505 "id": "mem_p",
1506 "content": "provenance tier test",
1507 "project": "ijima",
1508 "topic": "t",
1509 "source": "Explicit",
1510 "harness": "Pi",
1511 })
1512 .to_string();
1513 let res = app
1515 .clone()
1516 .oneshot(
1517 Request::builder()
1518 .method("POST")
1519 .uri("/memories")
1520 .header("authorization", &write)
1521 .header("content-type", "application/json")
1522 .body(Body::from(body))
1523 .unwrap(),
1524 )
1525 .await
1526 .unwrap();
1527 assert_eq!(res.status(), StatusCode::OK);
1528
1529 let promote_body = serde_json::json!({ "target_namespace": "ns_team_shared" }).to_string();
1531 let res = app
1532 .clone()
1533 .oneshot(
1534 Request::builder()
1535 .method("POST")
1536 .uri("/memories/mem_p/promote")
1537 .header("authorization", &write)
1538 .header("content-type", "application/json")
1539 .body(Body::from(promote_body))
1540 .unwrap(),
1541 )
1542 .await
1543 .unwrap();
1544 assert_eq!(res.status(), StatusCode::FORBIDDEN);
1545
1546 let promote = bearer(&auth, "elliott", TRUST_PROMOTE);
1548 let promote_body = serde_json::json!({ "target_namespace": "ns_team_shared" }).to_string();
1549 let res = app
1550 .oneshot(
1551 Request::builder()
1552 .method("POST")
1553 .uri("/memories/mem_p/promote")
1554 .header("authorization", &promote)
1555 .header("content-type", "application/json")
1556 .body(Body::from(promote_body))
1557 .unwrap(),
1558 )
1559 .await
1560 .unwrap();
1561 assert_eq!(res.status(), StatusCode::OK);
1562 }
1563
1564 #[tokio::test]
1565 async fn cross_principal_personal_namespace_is_forbidden() {
1566 let (app, auth) = app_with_store().await;
1567 let alice_write = bearer(&auth, "alice", MEMORY_WRITE);
1569 let _ = app
1570 .clone()
1571 .oneshot(
1572 Request::builder()
1573 .method("POST")
1574 .uri("/memories")
1575 .header("authorization", &alice_write)
1576 .header("content-type", "application/json")
1577 .body(Body::from(
1578 serde_json::json!({
1579 "id": "mem_a",
1580 "content": "alice only",
1581 "project": "x",
1582 "topic": "x",
1583 "source": "Explicit",
1584 "harness": "Pi",
1585 })
1586 .to_string(),
1587 ))
1588 .unwrap(),
1589 )
1590 .await
1591 .unwrap();
1592
1593 let bob_read = bearer(&auth, "bob", MEMORY_READ);
1595 let res = app
1596 .oneshot(
1597 Request::builder()
1598 .uri("/memories/mem_a?namespace=ns_alice_private")
1599 .header("authorization", &bob_read)
1600 .body(Body::empty())
1601 .unwrap(),
1602 )
1603 .await
1604 .unwrap();
1605 assert_eq!(res.status(), StatusCode::FORBIDDEN);
1606 }
1607
1608 #[tokio::test]
1609 async fn doctrine_ingest_requires_admin_and_is_readable_shared() {
1610 let (app, auth) = app_with_store().await;
1611 let admin = bearer(&auth, "ci", "admin");
1612 let read = bearer(&auth, "anyone", MEMORY_READ);
1613
1614 let res = app
1616 .clone()
1617 .oneshot(
1618 Request::builder()
1619 .method("POST")
1620 .uri("/doctrine")
1621 .header("authorization", &read)
1622 .header("content-type", "application/json")
1623 .body(Body::from(
1624 serde_json::json!({
1625 "id": "d1",
1626 "content": "doctrine body",
1627 "project": "ijima",
1628 "topic": "arch",
1629 })
1630 .to_string(),
1631 ))
1632 .unwrap(),
1633 )
1634 .await
1635 .unwrap();
1636 assert_eq!(res.status(), StatusCode::FORBIDDEN);
1637
1638 let res = app
1640 .clone()
1641 .oneshot(
1642 Request::builder()
1643 .method("POST")
1644 .uri("/doctrine")
1645 .header("authorization", &admin)
1646 .header("content-type", "application/json")
1647 .body(Body::from(
1648 serde_json::json!({
1649 "id": "d1",
1650 "content": "doctrine body",
1651 "project": "ijima",
1652 "topic": "arch",
1653 })
1654 .to_string(),
1655 ))
1656 .unwrap(),
1657 )
1658 .await
1659 .unwrap();
1660 assert_eq!(res.status(), StatusCode::OK);
1661
1662 let res = app
1664 .oneshot(
1665 Request::builder()
1666 .uri("/memories/d1?namespace=ns_doctrine")
1667 .header("authorization", &read)
1668 .body(Body::empty())
1669 .unwrap(),
1670 )
1671 .await
1672 .unwrap();
1673 assert_eq!(res.status(), StatusCode::OK);
1674 let mem: Memory = serde_json::from_slice(
1675 &axum::body::to_bytes(res.into_body(), usize::MAX)
1676 .await
1677 .unwrap(),
1678 )
1679 .unwrap();
1680 assert_eq!(mem.content, "doctrine body");
1681 assert_eq!(mem.source, ijima_core::memory::MemorySource::Doctrine);
1682 }
1683
1684 #[tokio::test]
1685 async fn wakeup_composes_personal_and_doctrine() {
1686 let (app, auth) = app_with_store().await;
1687 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1688 let admin = bearer(&auth, "ci", "admin");
1689 let read = bearer(&auth, "elliott", MEMORY_READ);
1690
1691 let _ = app
1693 .clone()
1694 .oneshot(
1695 Request::builder()
1696 .method("POST")
1697 .uri("/memories")
1698 .header("authorization", &write)
1699 .header("content-type", "application/json")
1700 .body(Body::from(
1701 serde_json::json!({
1702 "id": "mem_p",
1703 "content": "personal essential",
1704 "project": "ijima",
1705 "topic": "x",
1706 "source": "Explicit",
1707 "harness": "Pi",
1708 })
1709 .to_string(),
1710 ))
1711 .unwrap(),
1712 )
1713 .await
1714 .unwrap();
1715
1716 let _ = app
1718 .clone()
1719 .oneshot(
1720 Request::builder()
1721 .method("POST")
1722 .uri("/doctrine")
1723 .header("authorization", &admin)
1724 .header("content-type", "application/json")
1725 .body(Body::from(
1726 serde_json::json!({
1727 "id": "doc_1",
1728 "content": "doctrine baseline",
1729 "project": "ijima",
1730 "topic": "arch",
1731 })
1732 .to_string(),
1733 ))
1734 .unwrap(),
1735 )
1736 .await
1737 .unwrap();
1738
1739 let res = app
1741 .oneshot(
1742 Request::builder()
1743 .uri("/wakeup")
1744 .header("authorization", &read)
1745 .body(Body::empty())
1746 .unwrap(),
1747 )
1748 .await
1749 .unwrap();
1750 assert_eq!(res.status(), StatusCode::OK);
1751 let body: serde_json::Value = serde_json::from_slice(
1752 &axum::body::to_bytes(res.into_body(), usize::MAX)
1753 .await
1754 .unwrap(),
1755 )
1756 .unwrap();
1757 assert_eq!(body["identity"]["principal"], "elliott");
1758 assert_eq!(body["personal_essentials"].as_array().unwrap().len(), 1);
1759 assert_eq!(
1760 body["personal_essentials"][0]["content"],
1761 "personal essential"
1762 );
1763 assert_eq!(body["doctrine"].as_array().unwrap().len(), 1);
1764 assert_eq!(body["doctrine"][0]["content"], "doctrine baseline");
1765 assert_eq!(body["doctrine"][0]["source"], "Doctrine");
1766 }
1767
1768 #[tokio::test]
1769 async fn knowledge_graph_add_query_invalidate() {
1770 let (app, auth) = app_with_store().await;
1771 let write = bearer(&auth, "elliott", "knowledge:write");
1772 let read = bearer(&auth, "elliott", "knowledge:read");
1773
1774 let res = app
1776 .clone()
1777 .oneshot(
1778 Request::builder()
1779 .method("POST")
1780 .uri("/kg/triples")
1781 .header("authorization", &write)
1782 .header("content-type", "application/json")
1783 .body(Body::from(
1784 serde_json::json!({
1785 "subject": "Ijima",
1786 "predicate": "depends_on",
1787 "object": "SurrealDB",
1788 "confidence": 1.0,
1789 })
1790 .to_string(),
1791 ))
1792 .unwrap(),
1793 )
1794 .await
1795 .unwrap();
1796 assert_eq!(res.status(), StatusCode::OK);
1797
1798 let res = app
1800 .clone()
1801 .oneshot(
1802 Request::builder()
1803 .uri("/kg/entities/Ijima")
1804 .header("authorization", &read)
1805 .body(Body::empty())
1806 .unwrap(),
1807 )
1808 .await
1809 .unwrap();
1810 assert_eq!(res.status(), StatusCode::OK);
1811 let body: serde_json::Value = serde_json::from_slice(
1812 &axum::body::to_bytes(res.into_body(), usize::MAX)
1813 .await
1814 .unwrap(),
1815 )
1816 .unwrap();
1817 assert_eq!(body["outgoing"].as_array().unwrap().len(), 1);
1818 assert_eq!(body["outgoing"][0]["object"], "SurrealDB");
1819 assert!(body["incoming"].as_array().unwrap().is_empty());
1820
1821 let res = app
1823 .clone()
1824 .oneshot(
1825 Request::builder()
1826 .uri("/kg/stats")
1827 .header("authorization", &read)
1828 .body(Body::empty())
1829 .unwrap(),
1830 )
1831 .await
1832 .unwrap();
1833 let body: serde_json::Value = serde_json::from_slice(
1834 &axum::body::to_bytes(res.into_body(), usize::MAX)
1835 .await
1836 .unwrap(),
1837 )
1838 .unwrap();
1839 assert_eq!(body["entities"], 2);
1840 assert_eq!(body["triples"], 1);
1841
1842 let res = app
1844 .oneshot(
1845 Request::builder()
1846 .method("POST")
1847 .uri("/kg/triples/Ijima:depends_on:SurrealDB/invalidate")
1848 .header("authorization", &write)
1849 .body(Body::empty())
1850 .unwrap(),
1851 )
1852 .await
1853 .unwrap();
1854 assert_eq!(res.status(), StatusCode::NO_CONTENT);
1855 }
1856
1857 #[tokio::test]
1858 async fn status_requires_admin_and_reports_counts() {
1859 let (app, auth) = app_with_store().await;
1860 let admin = bearer(&auth, "op", "admin");
1861 let read = bearer(&auth, "user", MEMORY_READ);
1862
1863 let _ = app
1865 .clone()
1866 .oneshot(
1867 Request::builder()
1868 .method("POST")
1869 .uri("/memories")
1870 .header("authorization", &admin)
1871 .header("content-type", "application/json")
1872 .body(Body::from(
1873 serde_json::json!({
1874 "id": "m1",
1875 "content": "stat test",
1876 "project": "x",
1877 "topic": "x",
1878 "source": "Explicit",
1879 "harness": "Pi",
1880 })
1881 .to_string(),
1882 ))
1883 .unwrap(),
1884 )
1885 .await
1886 .unwrap();
1887
1888 let res = app
1890 .clone()
1891 .oneshot(
1892 Request::builder()
1893 .uri("/status")
1894 .header("authorization", &read)
1895 .body(Body::empty())
1896 .unwrap(),
1897 )
1898 .await
1899 .unwrap();
1900 assert_eq!(res.status(), StatusCode::FORBIDDEN);
1901
1902 let res = app
1904 .oneshot(
1905 Request::builder()
1906 .uri("/status")
1907 .header("authorization", &admin)
1908 .body(Body::empty())
1909 .unwrap(),
1910 )
1911 .await
1912 .unwrap();
1913 assert_eq!(res.status(), StatusCode::OK);
1914 let body: serde_json::Value = serde_json::from_slice(
1915 &axum::body::to_bytes(res.into_body(), usize::MAX)
1916 .await
1917 .unwrap(),
1918 )
1919 .unwrap();
1920 assert_eq!(body["memories"], 1);
1921 assert!(!body["namespaces"].as_array().unwrap().is_empty());
1922 }
1923
1924 #[tokio::test]
1925 async fn sessions_create_list_end_via_http() {
1926 let (app, auth) = app_with_store().await;
1927 let ingest = bearer(&auth, "op", SESSION_INGEST);
1928 let read = bearer(&auth, "op", MEMORY_READ);
1929
1930 for (id, harness) in [("sess_a", "Pi"), ("sess_b", "Sakamoto")] {
1932 let res = app
1933 .clone()
1934 .oneshot(
1935 Request::builder()
1936 .method("POST")
1937 .uri("/sessions")
1938 .header("authorization", &ingest)
1939 .header("content-type", "application/json")
1940 .body(Body::from(
1941 serde_json::json!({
1942 "id": id,
1943 "harness": harness,
1944 "channel": "thread-1",
1945 "started_at": "2026-07-05T10:00:00Z",
1946 })
1947 .to_string(),
1948 ))
1949 .unwrap(),
1950 )
1951 .await
1952 .unwrap();
1953 assert_eq!(res.status(), StatusCode::OK);
1954 }
1955
1956 let res = app
1958 .clone()
1959 .oneshot(
1960 Request::builder()
1961 .uri("/sessions")
1962 .header("authorization", &read)
1963 .body(Body::empty())
1964 .unwrap(),
1965 )
1966 .await
1967 .unwrap();
1968 assert_eq!(res.status(), StatusCode::OK);
1969 let body: serde_json::Value = serde_json::from_slice(
1970 &axum::body::to_bytes(res.into_body(), usize::MAX)
1971 .await
1972 .unwrap(),
1973 )
1974 .unwrap();
1975 let arr = body.as_array().unwrap();
1976 assert_eq!(arr.len(), 2);
1977
1978 let res = app
1980 .clone()
1981 .oneshot(
1982 Request::builder()
1983 .uri("/sessions?harness=pi")
1984 .header("authorization", &read)
1985 .body(Body::empty())
1986 .unwrap(),
1987 )
1988 .await
1989 .unwrap();
1990 let body: serde_json::Value = serde_json::from_slice(
1991 &axum::body::to_bytes(res.into_body(), usize::MAX)
1992 .await
1993 .unwrap(),
1994 )
1995 .unwrap();
1996 assert_eq!(body.as_array().unwrap().len(), 1);
1997 assert_eq!(body[0]["harness"], "Pi");
1998
1999 let res = app
2001 .clone()
2002 .oneshot(
2003 Request::builder()
2004 .method("POST")
2005 .uri("/sessions/sess_a/end")
2006 .header("authorization", &ingest)
2007 .header("content-type", "application/json")
2008 .body(Body::from(
2009 serde_json::json!({ "ended_at": "2026-07-05T11:00:00Z" }).to_string(),
2010 ))
2011 .unwrap(),
2012 )
2013 .await
2014 .unwrap();
2015 assert_eq!(res.status(), StatusCode::NO_CONTENT);
2016
2017 let res = app
2019 .oneshot(
2020 Request::builder()
2021 .uri("/sessions?harness=pi")
2022 .header("authorization", &read)
2023 .body(Body::empty())
2024 .unwrap(),
2025 )
2026 .await
2027 .unwrap();
2028 let body: serde_json::Value = serde_json::from_slice(
2029 &axum::body::to_bytes(res.into_body(), usize::MAX)
2030 .await
2031 .unwrap(),
2032 )
2033 .unwrap();
2034 assert_eq!(body[0]["ended_at"], "2026-07-05T11:00:00Z");
2035 }
2036
2037 #[tokio::test]
2038 async fn mining_queue_requires_review_capability() {
2039 let (app, auth) = app_with_store().await;
2040 let reviewer = bearer(&auth, "op", MINING_REVIEW);
2041 let reader = bearer(&auth, "op", MEMORY_READ);
2042
2043 let res = app
2045 .clone()
2046 .oneshot(
2047 Request::builder()
2048 .uri("/mining/queue")
2049 .header("authorization", &reader)
2050 .body(Body::empty())
2051 .unwrap(),
2052 )
2053 .await
2054 .unwrap();
2055 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2056
2057 let res = app
2059 .oneshot(
2060 Request::builder()
2061 .uri("/mining/queue")
2062 .header("authorization", &reviewer)
2063 .body(Body::empty())
2064 .unwrap(),
2065 )
2066 .await
2067 .unwrap();
2068 assert_eq!(res.status(), StatusCode::OK);
2069 let body: serde_json::Value = serde_json::from_slice(
2070 &axum::body::to_bytes(res.into_body(), usize::MAX)
2071 .await
2072 .unwrap(),
2073 )
2074 .unwrap();
2075 assert!(body.as_array().unwrap().is_empty());
2076 }
2077
2078 fn hit_mem(id: &str, sim: f32) -> SearchHit {
2079 SearchHit {
2080 memory: Memory {
2081 id: MemoryId(id.into()),
2082 content: id.into(),
2083 project: "p".into(),
2084 topic: "t".into(),
2085 source: ijima_core::MemorySource::Explicit,
2086 harness: ijima_core::harness::Harness::Pi,
2087 session_id: None,
2088 origin: ijima_core::InstanceId::local(),
2089 authority: ijima_core::AuthorityScope::local(),
2090 importance: 0.5,
2091 created_at: "0".into(),
2092 },
2093 similarity: sim,
2094 }
2095 }
2096
2097 #[test]
2098 fn merge_search_hits_ranks_desc_dedups_and_truncates() {
2099 let a = vec![hit_mem("a", 0.9), hit_mem("b", 0.5)];
2102 let b = vec![hit_mem("c", 0.8), hit_mem("a", 0.7)]; let merged = merge_search_hits(a, b, 3);
2104 assert_eq!(merged.len(), 3);
2107 assert_eq!(merged[0].memory.id.0, "a");
2108 assert_eq!((merged[0].similarity * 10.0).round() as i32, 9);
2109 assert_eq!(merged[1].memory.id.0, "c");
2110 assert_eq!(merged[2].memory.id.0, "b");
2111 }
2112
2113 #[test]
2114 fn merge_search_hits_respects_limit() {
2115 let a = vec![hit_mem("a", 0.9), hit_mem("b", 0.8)];
2116 let b = vec![hit_mem("c", 0.7), hit_mem("d", 0.6)];
2117 let merged = merge_search_hits(a, b, 2);
2118 assert_eq!(merged.len(), 2);
2119 assert_eq!(merged[0].memory.id.0, "a");
2120 assert_eq!(merged[1].memory.id.0, "b");
2121 }
2122
2123 #[cfg(feature = "mining")]
2124 #[tokio::test]
2125 async fn trigger_requires_mining_trigger_capability() {
2126 let (app, auth) = app_with_store().await;
2127 let write = bearer(&auth, "elliott", MEMORY_WRITE);
2129 let res = app
2130 .oneshot(
2131 Request::builder()
2132 .method("POST")
2133 .uri("/sessions/sess_x/mine")
2134 .header("authorization", &write)
2135 .body(Body::empty())
2136 .unwrap(),
2137 )
2138 .await
2139 .unwrap();
2140 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2141 }
2142
2143 #[cfg(feature = "mining")]
2144 #[tokio::test]
2145 async fn trigger_mines_decision_and_archives() {
2146 let (app, auth) = app_with_store().await;
2150 let ingest = bearer(&auth, "elliott", SESSION_INGEST);
2151 let trigger = bearer(&auth, "elliott", MINING_TRIGGER);
2152
2153 let turn = serde_json::json!({
2155 "session_id": "sess_mine",
2156 "turn_index": 0,
2157 "role": "User",
2158 "content": "We decided to use SurrealDB for storage.",
2159 "timestamp": "0",
2160 });
2161 let res = app
2162 .clone()
2163 .oneshot(
2164 Request::builder()
2165 .method("POST")
2166 .uri("/sessions/sess_mine/turns")
2167 .header("authorization", &ingest)
2168 .header("content-type", "application/json")
2169 .body(Body::from(turn.to_string()))
2170 .unwrap(),
2171 )
2172 .await
2173 .unwrap();
2174 assert_eq!(res.status(), StatusCode::NO_CONTENT);
2175
2176 let res = app
2178 .oneshot(
2179 Request::builder()
2180 .method("POST")
2181 .uri("/sessions/sess_mine/mine")
2182 .header("authorization", &trigger)
2183 .body(Body::empty())
2184 .unwrap(),
2185 )
2186 .await
2187 .unwrap();
2188 assert_eq!(res.status(), StatusCode::OK);
2189 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2190 .await
2191 .unwrap();
2192 let report: crate::mining_pipeline::MiningReport = serde_json::from_slice(&body).unwrap();
2193 assert!(
2194 report.archived >= 1,
2195 "rules tier should archive the decision: {report:?}"
2196 );
2197 }
2198
2199 async fn body_json(res: axum::response::Response) -> serde_json::Value {
2202 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2203 .await
2204 .unwrap();
2205 serde_json::from_slice(&body).unwrap()
2206 }
2207
2208 async fn seed_memory(app: &Router, auth: &IjimaAuth, id: &str, project: &str, topic: &str) {
2209 let body = serde_json::json!({
2210 "id": id,
2211 "content": format!("{project}/{topic} note"),
2212 "project": project,
2213 "topic": topic,
2214 "source": "Explicit",
2215 "harness": "Pi",
2216 "session_id": "sess_1",
2217 "importance": 0.5,
2218 "created_at": "0",
2219 })
2220 .to_string();
2221 let res = app
2222 .clone()
2223 .oneshot(
2224 Request::builder()
2225 .method("POST")
2226 .uri("/memories")
2227 .header("authorization", bearer(auth, "elliott", MEMORY_WRITE))
2228 .header("content-type", "application/json")
2229 .body(Body::from(body))
2230 .unwrap(),
2231 )
2232 .await
2233 .unwrap();
2234 assert_eq!(res.status(), StatusCode::OK, "seed {id} failed");
2235 }
2236
2237 #[tokio::test]
2238 async fn rooms_taxonomy_stats_reflect_seeded_memories() {
2239 let (app, auth) = app_with_store().await;
2240 seed_memory(&app, &auth, "mem_a", "ijima", "api").await;
2241 seed_memory(&app, &auth, "mem_b", "ijima", "auth").await;
2242 let read = bearer(&auth, "elliott", MEMORY_READ);
2243
2244 let res = app
2246 .clone()
2247 .oneshot(
2248 Request::builder()
2249 .uri("/rooms")
2250 .header("authorization", &read)
2251 .body(Body::empty())
2252 .unwrap(),
2253 )
2254 .await
2255 .unwrap();
2256 assert_eq!(res.status(), StatusCode::OK);
2257 let rooms = body_json(res).await;
2258 let topics: std::collections::HashSet<&str> = rooms
2259 .as_array()
2260 .unwrap()
2261 .iter()
2262 .map(|r| r["topic"].as_str().unwrap())
2263 .collect();
2264 assert!(
2265 topics.contains("api") && topics.contains("auth"),
2266 "rooms: {rooms}"
2267 );
2268
2269 let res = app
2271 .clone()
2272 .oneshot(
2273 Request::builder()
2274 .uri("/memories/stats")
2275 .header("authorization", &read)
2276 .body(Body::empty())
2277 .unwrap(),
2278 )
2279 .await
2280 .unwrap();
2281 assert_eq!(res.status(), StatusCode::OK);
2282 let stats = body_json(res).await;
2283 assert_eq!(stats["total"], 2, "stats: {stats}");
2284 assert_eq!(stats["projects"][0]["project"], "ijima");
2285 assert_eq!(stats["projects"][0]["count"], 2);
2286 }
2287
2288 #[tokio::test]
2289 async fn browse_memories_filters_by_project() {
2290 let (app, auth) = app_with_store().await;
2291 seed_memory(&app, &auth, "mem_a", "ijima", "api").await;
2292 seed_memory(&app, &auth, "mem_b", "possum", "efficiency").await;
2293 let read = bearer(&auth, "elliott", MEMORY_READ);
2294
2295 let res = app
2296 .clone()
2297 .oneshot(
2298 Request::builder()
2299 .uri("/memories?project=possum")
2300 .header("authorization", &read)
2301 .body(Body::empty())
2302 .unwrap(),
2303 )
2304 .await
2305 .unwrap();
2306 assert_eq!(res.status(), StatusCode::OK);
2307 let mems = body_json(res).await;
2308 let arr = mems.as_array().unwrap();
2309 assert_eq!(arr.len(), 1);
2310 assert_eq!(arr[0]["project"], "possum");
2311 }
2312
2313 #[tokio::test]
2314 async fn palace_graph_and_tunnel_link_shared_topic() {
2315 let (app, auth) = app_with_store().await;
2316 seed_memory(&app, &auth, "mem_a", "ijima", "efficiency").await;
2317 seed_memory(&app, &auth, "mem_b", "possum", "efficiency").await;
2318 let read = bearer(&auth, "elliott", MEMORY_READ);
2319
2320 let res = app
2321 .clone()
2322 .oneshot(
2323 Request::builder()
2324 .uri("/palace/graph")
2325 .header("authorization", &read)
2326 .body(Body::empty())
2327 .unwrap(),
2328 )
2329 .await
2330 .unwrap();
2331 assert_eq!(res.status(), StatusCode::OK);
2332 let graph = body_json(res).await;
2333 let projects: std::collections::HashSet<&str> = graph["projects"]
2334 .as_array()
2335 .unwrap()
2336 .iter()
2337 .map(|p| p.as_str().unwrap())
2338 .collect();
2339 assert!(
2340 projects.contains("ijima") && projects.contains("possum"),
2341 "graph: {graph}"
2342 );
2343
2344 let res = app
2345 .clone()
2346 .oneshot(
2347 Request::builder()
2348 .uri("/palace/tunnel?topic=efficiency&project_a=ijima&project_b=possum")
2349 .header("authorization", &read)
2350 .body(Body::empty())
2351 .unwrap(),
2352 )
2353 .await
2354 .unwrap();
2355 assert_eq!(res.status(), StatusCode::OK);
2356 let trav = body_json(res).await;
2357 assert_eq!(trav["memories_a"].as_array().unwrap().len(), 1);
2358 assert_eq!(trav["memories_b"].as_array().unwrap().len(), 1);
2359 }
2360
2361 #[tokio::test]
2362 async fn diary_write_then_read_round_trips() {
2363 let (app, auth) = app_with_store().await;
2364 let write = bearer(&auth, "elliott", MEMORY_WRITE);
2365 let read = bearer(&auth, "elliott", MEMORY_READ);
2366
2367 let body = serde_json::json!({
2368 "agent": "pi",
2369 "content": "shipped the routes",
2370 "topic": "ijima",
2371 "timestamp": "2026-08-09T12:00:00Z"
2372 })
2373 .to_string();
2374 let res = app
2375 .clone()
2376 .oneshot(
2377 Request::builder()
2378 .method("POST")
2379 .uri("/diaries")
2380 .header("authorization", &write)
2381 .header("content-type", "application/json")
2382 .body(Body::from(body))
2383 .unwrap(),
2384 )
2385 .await
2386 .unwrap();
2387 assert_eq!(res.status(), StatusCode::NO_CONTENT);
2388
2389 let res = app
2390 .clone()
2391 .oneshot(
2392 Request::builder()
2393 .uri("/diaries/pi")
2394 .header("authorization", &read)
2395 .body(Body::empty())
2396 .unwrap(),
2397 )
2398 .await
2399 .unwrap();
2400 assert_eq!(res.status(), StatusCode::OK);
2401 let entries = body_json(res).await;
2402 let arr = entries.as_array().unwrap();
2403 assert_eq!(arr.len(), 1);
2404 assert_eq!(arr[0]["content"], "shipped the routes");
2405 }
2406
2407 #[tokio::test]
2408 async fn diary_write_requires_memory_write_not_read() {
2409 let (app, auth) = app_with_store().await;
2410 let read = bearer(&auth, "elliott", MEMORY_READ);
2411 let body = serde_json::json!({"agent": "pi", "content": "x", "timestamp": "t"}).to_string();
2412 let res = app
2413 .clone()
2414 .oneshot(
2415 Request::builder()
2416 .method("POST")
2417 .uri("/diaries")
2418 .header("authorization", &read)
2419 .header("content-type", "application/json")
2420 .body(Body::from(body))
2421 .unwrap(),
2422 )
2423 .await
2424 .unwrap();
2425 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2426 }
2427
2428 #[tokio::test]
2429 async fn repo_register_list_resolve_round_trips() {
2430 let (app, auth) = app_with_store().await;
2431 let admin = bearer(&auth, "elliott", ADMIN);
2432 let read = bearer(&auth, "elliott", MEMORY_READ);
2433
2434 let body = serde_json::json!({
2436 "name": "Ijima",
2437 "path": "/home/x/Ijima",
2438 "remote_url": "git@github.com:Industrial-Algebra/Ijima.git",
2439 "role": "memory-service"
2440 })
2441 .to_string();
2442 let res = app
2443 .clone()
2444 .oneshot(
2445 Request::builder()
2446 .method("POST")
2447 .uri("/repos")
2448 .header("authorization", &admin)
2449 .header("content-type", "application/json")
2450 .body(Body::from(body))
2451 .unwrap(),
2452 )
2453 .await
2454 .unwrap();
2455 assert_eq!(res.status(), StatusCode::NO_CONTENT);
2456
2457 let res = app
2459 .clone()
2460 .oneshot(
2461 Request::builder()
2462 .uri("/repos")
2463 .header("authorization", &read)
2464 .body(Body::empty())
2465 .unwrap(),
2466 )
2467 .await
2468 .unwrap();
2469 assert_eq!(res.status(), StatusCode::OK);
2470 let repos = body_json(res).await;
2471 assert_eq!(repos[0]["name"], "Ijima");
2472 assert_eq!(repos[0]["path"], "/home/x/Ijima");
2473
2474 let res = app
2476 .clone()
2477 .oneshot(
2478 Request::builder()
2479 .uri("/repos/resolve?cwd=/home/x/Ijima/src")
2480 .header("authorization", &read)
2481 .body(Body::empty())
2482 .unwrap(),
2483 )
2484 .await
2485 .unwrap();
2486 assert_eq!(res.status(), StatusCode::OK);
2487 let repo = body_json(res).await;
2488 assert_eq!(repo["name"], "Ijima");
2489
2490 let res = app
2492 .clone()
2493 .oneshot(
2494 Request::builder()
2495 .uri("/repos/resolve?cwd=/nowhere/here")
2496 .header("authorization", &read)
2497 .body(Body::empty())
2498 .unwrap(),
2499 )
2500 .await
2501 .unwrap();
2502 assert_eq!(res.status(), StatusCode::NOT_FOUND);
2503 }
2504
2505 #[tokio::test]
2506 async fn repo_register_requires_admin() {
2507 let (app, auth) = app_with_store().await;
2508 let read = bearer(&auth, "elliott", MEMORY_READ);
2509 let body = serde_json::json!({
2510 "name": "X", "path": "/x", "remote_url": "u", "role": "r"
2511 })
2512 .to_string();
2513 let res = app
2514 .clone()
2515 .oneshot(
2516 Request::builder()
2517 .method("POST")
2518 .uri("/repos")
2519 .header("authorization", &read)
2520 .header("content-type", "application/json")
2521 .body(Body::from(body))
2522 .unwrap(),
2523 )
2524 .await
2525 .unwrap();
2526 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2527 }
2528}