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, TokenRevocation, TunnelTraversal,
48 capabilities::{
49 ADMIN, KNOWLEDGE_READ, MEMORY_READ, MEMORY_WRITE, MINING_REVIEW, SESSION_INGEST,
50 TRUST_PROMOTE,
51 },
52 harness::Harness,
53 memory::MemorySource,
54};
55
56use crate::extractor::AuthPrincipal;
57use crate::redaction::Redactor;
58
59#[cfg(feature = "federation")]
60use ijima_core::federation::{
61 AuthoritativeScope, ConflictSignal, FederationState, InstanceFederationConfig, RoutedWrite,
62 RoutedWriteReceipt,
63};
64
65pub fn app(
70 auth: Arc<crate::IjimaAuth>,
71 store: Arc<dyn Store>,
72 kg: Arc<dyn KnowledgeGraph>,
73 embedder: Option<Arc<dyn Embedder>>,
74 redactor: Arc<Redactor>,
75 #[cfg(feature = "rate-limit")] rate_limiter: Option<crate::rate_limit::RateLimitState>,
76 #[cfg(feature = "federation")] federation_config: Arc<InstanceFederationConfig>,
77) -> Router {
78 let router = Router::new()
79 .route("/health", get(health))
80 .route("/status", get(status))
81 .route("/memories", get(browse_memories).post(store_memory))
82 .route("/memories/check", post(check_duplicate))
83 .route("/memories/search", post(search_memories))
84 .route("/memories/stats", get(memory_stats))
85 .route("/memories/{id}", get(recall_memory).delete(delete_memory))
86 .route("/memories/{id}/promote", post(promote_memory))
87 .route("/rooms", get(list_rooms))
88 .route("/taxonomy", get(taxonomy))
89 .route("/palace/graph", get(palace_graph))
90 .route("/palace/tunnel", get(traverse_tunnel))
91 .route("/diaries", post(write_diary))
92 .route("/diaries/{agent}", get(read_diary))
93 .route("/repos", get(list_repos).post(register_repo))
94 .route("/repos/resolve", get(resolve_repo))
95 .route("/tokens/revoke", post(revoke_token_route))
96 .route("/tokens/revocations", get(list_token_revocations))
97 .route("/namespaces/grant", post(grant_ns_membership))
98 .route("/namespaces/revoke", post(revoke_ns_membership))
99 .route("/namespaces/members", get(list_ns_members))
100 .route("/doctrine", post(ingest_doctrine))
101 .route("/wakeup", get(wakeup))
102 .route("/kg/triples", post(add_triple).get(find_triples))
103 .route("/kg/entities/{id}", get(query_entity))
104 .route("/kg/triples/{id}/invalidate", post(invalidate_triple))
105 .route("/kg/timeline", get(kg_timeline))
106 .route("/kg/stats", get(kg_stats))
107 .route(
108 "/sessions/{session_id}/turns",
109 post(ingest_turn).get(session_turns),
110 )
111 .route("/sessions", post(create_session).get(list_sessions))
112 .route("/sessions/{session_id}/end", post(end_session))
113 .route("/mining/queue", get(list_pending))
114 .route("/mining/queue/{id}/accept", post(accept_extraction))
115 .route("/mining/queue/{id}/reject", post(reject_extraction));
116 #[cfg(feature = "mining")]
117 let router = router.route("/sessions/{session_id}/mine", post(trigger_mine));
118
119 #[cfg(feature = "federation")]
120 let router = router
121 .route("/federation/state", get(federation_state))
122 .route("/federation/routed-write", post(routed_write))
123 .route("/federation/conflict-signal", post(conflict_signal));
124 let router = router
125 .layer(Extension(auth))
126 .layer(Extension(store))
127 .layer(Extension(kg))
128 .layer(Extension(embedder))
129 .layer(Extension(redactor));
130
131 #[cfg(feature = "federation")]
132 let router = router.layer(Extension(federation_config));
133
134 #[cfg(feature = "rate-limit")]
135 let router = match rate_limiter {
136 Some(rl) => router.layer(Extension(rl)),
137 None => router,
138 };
139 #[cfg(not(feature = "rate-limit"))]
140 let router = router;
141
142 router
143}
144
145#[derive(Debug)]
149pub enum ApiError {
150 Forbidden,
152 NotFound,
154 BadRequest(String),
156 Conflict(String),
158 Internal(String),
160}
161
162impl IntoResponse for ApiError {
163 fn into_response(self) -> Response {
164 let (status, msg): (StatusCode, String) = match self {
165 ApiError::Forbidden => (StatusCode::FORBIDDEN, "forbidden".into()),
166 ApiError::NotFound => (StatusCode::NOT_FOUND, "not found".into()),
167 ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
168 ApiError::Conflict(m) => (StatusCode::CONFLICT, m),
169 ApiError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m),
170 };
171 (status, msg).into_response()
172 }
173}
174
175fn internal(e: ijima_core::IjimaError) -> ApiError {
176 match e {
177 ijima_core::IjimaError::Duplicate { detail } => ApiError::Conflict(detail),
178 other => ApiError::Internal(other.to_string()),
179 }
180}
181
182#[derive(Deserialize, Default)]
184struct NsQuery {
185 namespace: Option<String>,
189 limit: Option<usize>,
190}
191
192async fn resolve_ns(
204 principal: &AuthPrincipal,
205 store: &dyn Store,
206 requested: Option<&str>,
207) -> Result<ijima_core::NamespaceId, ApiError> {
208 let own = format!("ns_{}_private", principal.0.principal.as_str());
209 let requested = match requested {
210 None => return Ok(ijima_core::NamespaceId::new(own)),
211 Some(ns) => ns,
212 };
213 if requested == own {
214 return Ok(ijima_core::NamespaceId::new(requested));
215 }
216 if requested.ends_with("_private") {
217 return Err(ApiError::Forbidden);
218 }
219 let open = requested == "global"
220 || requested == ijima_core::namespace::DOCTRINE_NAMESPACE
221 || requested.starts_with("ns_import_");
222 if !open && !principal.0.may(ADMIN) {
223 let ns = ijima_core::NamespaceId::new(requested);
224 let member = store
225 .is_namespace_member(&ns, principal.0.principal.as_str())
226 .await
227 .map_err(internal)?;
228 if !member {
229 return Err(ApiError::Forbidden);
230 }
231 }
232 Ok(ijima_core::NamespaceId::new(requested))
233}
234
235async fn health() -> impl IntoResponse {
238 Json(serde_json::json!({ "status": "ok" }))
239}
240
241static STARTED_AT: std::sync::OnceLock<std::time::SystemTime> = std::sync::OnceLock::new();
244
245#[derive(Serialize)]
246struct StatusResponse {
247 memories: usize,
248 namespaces: Vec<NamespaceCount>,
249 entities: usize,
250 triples: usize,
251 version: &'static str,
253 started_at_unix: u64,
255 uptime_secs: u64,
257}
258
259async fn status(
263 principal: AuthPrincipal,
264 Extension(store): Extension<Arc<dyn Store>>,
265 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
266) -> Result<Json<StatusResponse>, ApiError> {
267 if !principal.0.may(ijima_core::capabilities::ADMIN) {
268 return Err(ApiError::Forbidden);
269 }
270 let store_stats = store.store_stats().await.map_err(internal)?;
271 let kg_stats = kg.kg_global_stats().await.map_err(internal)?;
272 let started = *STARTED_AT.get_or_init(std::time::SystemTime::now);
273 let uptime_secs = started.elapsed().map(|d| d.as_secs()).unwrap_or(0);
274 let started_at_unix = started
275 .duration_since(std::time::UNIX_EPOCH)
276 .map(|d| d.as_secs())
277 .unwrap_or(0);
278 Ok(Json(StatusResponse {
279 memories: store_stats.total_memories,
280 namespaces: store_stats.namespaces,
281 entities: kg_stats.entities,
282 triples: kg_stats.triples,
283 version: env!("CARGO_PKG_VERSION"),
284 started_at_unix,
285 uptime_secs,
286 }))
287}
288
289#[derive(Deserialize)]
293struct RevokeRequest {
294 token: String,
297 reason: Option<String>,
299}
300
301async fn revoke_token_route(
304 principal: AuthPrincipal,
305 Extension(store): Extension<Arc<dyn Store>>,
306 Extension(auth): Extension<Arc<crate::IjimaAuth>>,
307 Json(req): Json<RevokeRequest>,
308) -> Result<StatusCode, ApiError> {
309 if !principal.0.may(ADMIN) {
310 return Err(ApiError::Forbidden);
311 }
312 let revocation = TokenRevocation {
313 token_hash: crate::auth::bearer_hash(&req.token),
314 revoked_at_unix: std::time::SystemTime::now()
315 .duration_since(std::time::UNIX_EPOCH)
316 .map(|d| d.as_secs())
317 .unwrap_or(0),
318 reason: req.reason,
319 };
320 store
323 .revoke_token(revocation.clone())
324 .await
325 .map_err(internal)?;
326 auth.revoke(&revocation.token_hash);
327 Ok(StatusCode::NO_CONTENT)
328}
329
330#[derive(Deserialize)]
333struct NsMembershipRequest {
334 namespace: String,
336 principal: String,
338}
339
340async fn grant_ns_membership(
343 principal: AuthPrincipal,
344 Extension(store): Extension<Arc<dyn Store>>,
345 Json(req): Json<NsMembershipRequest>,
346) -> Result<Json<serde_json::Value>, ApiError> {
347 if !principal.0.may(ADMIN) {
348 return Err(ApiError::Forbidden);
349 }
350 let membership = ijima_core::NamespaceMembership {
351 namespace: req.namespace.clone(),
352 principal: req.principal.clone(),
353 granted_at_unix: std::time::SystemTime::now()
354 .duration_since(std::time::UNIX_EPOCH)
355 .map(|d| d.as_secs())
356 .unwrap_or(0),
357 granted_by: principal.0.principal.as_str().to_string(),
358 };
359 store
360 .grant_namespace_membership(membership)
361 .await
362 .map_err(internal)?;
363 Ok(Json(
364 serde_json::json!({ "granted": true, "namespace": req.namespace, "principal": req.principal }),
365 ))
366}
367
368async fn revoke_ns_membership(
370 principal: AuthPrincipal,
371 Extension(store): Extension<Arc<dyn Store>>,
372 Json(req): Json<NsMembershipRequest>,
373) -> Result<StatusCode, ApiError> {
374 if !principal.0.may(ADMIN) {
375 return Err(ApiError::Forbidden);
376 }
377 store
378 .revoke_namespace_membership(&NamespaceId::new(&req.namespace), &req.principal)
379 .await
380 .map_err(internal)?;
381 Ok(StatusCode::NO_CONTENT)
382}
383
384async fn list_ns_members(
386 principal: AuthPrincipal,
387 Extension(store): Extension<Arc<dyn Store>>,
388 Query(q): Query<NsQuery>,
389) -> Result<Json<Vec<ijima_core::NamespaceMembership>>, ApiError> {
390 if !principal.0.may(ADMIN) {
391 return Err(ApiError::Forbidden);
392 }
393 let ns = q.namespace.as_deref().ok_or(ApiError::BadRequest(
394 "?namespace=<ns> is required".to_string(),
395 ))?;
396 let members = store
397 .list_namespace_members(&NamespaceId::new(ns))
398 .await
399 .map_err(internal)?;
400 Ok(Json(members))
401}
402
403async fn list_token_revocations(
405 principal: AuthPrincipal,
406 Extension(store): Extension<Arc<dyn Store>>,
407) -> Result<Json<Vec<TokenRevocation>>, ApiError> {
408 if !principal.0.may(ADMIN) {
409 return Err(ApiError::Forbidden);
410 }
411 Ok(Json(store.list_revocations().await.map_err(internal)?))
412}
413
414#[derive(Serialize)]
415struct IdResponse {
416 id: String,
417}
418
419async fn store_memory(
420 principal: AuthPrincipal,
421 Extension(store): Extension<Arc<dyn Store>>,
422 Query(q): Query<NsQuery>,
423 Json(memory): Json<Memory>,
424) -> Result<Json<IdResponse>, ApiError> {
425 if !principal.0.may(MEMORY_WRITE) {
426 return Err(ApiError::Forbidden);
427 }
428 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
432 let mut memory = memory;
433 if memory.created_at.is_empty() {
434 memory.created_at = std::time::SystemTime::now()
435 .duration_since(std::time::UNIX_EPOCH)
436 .map(|d| d.as_secs().to_string())
437 .unwrap_or_default();
438 }
439 let id = store.store_memory(&ns, memory).await.map_err(internal)?;
440 Ok(Json(IdResponse { id: id.0 }))
441}
442
443#[derive(Deserialize)]
444struct CheckDuplicateRequest {
445 content: String,
446}
447
448#[derive(Serialize)]
449struct CheckDuplicateResponse {
450 duplicate: Option<String>,
452}
453
454async fn check_duplicate(
458 principal: AuthPrincipal,
459 Extension(store): Extension<Arc<dyn Store>>,
460 Query(q): Query<NsQuery>,
461 Json(req): Json<CheckDuplicateRequest>,
462) -> Result<Json<CheckDuplicateResponse>, ApiError> {
463 if !principal.0.may(MEMORY_READ) {
464 return Err(ApiError::Forbidden);
465 }
466 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
467 let dup = store
468 .check_duplicate(&ns, &req.content)
469 .await
470 .map_err(internal)?;
471 Ok(Json(CheckDuplicateResponse {
472 duplicate: dup.map(|id| id.0),
473 }))
474}
475
476async fn recall_memory(
477 principal: AuthPrincipal,
478 Extension(store): Extension<Arc<dyn Store>>,
479 Path(id): Path<String>,
480 Query(q): Query<NsQuery>,
481) -> Result<Json<Memory>, ApiError> {
482 if !principal.0.may(MEMORY_READ) {
483 return Err(ApiError::Forbidden);
484 }
485 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
486 match store
487 .recall_memory(&ns, &MemoryId(id))
488 .await
489 .map_err(internal)?
490 {
491 Some(memory) => Ok(Json(memory)),
492 None => Err(ApiError::NotFound),
493 }
494}
495
496async fn delete_memory(
497 principal: AuthPrincipal,
498 Extension(store): Extension<Arc<dyn Store>>,
499 Path(id): Path<String>,
500 Query(q): Query<NsQuery>,
501) -> Result<StatusCode, ApiError> {
502 if !principal.0.may(MEMORY_WRITE) {
503 return Err(ApiError::Forbidden);
504 }
505 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
506 store
507 .delete_memory(&ns, &MemoryId(id))
508 .await
509 .map_err(internal)?;
510 Ok(StatusCode::NO_CONTENT)
511}
512
513#[derive(Deserialize)]
514struct SearchRequest {
515 text: String,
519 limit: Option<usize>,
520 scope: Option<String>,
525}
526
527#[derive(Serialize)]
528struct SearchResponse {
529 memories: Vec<SearchHit>,
530}
531
532async fn search_memories(
533 principal: AuthPrincipal,
534 Extension(store): Extension<Arc<dyn Store>>,
535 Extension(embedder): Extension<Option<Arc<dyn Embedder>>>,
536 Query(q): Query<NsQuery>,
537 Json(req): Json<SearchRequest>,
538) -> Result<Json<SearchResponse>, ApiError> {
539 if !principal.0.may(MEMORY_READ) {
540 return Err(ApiError::Forbidden);
541 }
542 let embedder = embedder
543 .ok_or_else(|| ApiError::Internal("search unavailable: daemon has no embedder".into()))?;
544 let query = embedder.embed(&req.text).map_err(internal)?;
545 let limit = req.limit.unwrap_or(10);
546
547 let hits = if req.scope.as_deref() == Some("visible") {
551 let own_ns = principal.0.personal_namespace();
552 let global_ns = NamespaceId::new("global");
553 let own_hits = store
554 .search_memories(&own_ns, &query, limit)
555 .await
556 .map_err(internal)?;
557 let global_hits = if own_ns == global_ns {
558 Vec::new()
559 } else {
560 store
561 .search_memories(&global_ns, &query, limit)
562 .await
563 .map_err(internal)?
564 };
565 merge_search_hits(own_hits, global_hits, limit)
566 } else {
567 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
568 store
569 .search_memories(&ns, &query, limit)
570 .await
571 .map_err(internal)?
572 };
573 Ok(Json(SearchResponse { memories: hits }))
574}
575
576fn merge_search_hits(a: Vec<SearchHit>, b: Vec<SearchHit>, limit: usize) -> Vec<SearchHit> {
581 use std::collections::HashSet;
582 let mut all: Vec<SearchHit> = a.into_iter().chain(b).collect();
583 all.sort_by(|x, y| {
584 y.similarity
585 .partial_cmp(&x.similarity)
586 .unwrap_or(std::cmp::Ordering::Equal)
587 });
588 let mut seen: HashSet<String> = HashSet::new();
590 all.retain(|h| seen.insert(h.memory.id.0.clone()));
591 all.truncate(limit);
592 all
593}
594
595#[derive(Deserialize)]
598struct PromoteRequest {
599 target_namespace: String,
602 new_id: Option<String>,
605}
606
607#[derive(Serialize)]
608struct PromoteResponse {
609 id: String,
610 original_id: String,
611 target_namespace: String,
612 redactions: Vec<crate::redaction::Redaction>,
613}
614
615async fn promote_memory(
620 principal: AuthPrincipal,
621 Extension(store): Extension<Arc<dyn Store>>,
622 Extension(redactor): Extension<Arc<Redactor>>,
623 Path(id): Path<String>,
624 Json(req): Json<PromoteRequest>,
625) -> Result<Json<PromoteResponse>, ApiError> {
626 if !principal.0.may(TRUST_PROMOTE) {
627 return Err(ApiError::Forbidden);
628 }
629 let personal_ns = principal.0.personal_namespace();
630
631 let memory = store
633 .recall_memory(&personal_ns, &MemoryId(id.clone()))
634 .await
635 .map_err(internal)?
636 .ok_or(ApiError::NotFound)?;
637
638 let scrubbed = redactor.redact(&memory.content);
640
641 let new_id = req
643 .new_id
644 .clone()
645 .unwrap_or_else(|| format!("{id}__shared"));
646 let promoted = Memory {
647 id: MemoryId(new_id.clone()),
648 content: scrubbed.text,
649 project: memory.project,
650 topic: memory.topic,
651 source: ijima_core::memory::MemorySource::Explicit,
652 harness: memory.harness,
653 session_id: Some(id.clone()),
655 origin: memory.origin.clone(),
657 authority: memory.authority.clone(),
658 importance: memory.importance,
659 created_at: memory.created_at.clone(),
660 };
661 let target_ns = ijima_core::NamespaceId::new(&req.target_namespace);
662 {
666 let target = req.target_namespace.as_str();
667 if target.ends_with("_private") && target != personal_ns.as_str() {
668 return Err(ApiError::Forbidden);
669 }
670 let open = target == "global"
671 || target == ijima_core::namespace::DOCTRINE_NAMESPACE
672 || target == personal_ns.as_str();
673 if target.starts_with("ns_import_") {
674 return Err(ApiError::BadRequest(
675 "import staging namespaces are not promotion targets".to_string(),
676 ));
677 }
678 if !open && !principal.0.may(ADMIN) {
679 let member = store
680 .is_namespace_member(&target_ns, principal.0.principal.as_str())
681 .await
682 .map_err(internal)?;
683 if !member {
684 return Err(ApiError::Forbidden);
685 }
686 }
687 }
688 store
689 .store_memory(&target_ns, promoted)
690 .await
691 .map_err(internal)?;
692
693 Ok(Json(PromoteResponse {
694 id: new_id,
695 original_id: id,
696 target_namespace: req.target_namespace,
697 redactions: scrubbed.redactions,
698 }))
699}
700
701#[derive(Deserialize)]
704struct DoctrineRequest {
705 id: String,
706 content: String,
707 project: String,
708 topic: String,
709}
710
711async fn ingest_doctrine(
716 principal: AuthPrincipal,
717 Extension(store): Extension<Arc<dyn Store>>,
718 Json(req): Json<DoctrineRequest>,
719) -> Result<Json<IdResponse>, ApiError> {
720 if !principal.0.may(ijima_core::capabilities::ADMIN) {
721 return Err(ApiError::Forbidden);
722 }
723 let ns = ijima_core::NamespaceId::new(ijima_core::namespace::DOCTRINE_NAMESPACE);
724 store
726 .delete_memory(&ns, &MemoryId(req.id.clone()))
727 .await
728 .map_err(internal)?;
729 let memory = Memory {
730 id: MemoryId(req.id.clone()),
731 content: req.content,
732 project: req.project,
733 topic: req.topic,
734 source: ijima_core::memory::MemorySource::Doctrine,
735 harness: ijima_core::harness::Harness::Other,
736 session_id: None,
737 origin: ijima_core::InstanceId::local(),
739 authority: ijima_core::AuthorityScope::local(),
740 importance: 1.0,
741 created_at: std::time::SystemTime::now()
742 .duration_since(std::time::UNIX_EPOCH)
743 .map(|d| d.as_secs().to_string())
744 .unwrap_or_default(),
745 };
746 store.store_memory(&ns, memory).await.map_err(internal)?;
747 Ok(Json(IdResponse { id: req.id }))
748}
749
750const WAKEUP_PERSONAL_LIMIT: usize = 20;
754const WAKEUP_DOCTRINE_LIMIT: usize = 50;
756
757#[derive(Serialize)]
758struct WakeupResponse {
759 identity: serde_json::Value,
761 personal_essentials: Vec<Memory>,
763 doctrine: Vec<Memory>,
765}
766
767async fn wakeup(
771 principal: AuthPrincipal,
772 Extension(store): Extension<Arc<dyn Store>>,
773) -> Result<Json<WakeupResponse>, ApiError> {
774 if !principal.0.may(MEMORY_READ) {
775 return Err(ApiError::Forbidden);
776 }
777 let personal_ns = principal.0.personal_namespace();
778 let doctrine_ns = ijima_core::NamespaceId::new(ijima_core::namespace::DOCTRINE_NAMESPACE);
779
780 let (personal_essentials, doctrine) = tokio::join!(
781 store.list_memories(&personal_ns, WAKEUP_PERSONAL_LIMIT),
782 store.list_memories(&doctrine_ns, WAKEUP_DOCTRINE_LIMIT),
783 );
784
785 Ok(Json(WakeupResponse {
786 identity: serde_json::json!({ "principal": principal.0.principal.as_str() }),
787 personal_essentials: personal_essentials.map_err(internal)?,
788 doctrine: doctrine.map_err(internal)?,
789 }))
790}
791
792#[derive(Deserialize)]
795struct AddTripleRequest {
796 subject: String,
797 predicate: String,
798 object: String,
799 valid_from: Option<String>,
800 confidence: Option<f32>,
801 source_memory_id: Option<String>,
802}
803
804async fn add_triple(
805 principal: AuthPrincipal,
806 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
807 Extension(store): Extension<Arc<dyn Store>>,
808 Json(req): Json<AddTripleRequest>,
809) -> Result<Json<ijima_core::Triple>, ApiError> {
810 if !principal.0.may(ijima_core::capabilities::KNOWLEDGE_WRITE) {
811 return Err(ApiError::Forbidden);
812 }
813 let ns = resolve_ns(&principal, store.as_ref(), None).await?;
814 let triple = kg
815 .add_triple(
816 &ns,
817 EntityId::new(req.subject),
818 &req.predicate,
819 EntityId::new(req.object),
820 req.valid_from.as_deref(),
821 req.confidence.unwrap_or(1.0),
822 req.source_memory_id.as_deref(),
823 )
824 .await
825 .map_err(internal)?;
826 let _ = store;
828 Ok(Json(triple))
829}
830
831async fn query_entity(
832 principal: AuthPrincipal,
833 Extension(store): Extension<Arc<dyn Store>>,
834 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
835 Path(id): Path<String>,
836 Query(q): Query<NsQuery>,
837) -> Result<Json<ijima_core::EntityRecord>, ApiError> {
838 if !principal.0.may(KNOWLEDGE_READ) {
839 return Err(ApiError::Forbidden);
840 }
841 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
842 let rec = kg
843 .query_entity(&ns, &EntityId::new(id))
844 .await
845 .map_err(internal)?;
846 Ok(Json(rec))
847}
848
849async fn invalidate_triple(
850 principal: AuthPrincipal,
851 Extension(store): Extension<Arc<dyn Store>>,
852 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
853 Path(id): Path<String>,
854 Query(q): Query<NsQuery>,
855) -> Result<StatusCode, ApiError> {
856 if !principal.0.may(ijima_core::capabilities::KNOWLEDGE_WRITE) {
857 return Err(ApiError::Forbidden);
858 }
859 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
860 kg.invalidate_triple(&ns, &id).await.map_err(internal)?;
861 Ok(StatusCode::NO_CONTENT)
862}
863
864#[derive(Deserialize, Default)]
865struct FindTriplesQuery {
866 namespace: Option<String>,
867 subject: Option<String>,
868 predicate: Option<String>,
869 object: Option<String>,
870}
871
872async fn find_triples(
873 principal: AuthPrincipal,
874 Extension(store): Extension<Arc<dyn Store>>,
875 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
876 Query(q): Query<FindTriplesQuery>,
877) -> Result<Json<Vec<ijima_core::Triple>>, ApiError> {
878 if !principal.0.may(KNOWLEDGE_READ) {
879 return Err(ApiError::Forbidden);
880 }
881 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
882 let triples = kg
883 .find_triples(
884 &ns,
885 q.subject.as_deref().map(EntityId::new).as_ref(),
886 q.predicate.as_deref(),
887 q.object.as_deref().map(EntityId::new).as_ref(),
888 )
889 .await
890 .map_err(internal)?;
891 Ok(Json(triples))
892}
893
894async fn kg_timeline(
895 principal: AuthPrincipal,
896 Extension(store): Extension<Arc<dyn Store>>,
897 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
898 Query(q): Query<NsQuery>,
899) -> Result<Json<Vec<ijima_core::Triple>>, ApiError> {
900 if !principal.0.may(KNOWLEDGE_READ) {
901 return Err(ApiError::Forbidden);
902 }
903 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
904 let triples = kg
905 .kg_timeline(&ns, q.limit.unwrap_or(50))
906 .await
907 .map_err(internal)?;
908 Ok(Json(triples))
909}
910
911async fn kg_stats(
912 principal: AuthPrincipal,
913 Extension(store): Extension<Arc<dyn Store>>,
914 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
915 Query(q): Query<NsQuery>,
916) -> Result<Json<ijima_core::KgStats>, ApiError> {
917 if !principal.0.may(KNOWLEDGE_READ) {
918 return Err(ApiError::Forbidden);
919 }
920 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
921 let stats = kg.knowledge_stats(&ns).await.map_err(internal)?;
922 Ok(Json(stats))
923}
924
925async fn ingest_turn(
926 principal: AuthPrincipal,
927 Extension(store): Extension<Arc<dyn Store>>,
928 Path(session_id): Path<String>,
929 Json(mut turn): Json<SessionTurn>,
930) -> Result<StatusCode, ApiError> {
931 if !principal.0.may(SESSION_INGEST) {
932 return Err(ApiError::Forbidden);
933 }
934 let ns = principal.0.personal_namespace();
935 turn.session_id = SessionId::new(session_id);
936 store.ingest_turn(&ns, turn).await.map_err(internal)?;
937 Ok(StatusCode::NO_CONTENT)
938}
939
940#[derive(Serialize)]
943struct TurnsResponse {
944 turns: Vec<SessionTurn>,
945}
946
947async fn session_turns(
948 principal: AuthPrincipal,
949 Extension(store): Extension<Arc<dyn Store>>,
950 Path(session_id): Path<String>,
951 Query(q): Query<NsQuery>,
952) -> Result<Json<TurnsResponse>, ApiError> {
953 if !principal.0.may(MEMORY_READ) {
954 return Err(ApiError::Forbidden);
955 }
956 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
957 let turns = store
958 .session_turns(&ns, &SessionId::new(session_id), q.limit.unwrap_or(50))
959 .await
960 .map_err(internal)?;
961 Ok(Json(TurnsResponse { turns }))
962}
963
964async fn create_session(
969 principal: AuthPrincipal,
970 Extension(store): Extension<Arc<dyn Store>>,
971 Json(mut session): Json<Session>,
972) -> Result<Json<IdResponse>, ApiError> {
973 if !principal.0.may(SESSION_INGEST) {
974 return Err(ApiError::Forbidden);
975 }
976 let ns = principal.0.personal_namespace();
977 if session.started_at.is_empty() {
978 session.started_at = std::time::SystemTime::now()
979 .duration_since(std::time::UNIX_EPOCH)
980 .map(|d| d.as_secs().to_string())
981 .unwrap_or_default();
982 }
983 session.ended_at = None;
984 let id = store.create_session(&ns, session).await.map_err(internal)?;
985 Ok(Json(IdResponse { id: id.0 }))
986}
987
988#[derive(Deserialize)]
989struct SessionListQuery {
990 namespace: Option<String>,
991 harness: Option<String>,
993 limit: Option<usize>,
994}
995
996async fn list_sessions(
1000 principal: AuthPrincipal,
1001 Extension(store): Extension<Arc<dyn Store>>,
1002 Query(q): Query<SessionListQuery>,
1003) -> Result<Json<Vec<Session>>, ApiError> {
1004 if !principal.0.may(MEMORY_READ) {
1005 return Err(ApiError::Forbidden);
1006 }
1007 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1008 let harness = q.harness.as_deref().map(Harness::from_wire_str);
1009 let limit = q.limit.unwrap_or(50).min(500);
1010 let sessions = store
1011 .list_sessions(&ns, harness.as_ref(), limit)
1012 .await
1013 .map_err(internal)?;
1014 Ok(Json(sessions))
1015}
1016
1017#[derive(Deserialize)]
1018struct EndSessionRequest {
1019 ended_at: String,
1020}
1021
1022async fn end_session(
1025 principal: AuthPrincipal,
1026 Extension(store): Extension<Arc<dyn Store>>,
1027 Path(session_id): Path<String>,
1028 Json(req): Json<EndSessionRequest>,
1029) -> Result<StatusCode, ApiError> {
1030 if !principal.0.may(SESSION_INGEST) {
1031 return Err(ApiError::Forbidden);
1032 }
1033 let ns = principal.0.personal_namespace();
1034 store
1035 .end_session(&ns, &SessionId::new(session_id), req.ended_at)
1036 .await
1037 .map_err(internal)?;
1038 Ok(StatusCode::NO_CONTENT)
1039}
1040
1041async fn list_pending(
1046 principal: AuthPrincipal,
1047 Extension(store): Extension<Arc<dyn Store>>,
1048 Query(q): Query<NsQuery>,
1049) -> Result<Json<Vec<QueuedExtraction>>, ApiError> {
1050 if !principal.0.may(MINING_REVIEW) {
1051 return Err(ApiError::Forbidden);
1052 }
1053 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1054 let limit = q.limit.unwrap_or(50).min(500);
1055 let pending = store.list_pending(&ns, limit).await.map_err(internal)?;
1056 Ok(Json(pending))
1057}
1058
1059async fn accept_extraction(
1062 principal: AuthPrincipal,
1063 Extension(store): Extension<Arc<dyn Store>>,
1064 Path(id): Path<String>,
1065) -> Result<Json<AcceptedExtraction>, ApiError> {
1066 if !principal.0.may(MINING_REVIEW) {
1067 return Err(ApiError::Forbidden);
1068 }
1069 let ns = principal.0.personal_namespace();
1070 let accepted = store.accept_extraction(&ns, &id).await.map_err(internal)?;
1071 Ok(Json(accepted))
1072}
1073
1074async fn reject_extraction(
1077 principal: AuthPrincipal,
1078 Extension(store): Extension<Arc<dyn Store>>,
1079 Path(id): Path<String>,
1080) -> Result<StatusCode, ApiError> {
1081 if !principal.0.may(MINING_REVIEW) {
1082 return Err(ApiError::Forbidden);
1083 }
1084 let ns = principal.0.personal_namespace();
1085 store.reject_extraction(&ns, &id).await.map_err(internal)?;
1086 Ok(StatusCode::NO_CONTENT)
1087}
1088
1089#[cfg(feature = "mining")]
1103async fn trigger_mine(
1104 principal: AuthPrincipal,
1105 Extension(store): Extension<Arc<dyn Store>>,
1106 Path(session_id): Path<String>,
1107 Query(q): Query<NsQuery>,
1108) -> Result<Json<crate::mining_pipeline::MiningReport>, ApiError> {
1109 use proserpina_agent::http::HttpAgent;
1110
1111 if !principal.0.may(MINING_TRIGGER) {
1112 return Err(ApiError::Forbidden);
1113 }
1114 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1115
1116 let turns = store
1118 .session_turns(&ns, &SessionId::new(session_id.clone()), 10_000)
1119 .await
1120 .map_err(internal)?;
1121 let turn_texts: Vec<String> = turns.into_iter().map(|t| t.content).collect();
1122 let ctx = crate::mining_pipeline::mining_context(&session_id, "general", Harness::Other);
1123
1124 let extractions = tokio::task::spawn_blocking(move || {
1131 let mut agent: Option<HttpAgent> = build_mining_agent();
1132 let agent_dyn: Option<&mut dyn proserpina_agent::Agent> = agent
1133 .as_mut()
1134 .map(|a| a as &mut dyn proserpina_agent::Agent);
1135 ijima_miner::mine_all(&turn_texts, &ctx, agent_dyn)
1136 })
1137 .await
1138 .map_err(|e| {
1139 internal(ijima_core::IjimaError::Mining {
1140 detail: format!("extraction task failed: {e}"),
1141 })
1142 })?
1143 .map_err(internal)?;
1144
1145 let report = crate::mining_pipeline::ingest_extractions(store.as_ref(), &ns, extractions)
1146 .await
1147 .map_err(internal)?;
1148 Ok(Json(report))
1149}
1150
1151#[cfg(feature = "mining")]
1161fn build_mining_agent() -> Option<proserpina_agent::http::HttpAgent> {
1162 use proserpina_agent::{
1163 AgentId, Persona,
1164 http::{HttpAgent, HttpConfig},
1165 };
1166
1167 let base_url = std::env::var("IJIMA_LLM_BASE_URL")
1168 .unwrap_or_else(|_| "https://api.deepseek.com/v1".to_string());
1169 let model = std::env::var("IJIMA_LLM_MODEL").ok()?;
1170 let api_key = std::env::var("IJIMA_LLM_API_KEY").ok()?;
1171
1172 let persona = Persona::new("Session Mining Extractor")
1173 .with_framing(
1174 "You mine session transcripts for durable facts and recurring \
1175 patterns. Output one JSON object per line, each \
1176 {\"content\",\"project\",\"topic\",\"confidence\"}. Omit all \
1177 preamble. If nothing worth extracting, output nothing.",
1178 )
1179 .with_focus(
1180 "decisions, chosen tools, stated constraints, measurements, recurring workflows",
1181 );
1182
1183 Some(HttpAgent::new(
1184 AgentId::new("ijima-miner"),
1185 persona,
1186 HttpConfig {
1187 base_url,
1188 model,
1189 api_key,
1190 },
1191 ))
1192}
1193
1194#[derive(Deserialize)]
1197struct NamespaceQuery {
1198 namespace: Option<String>,
1199}
1200
1201#[derive(Deserialize)]
1202struct RoomsQuery {
1203 namespace: Option<String>,
1204 project: Option<String>,
1205 limit: Option<usize>,
1206}
1207
1208#[derive(Deserialize)]
1209struct TunnelQuery {
1210 namespace: Option<String>,
1211 topic: String,
1212 project_a: String,
1213 project_b: String,
1214 limit: Option<usize>,
1215}
1216
1217#[derive(Deserialize)]
1218struct DiaryQuery {
1219 namespace: Option<String>,
1220 limit: Option<usize>,
1221}
1222
1223#[derive(Deserialize)]
1224struct MemoryBrowseQuery {
1225 namespace: Option<String>,
1226 project: Option<String>,
1227 topic: Option<String>,
1228 limit: Option<usize>,
1229}
1230
1231#[derive(Deserialize)]
1232struct ResolveRepoQuery {
1233 cwd: String,
1234}
1235
1236async fn list_rooms(
1238 principal: AuthPrincipal,
1239 Extension(store): Extension<Arc<dyn Store>>,
1240 Query(q): Query<RoomsQuery>,
1241) -> Result<Json<Vec<Room>>, ApiError> {
1242 if !principal.0.may(MEMORY_READ) {
1243 return Err(ApiError::Forbidden);
1244 }
1245 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1246 let limit = q.limit.unwrap_or(50).min(500);
1247 let rooms = store
1248 .list_rooms(&ns, q.project.as_deref(), limit)
1249 .await
1250 .map_err(internal)?;
1251 Ok(Json(rooms))
1252}
1253
1254async fn taxonomy(
1256 principal: AuthPrincipal,
1257 Extension(store): Extension<Arc<dyn Store>>,
1258 Query(q): Query<NamespaceQuery>,
1259) -> Result<Json<Vec<ProjectTaxon>>, ApiError> {
1260 if !principal.0.may(MEMORY_READ) {
1261 return Err(ApiError::Forbidden);
1262 }
1263 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1264 Ok(Json(store.taxonomy(&ns).await.map_err(internal)?))
1265}
1266
1267async fn palace_graph(
1269 principal: AuthPrincipal,
1270 Extension(store): Extension<Arc<dyn Store>>,
1271 Query(q): Query<NamespaceQuery>,
1272) -> Result<Json<PalaceGraph>, ApiError> {
1273 if !principal.0.may(MEMORY_READ) {
1274 return Err(ApiError::Forbidden);
1275 }
1276 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1277 Ok(Json(store.palace_graph(&ns).await.map_err(internal)?))
1278}
1279
1280async fn traverse_tunnel(
1282 principal: AuthPrincipal,
1283 Extension(store): Extension<Arc<dyn Store>>,
1284 Query(q): Query<TunnelQuery>,
1285) -> Result<Json<TunnelTraversal>, ApiError> {
1286 if !principal.0.may(MEMORY_READ) {
1287 return Err(ApiError::Forbidden);
1288 }
1289 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1290 let limit = q.limit.unwrap_or(50).min(500);
1291 Ok(Json(
1292 store
1293 .traverse_tunnel(&ns, &q.topic, &q.project_a, &q.project_b, limit)
1294 .await
1295 .map_err(internal)?,
1296 ))
1297}
1298
1299async fn write_diary(
1301 principal: AuthPrincipal,
1302 Extension(store): Extension<Arc<dyn Store>>,
1303 Json(entry): Json<DiaryEntry>,
1304) -> Result<StatusCode, ApiError> {
1305 if !principal.0.may(MEMORY_WRITE) {
1306 return Err(ApiError::Forbidden);
1307 }
1308 let ns = principal.0.personal_namespace();
1309 store.write_diary(&ns, entry).await.map_err(internal)?;
1310 Ok(StatusCode::NO_CONTENT)
1311}
1312
1313async fn read_diary(
1315 principal: AuthPrincipal,
1316 Extension(store): Extension<Arc<dyn Store>>,
1317 Path(agent): Path<String>,
1318 Query(q): Query<DiaryQuery>,
1319) -> Result<Json<Vec<DiaryEntry>>, ApiError> {
1320 if !principal.0.may(MEMORY_READ) {
1321 return Err(ApiError::Forbidden);
1322 }
1323 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1324 let limit = q.limit.unwrap_or(50).min(500);
1325 Ok(Json(
1326 store
1327 .read_diary(&ns, &agent, limit)
1328 .await
1329 .map_err(internal)?,
1330 ))
1331}
1332
1333async fn browse_memories(
1336 principal: AuthPrincipal,
1337 Extension(store): Extension<Arc<dyn Store>>,
1338 Query(q): Query<MemoryBrowseQuery>,
1339) -> Result<Json<Vec<Memory>>, ApiError> {
1340 if !principal.0.may(MEMORY_READ) {
1341 return Err(ApiError::Forbidden);
1342 }
1343 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1344 let limit = q.limit.unwrap_or(50).min(500);
1345 Ok(Json(
1346 store
1347 .list_memories_filtered(&ns, q.project.as_deref(), q.topic.as_deref(), limit)
1348 .await
1349 .map_err(internal)?,
1350 ))
1351}
1352
1353#[derive(Serialize)]
1354struct NamespaceStats {
1355 total: usize,
1356 projects: Vec<ProjectCount>,
1357}
1358
1359#[derive(Serialize)]
1360struct ProjectCount {
1361 project: String,
1362 count: usize,
1363}
1364
1365async fn memory_stats(
1368 principal: AuthPrincipal,
1369 Extension(store): Extension<Arc<dyn Store>>,
1370 Query(q): Query<NamespaceQuery>,
1371) -> Result<Json<NamespaceStats>, ApiError> {
1372 if !principal.0.may(MEMORY_READ) {
1373 return Err(ApiError::Forbidden);
1374 }
1375 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1376 let rooms = store.list_rooms(&ns, None, 1000).await.map_err(internal)?;
1377 let total: usize = rooms.iter().map(|r| r.count).sum();
1378 let mut by_project: std::collections::BTreeMap<String, usize> =
1379 std::collections::BTreeMap::new();
1380 for r in &rooms {
1381 *by_project.entry(r.project.clone()).or_default() += r.count;
1382 }
1383 let projects = by_project
1384 .into_iter()
1385 .map(|(project, count)| ProjectCount { project, count })
1386 .collect();
1387 Ok(Json(NamespaceStats { total, projects }))
1388}
1389
1390async fn register_repo(
1394 principal: AuthPrincipal,
1395 Extension(store): Extension<Arc<dyn Store>>,
1396 Json(repo): Json<RepoDirectory>,
1397) -> Result<StatusCode, ApiError> {
1398 if !principal.0.may(ADMIN) {
1399 return Err(ApiError::Forbidden);
1400 }
1401 store.register_repo(repo).await.map_err(internal)?;
1402 Ok(StatusCode::NO_CONTENT)
1403}
1404
1405async fn list_repos(
1407 principal: AuthPrincipal,
1408 Extension(store): Extension<Arc<dyn Store>>,
1409) -> Result<Json<Vec<RepoDirectory>>, ApiError> {
1410 if !principal.0.may(MEMORY_READ) {
1411 return Err(ApiError::Forbidden);
1412 }
1413 Ok(Json(store.list_repos().await.map_err(internal)?))
1414}
1415
1416async fn resolve_repo(
1418 principal: AuthPrincipal,
1419 Extension(store): Extension<Arc<dyn Store>>,
1420 Query(q): Query<ResolveRepoQuery>,
1421) -> Result<Json<RepoDirectory>, ApiError> {
1422 if !principal.0.may(MEMORY_READ) {
1423 return Err(ApiError::Forbidden);
1424 }
1425 match store.resolve_repo(&q.cwd).await.map_err(internal)? {
1426 Some(repo) => Ok(Json(repo)),
1427 None => Err(ApiError::NotFound),
1428 }
1429}
1430
1431#[cfg(feature = "federation")]
1435async fn federation_state(
1436 Extension(cfg): Extension<Arc<InstanceFederationConfig>>,
1437) -> Json<FederationState> {
1438 Json(cfg.to_state())
1439}
1440
1441#[cfg(feature = "federation")]
1449async fn routed_write(
1450 principal: AuthPrincipal,
1451 Extension(store): Extension<Arc<dyn Store>>,
1452 Extension(cfg): Extension<Arc<InstanceFederationConfig>>,
1453 Json(write): Json<RoutedWrite>,
1454) -> Result<Json<RoutedWriteReceipt>, ApiError> {
1455 if !principal.0.may(MEMORY_WRITE) {
1456 return Err(ApiError::Forbidden);
1457 }
1458 let RoutedWrite {
1459 target: _,
1460 scope,
1461 operation: _,
1462 payload,
1463 } = write;
1464
1465 if cfg.role == ijima_core::federation::InstanceRole::Airgapped {
1468 return Err(ApiError::Forbidden);
1469 }
1470 if !cfg.accepts_scope(&scope) {
1473 return Err(ApiError::BadRequest(format!(
1474 "out of authoritative scope: {}/{}",
1475 scope.namespace, scope.project
1476 )));
1477 }
1478
1479 let mut memory: Memory = serde_json::from_value(payload)
1480 .map_err(|e| ApiError::BadRequest(format!("payload is not a Memory: {e}")))?;
1481 memory.origin = ijima_core::provenance::InstanceId::local();
1484 memory.authority =
1485 ijima_core::provenance::AuthorityScope(format!("{}/{}", scope.namespace, scope.project));
1486 if memory.created_at.is_empty() {
1487 memory.created_at = std::time::SystemTime::now()
1488 .duration_since(std::time::UNIX_EPOCH)
1489 .map(|d| d.as_secs().to_string())
1490 .unwrap_or_default();
1491 }
1492 let ns = principal.0.personal_namespace();
1493
1494 let (commit, mut warnings) = if memory.source == MemorySource::Doctrine {
1498 let pending = store
1499 .enqueue_extraction(&ns, memory, 0.5)
1500 .await
1501 .map_err(internal)?;
1502 (
1503 pending,
1504 vec!["doctrine downgraded to PendingReview (trust-tier ingress rule)".into()],
1505 )
1506 } else {
1507 let id = store.store_memory(&ns, memory).await.map_err(internal)?;
1508 (id.0, Vec::new())
1509 };
1510 warnings.push("boundary enforcement: scope + airgap + doctrine-downgrade applied".into());
1511 Ok(Json(RoutedWriteReceipt {
1512 accepted: true,
1513 instance: cfg.instance_id.clone(),
1514 scope,
1515 commit: Some(commit),
1516 warnings,
1517 }))
1518}
1519
1520#[cfg(feature = "federation")]
1525async fn conflict_signal(
1526 Json(_scope): Json<AuthoritativeScope>,
1527) -> Result<Json<ConflictSignal>, ApiError> {
1528 Err(ApiError::NotFound)
1529}
1530
1531#[cfg(test)]
1532mod tests {
1533 use super::*;
1534 use crate::IjimaAuth;
1535 use axum::body::Body;
1536 use axum::http::{Request, StatusCode};
1537 use ijima_core::{harness::Harness, memory::MemorySource};
1538 use tower::ServiceExt;
1539
1540 async fn app_with_store() -> (Router, Arc<IjimaAuth>) {
1541 let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy"));
1542 let store_inner = Arc::new(crate::SurrealStore::open_embedded().await.expect("open"));
1543 let store: Arc<dyn Store> = store_inner.clone();
1544 let kg: Arc<dyn KnowledgeGraph> = store_inner;
1545 (
1546 app(
1547 auth.clone(),
1548 store,
1549 kg,
1550 None,
1551 Arc::new(crate::redaction::Redactor::new()),
1552 #[cfg(feature = "rate-limit")]
1553 None,
1554 #[cfg(feature = "federation")]
1555 Arc::new(InstanceFederationConfig::default()),
1556 ),
1557 auth,
1558 )
1559 }
1560
1561 #[cfg(feature = "federation")]
1564 async fn app_with_federation_config(
1565 config: InstanceFederationConfig,
1566 ) -> (Router, Arc<IjimaAuth>) {
1567 let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy"));
1568 let store_inner = Arc::new(crate::SurrealStore::open_embedded().await.expect("open"));
1569 let store: Arc<dyn Store> = store_inner.clone();
1570 let kg: Arc<dyn KnowledgeGraph> = store_inner;
1571 (
1572 app(
1573 auth.clone(),
1574 store,
1575 kg,
1576 None,
1577 Arc::new(crate::redaction::Redactor::new()),
1578 #[cfg(feature = "rate-limit")]
1579 None,
1580 Arc::new(config),
1581 ),
1582 auth,
1583 )
1584 }
1585
1586 fn bearer(auth: &IjimaAuth, principal: &str, cap: &str) -> String {
1587 format!(
1588 "Bearer {}",
1589 auth.issue_bearer(principal, cap).expect("issue")
1590 )
1591 }
1592
1593 #[cfg(feature = "federation")]
1594 #[tokio::test]
1595 async fn federation_state_returns_local_config() {
1596 let (app, _auth) = app_with_store().await;
1597 let res = app
1598 .oneshot(
1599 Request::builder()
1600 .uri("/federation/state")
1601 .body(Body::empty())
1602 .unwrap(),
1603 )
1604 .await
1605 .unwrap();
1606 assert_eq!(res.status(), StatusCode::OK);
1607 let state = body_json(res).await;
1608 assert_eq!(state["instance_id"], "local");
1609 assert_eq!(state["role"], "Unifying");
1610 }
1611
1612 #[cfg(feature = "federation")]
1613 #[tokio::test]
1614 async fn routed_write_applies_a_memory() {
1615 let (app, auth) = app_with_store().await;
1616 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1617 let body = serde_json::json!({
1618 "target": "local",
1619 "scope": {"namespace": "local", "project": "Dominic"},
1620 "operation": "Create",
1621 "payload": {
1622 "id": "mem_fed_test",
1623 "content": "federated hello",
1624 "project": "Dominic",
1625 "topic": "federated",
1626 "source": "Explicit",
1627 "harness": "Dominic"
1628 }
1629 })
1630 .to_string();
1631 let res = app
1632 .oneshot(
1633 Request::builder()
1634 .method("POST")
1635 .uri("/federation/routed-write")
1636 .header("authorization", &write)
1637 .header("content-type", "application/json")
1638 .body(Body::from(body))
1639 .unwrap(),
1640 )
1641 .await
1642 .unwrap();
1643 assert_eq!(res.status(), StatusCode::OK);
1644 let receipt = body_json(res).await;
1645 assert_eq!(receipt["accepted"], true);
1646 assert!(receipt["commit"].as_str().is_some());
1647 assert_eq!(
1648 receipt["warnings"][0],
1649 "boundary enforcement: scope + airgap + doctrine-downgrade applied"
1650 );
1651 }
1652
1653 #[cfg(feature = "federation")]
1654 #[tokio::test]
1655 async fn routed_write_requires_memory_write() {
1656 let (app, auth) = app_with_store().await;
1657 let read = bearer(&auth, "elliott", MEMORY_READ); let body = serde_json::json!({
1659 "target": "local",
1660 "scope": {"namespace": "local", "project": "Dominic"},
1661 "operation": "Create",
1662 "payload": {
1663 "id": "x", "content": "c", "project": "p",
1664 "topic": "t", "source": "Explicit", "harness": "Dominic"
1665 }
1666 })
1667 .to_string();
1668 let res = app
1669 .oneshot(
1670 Request::builder()
1671 .method("POST")
1672 .uri("/federation/routed-write")
1673 .header("authorization", &read)
1674 .header("content-type", "application/json")
1675 .body(Body::from(body))
1676 .unwrap(),
1677 )
1678 .await
1679 .unwrap();
1680 assert_eq!(res.status(), StatusCode::FORBIDDEN);
1681 }
1682
1683 #[cfg(feature = "federation")]
1684 #[tokio::test]
1685 async fn routed_write_rejects_out_of_scope() {
1686 let (app, auth) = app_with_store().await;
1687 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1688 let body = serde_json::json!({
1690 "target": "local",
1691 "scope": {"namespace": "shared", "project": "Dominic"},
1692 "operation": "Create",
1693 "payload": {
1694 "id": "x", "content": "c", "project": "p",
1695 "topic": "t", "source": "Explicit", "harness": "Dominic"
1696 }
1697 })
1698 .to_string();
1699 let res = app
1700 .oneshot(
1701 Request::builder()
1702 .method("POST")
1703 .uri("/federation/routed-write")
1704 .header("authorization", &write)
1705 .header("content-type", "application/json")
1706 .body(Body::from(body))
1707 .unwrap(),
1708 )
1709 .await
1710 .unwrap();
1711 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
1712 }
1713
1714 #[cfg(feature = "federation")]
1715 #[tokio::test]
1716 async fn routed_write_rejects_when_airgapped() {
1717 let cfg = InstanceFederationConfig {
1718 role: ijima_core::federation::InstanceRole::Airgapped,
1719 ..InstanceFederationConfig::default()
1720 };
1721 let (app, auth) = app_with_federation_config(cfg).await;
1722 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1723 let body = serde_json::json!({
1724 "target": "local",
1725 "scope": {"namespace": "local", "project": "Dominic"},
1726 "operation": "Create",
1727 "payload": {
1728 "id": "x", "content": "c", "project": "p",
1729 "topic": "t", "source": "Explicit", "harness": "Dominic"
1730 }
1731 })
1732 .to_string();
1733 let res = app
1734 .oneshot(
1735 Request::builder()
1736 .method("POST")
1737 .uri("/federation/routed-write")
1738 .header("authorization", &write)
1739 .header("content-type", "application/json")
1740 .body(Body::from(body))
1741 .unwrap(),
1742 )
1743 .await
1744 .unwrap();
1745 assert_eq!(res.status(), StatusCode::FORBIDDEN);
1746 }
1747
1748 #[cfg(feature = "federation")]
1749 #[tokio::test]
1750 async fn routed_write_downgrades_doctrine_to_pending() {
1751 let (app, auth) = app_with_store().await;
1752 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1753 let body = serde_json::json!({
1754 "target": "local",
1755 "scope": {"namespace": "local", "project": "Dominic"},
1756 "operation": "Create",
1757 "payload": {
1758 "id": "mem_doctrine",
1759 "content": "peer-claimed doctrine",
1760 "project": "Dominic",
1761 "topic": "federated",
1762 "source": "Doctrine",
1763 "harness": "Dominic"
1764 }
1765 })
1766 .to_string();
1767 let res = app
1768 .oneshot(
1769 Request::builder()
1770 .method("POST")
1771 .uri("/federation/routed-write")
1772 .header("authorization", &write)
1773 .header("content-type", "application/json")
1774 .body(Body::from(body))
1775 .unwrap(),
1776 )
1777 .await
1778 .unwrap();
1779 assert_eq!(res.status(), StatusCode::OK);
1780 let receipt = body_json(res).await;
1781 assert_eq!(receipt["accepted"], true);
1782 assert_eq!(
1783 receipt["warnings"][0],
1784 "doctrine downgraded to PendingReview (trust-tier ingress rule)"
1785 );
1786 }
1787
1788 #[cfg(feature = "federation")]
1789 #[tokio::test]
1790 async fn conflict_signal_returns_404_when_none() {
1791 let (app, _auth) = app_with_store().await;
1792 let body = serde_json::json!({"namespace": "shared", "project": "Dominic"}).to_string();
1793 let res = app
1794 .oneshot(
1795 Request::builder()
1796 .method("POST")
1797 .uri("/federation/conflict-signal")
1798 .header("content-type", "application/json")
1799 .body(Body::from(body))
1800 .unwrap(),
1801 )
1802 .await
1803 .unwrap();
1804 assert_eq!(res.status(), StatusCode::NOT_FOUND);
1805 }
1806
1807 fn sample_memory_json(id: &str) -> String {
1808 serde_json::json!({
1809 "id": id,
1810 "content": "decided to wire the daemon",
1811 "project": "ijima",
1812 "topic": "api",
1813 "source": "Explicit",
1814 "harness": "Pi",
1815 "session_id": "sess_1",
1816 "importance": 0.5,
1817 "created_at": "0",
1818 })
1819 .to_string()
1820 }
1821
1822 #[tokio::test]
1823 async fn health_is_public() {
1824 let (app, _) = app_with_store().await;
1825 let res = app
1826 .oneshot(
1827 Request::builder()
1828 .uri("/health")
1829 .body(Body::empty())
1830 .unwrap(),
1831 )
1832 .await
1833 .unwrap();
1834 assert_eq!(res.status(), StatusCode::OK);
1835 }
1836
1837 #[tokio::test]
1838 async fn recall_without_auth_is_401() {
1839 let (app, _) = app_with_store().await;
1840 let res = app
1841 .oneshot(
1842 Request::builder()
1843 .uri("/memories/mem_1")
1844 .body(Body::empty())
1845 .unwrap(),
1846 )
1847 .await
1848 .unwrap();
1849 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
1850 }
1851
1852 #[tokio::test]
1853 async fn store_then_recall_round_trips() {
1854 let (app, auth) = app_with_store().await;
1855 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1856 let read = bearer(&auth, "elliott", MEMORY_READ);
1857
1858 let res = app
1860 .clone()
1861 .oneshot(
1862 Request::builder()
1863 .method("POST")
1864 .uri("/memories")
1865 .header("authorization", &write)
1866 .header("content-type", "application/json")
1867 .body(Body::from(sample_memory_json("mem_1")))
1868 .unwrap(),
1869 )
1870 .await
1871 .unwrap();
1872 assert_eq!(res.status(), StatusCode::OK);
1873
1874 let res = app
1876 .oneshot(
1877 Request::builder()
1878 .uri("/memories/mem_1")
1879 .header("authorization", &read)
1880 .body(Body::empty())
1881 .unwrap(),
1882 )
1883 .await
1884 .unwrap();
1885 assert_eq!(res.status(), StatusCode::OK);
1886 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
1887 .await
1888 .unwrap();
1889 let mem: Memory = serde_json::from_slice(&body).unwrap();
1890 assert_eq!(mem.content, "decided to wire the daemon");
1891 assert_eq!(mem.harness, Harness::Pi);
1892 assert_eq!(mem.source, MemorySource::Explicit);
1893 }
1894
1895 #[tokio::test]
1896 async fn store_with_read_only_token_is_403() {
1897 let (app, auth) = app_with_store().await;
1898 let read = bearer(&auth, "elliott", MEMORY_READ);
1899 let res = app
1900 .oneshot(
1901 Request::builder()
1902 .method("POST")
1903 .uri("/memories")
1904 .header("authorization", &read)
1905 .header("content-type", "application/json")
1906 .body(Body::from(sample_memory_json("mem_x")))
1907 .unwrap(),
1908 )
1909 .await
1910 .unwrap();
1911 assert_eq!(res.status(), StatusCode::FORBIDDEN);
1912 }
1913
1914 #[tokio::test]
1915 async fn namespace_isolation_across_principals() {
1916 let (app, auth) = app_with_store().await;
1917 let alice_write = bearer(&auth, "alice", MEMORY_WRITE);
1919 let _ = app
1920 .clone()
1921 .oneshot(
1922 Request::builder()
1923 .method("POST")
1924 .uri("/memories")
1925 .header("authorization", &alice_write)
1926 .header("content-type", "application/json")
1927 .body(Body::from(sample_memory_json("mem_a")))
1928 .unwrap(),
1929 )
1930 .await
1931 .unwrap();
1932 let bob_read = bearer(&auth, "bob", MEMORY_READ);
1934 let res = app
1935 .oneshot(
1936 Request::builder()
1937 .uri("/memories/mem_a")
1938 .header("authorization", &bob_read)
1939 .body(Body::empty())
1940 .unwrap(),
1941 )
1942 .await
1943 .unwrap();
1944 assert_eq!(res.status(), StatusCode::NOT_FOUND);
1945 }
1946
1947 #[tokio::test]
1948 async fn promote_redacts_secrets_and_leaves_original_intact() {
1949 let (app, auth) = app_with_store().await;
1950 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1951 let read = bearer(&auth, "elliott", MEMORY_READ);
1952 let promote = bearer(&auth, "elliott", TRUST_PROMOTE);
1953
1954 let admin = bearer(&auth, "root", ADMIN);
1957 let res = app
1958 .clone()
1959 .oneshot(
1960 Request::builder()
1961 .method("POST")
1962 .uri("/namespaces/grant")
1963 .header("authorization", &admin)
1964 .header("content-type", "application/json")
1965 .body(Body::from(
1966 serde_json::json!({
1967 "namespace": "ns_team_shared",
1968 "principal": "elliott"
1969 })
1970 .to_string(),
1971 ))
1972 .unwrap(),
1973 )
1974 .await
1975 .unwrap();
1976 assert_eq!(res.status(), StatusCode::OK, "membership grant");
1977
1978 let body = serde_json::json!({
1980 "id": "mem_secret",
1981 "content": "deploy key sk-abcdefghijklmnopqrstuvwxyz1234567890 contact ops@test.com",
1982 "project": "ijima",
1983 "topic": "ops",
1984 "source": "Explicit",
1985 "harness": "Pi",
1986 })
1987 .to_string();
1988 let res = app
1989 .clone()
1990 .oneshot(
1991 Request::builder()
1992 .method("POST")
1993 .uri("/memories")
1994 .header("authorization", &write)
1995 .header("content-type", "application/json")
1996 .body(Body::from(body))
1997 .unwrap(),
1998 )
1999 .await
2000 .unwrap();
2001 assert_eq!(res.status(), StatusCode::OK);
2002
2003 let promote_body = serde_json::json!({
2005 "target_namespace": "ns_team_shared",
2006 })
2007 .to_string();
2008 let res = app
2009 .clone()
2010 .oneshot(
2011 Request::builder()
2012 .method("POST")
2013 .uri("/memories/mem_secret/promote")
2014 .header("authorization", &promote)
2015 .header("content-type", "application/json")
2016 .body(Body::from(promote_body))
2017 .unwrap(),
2018 )
2019 .await
2020 .unwrap();
2021 assert_eq!(res.status(), StatusCode::OK);
2022 let resp: serde_json::Value = serde_json::from_slice(
2023 &axum::body::to_bytes(res.into_body(), usize::MAX)
2024 .await
2025 .unwrap(),
2026 )
2027 .unwrap();
2028 let new_id = resp["id"].as_str().unwrap();
2029 assert_eq!(new_id, "mem_secret__shared");
2030 let cats: Vec<&str> = resp["redactions"]
2031 .as_array()
2032 .unwrap()
2033 .iter()
2034 .map(|r| r["category"].as_str().unwrap())
2035 .collect();
2036 assert!(cats.contains(&"api_key"));
2037 assert!(cats.contains(&"email"));
2038
2039 let res = app
2041 .clone()
2042 .oneshot(
2043 Request::builder()
2044 .uri("/memories/mem_secret")
2045 .header("authorization", &read)
2046 .body(Body::empty())
2047 .unwrap(),
2048 )
2049 .await
2050 .unwrap();
2051 let orig: Memory = serde_json::from_slice(
2052 &axum::body::to_bytes(res.into_body(), usize::MAX)
2053 .await
2054 .unwrap(),
2055 )
2056 .unwrap();
2057 assert!(orig.content.contains("sk-abcdef"));
2058 assert!(orig.content.contains("ops@test.com"));
2059
2060 let res = app
2063 .oneshot(
2064 Request::builder()
2065 .uri("/memories/mem_secret__shared?namespace=ns_team_shared")
2066 .header("authorization", &read)
2067 .body(Body::empty())
2068 .unwrap(),
2069 )
2070 .await
2071 .unwrap();
2072 assert_eq!(res.status(), StatusCode::OK);
2073 let shared: Memory = serde_json::from_slice(
2074 &axum::body::to_bytes(res.into_body(), usize::MAX)
2075 .await
2076 .unwrap(),
2077 )
2078 .unwrap();
2079 assert!(shared.content.contains("[REDACTED:api_key]"));
2080 assert!(shared.content.contains("[REDACTED:email]"));
2081 assert!(!shared.content.contains("sk-abcdef"));
2082 assert!(!shared.content.contains("ops@test.com"));
2083 assert_eq!(shared.session_id.as_deref(), Some("mem_secret"));
2085 }
2086
2087 #[tokio::test]
2088 async fn promote_requires_trust_promote_not_memory_write() {
2089 let (app, auth) = app_with_store().await;
2093 let write = bearer(&auth, "elliott", MEMORY_WRITE);
2094 let body = serde_json::json!({
2095 "id": "mem_p",
2096 "content": "provenance tier test",
2097 "project": "ijima",
2098 "topic": "t",
2099 "source": "Explicit",
2100 "harness": "Pi",
2101 })
2102 .to_string();
2103 let res = app
2105 .clone()
2106 .oneshot(
2107 Request::builder()
2108 .method("POST")
2109 .uri("/memories")
2110 .header("authorization", &write)
2111 .header("content-type", "application/json")
2112 .body(Body::from(body))
2113 .unwrap(),
2114 )
2115 .await
2116 .unwrap();
2117 assert_eq!(res.status(), StatusCode::OK);
2118
2119 let promote_body = serde_json::json!({ "target_namespace": "ns_team_shared" }).to_string();
2121 let res = app
2122 .clone()
2123 .oneshot(
2124 Request::builder()
2125 .method("POST")
2126 .uri("/memories/mem_p/promote")
2127 .header("authorization", &write)
2128 .header("content-type", "application/json")
2129 .body(Body::from(promote_body))
2130 .unwrap(),
2131 )
2132 .await
2133 .unwrap();
2134 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2135
2136 let admin = bearer(&auth, "root", ADMIN);
2140 let res = app
2141 .clone()
2142 .oneshot(
2143 Request::builder()
2144 .method("POST")
2145 .uri("/namespaces/grant")
2146 .header("authorization", &admin)
2147 .header("content-type", "application/json")
2148 .body(Body::from(
2149 serde_json::json!({
2150 "namespace": "ns_team_shared",
2151 "principal": "elliott"
2152 })
2153 .to_string(),
2154 ))
2155 .unwrap(),
2156 )
2157 .await
2158 .unwrap();
2159 assert_eq!(res.status(), StatusCode::OK, "membership grant");
2160
2161 let promote = bearer(&auth, "elliott", TRUST_PROMOTE);
2162 let promote_body = serde_json::json!({ "target_namespace": "ns_team_shared" }).to_string();
2163 let res = app
2164 .oneshot(
2165 Request::builder()
2166 .method("POST")
2167 .uri("/memories/mem_p/promote")
2168 .header("authorization", &promote)
2169 .header("content-type", "application/json")
2170 .body(Body::from(promote_body))
2171 .unwrap(),
2172 )
2173 .await
2174 .unwrap();
2175 assert_eq!(res.status(), StatusCode::OK);
2176 }
2177
2178 #[tokio::test]
2183 async fn shared_namespace_membership_lifecycle() {
2184 let (app, auth) = app_with_store().await;
2185 let rw = bearer(&auth, "elliott", MEMORY_WRITE);
2186 let admin = bearer(&auth, "root", ADMIN);
2187
2188 let write_into = |app: Router, token: String, n: u8| async move {
2189 app.oneshot(
2190 Request::builder()
2191 .method("POST")
2192 .uri("/memories?namespace=ns_ia_shared")
2193 .header("authorization", token)
2194 .header("content-type", "application/json")
2195 .body(Body::from(
2196 serde_json::json!({
2197 "id": format!("mem_wall_{n}"),
2198 "content": format!("org-wall probe {n}"),
2199 "project": "ijima",
2200 "topic": "ws3",
2201 "source": "Explicit",
2202 "harness": "Pi",
2203 "importance": 0.5,
2204 "created_at": "0",
2205 })
2206 .to_string(),
2207 ))
2208 .unwrap(),
2209 )
2210 .await
2211 .unwrap()
2212 };
2213
2214 let res = write_into(app.clone(), rw.clone(), 1).await;
2216 assert_eq!(
2217 res.status(),
2218 StatusCode::FORBIDDEN,
2219 "non-member must be walled"
2220 );
2221
2222 let res = write_into(app.clone(), admin.clone(), 2).await;
2224 assert_eq!(res.status(), StatusCode::OK, "admin bypass");
2225
2226 let res = app
2228 .clone()
2229 .oneshot(
2230 Request::builder()
2231 .method("POST")
2232 .uri("/namespaces/grant")
2233 .header("authorization", rw.clone())
2234 .header("content-type", "application/json")
2235 .body(Body::from(
2236 serde_json::json!({ "namespace": "ns_ia_shared", "principal": "elliott" })
2237 .to_string(),
2238 ))
2239 .unwrap(),
2240 )
2241 .await
2242 .unwrap();
2243 assert_eq!(res.status(), StatusCode::FORBIDDEN, "grant requires admin");
2244
2245 let res = app
2247 .clone()
2248 .oneshot(
2249 Request::builder()
2250 .method("POST")
2251 .uri("/namespaces/grant")
2252 .header("authorization", admin.clone())
2253 .header("content-type", "application/json")
2254 .body(Body::from(
2255 serde_json::json!({ "namespace": "ns_ia_shared", "principal": "elliott" })
2256 .to_string(),
2257 ))
2258 .unwrap(),
2259 )
2260 .await
2261 .unwrap();
2262 assert_eq!(res.status(), StatusCode::OK);
2263 let res = write_into(app.clone(), rw.clone(), 3).await;
2264 assert_eq!(res.status(), StatusCode::OK, "member passes");
2265
2266 let res = app
2268 .clone()
2269 .oneshot(
2270 Request::builder()
2271 .uri("/namespaces/members?namespace=ns_ia_shared")
2272 .header("authorization", admin.clone())
2273 .body(Body::empty())
2274 .unwrap(),
2275 )
2276 .await
2277 .unwrap();
2278 assert_eq!(res.status(), StatusCode::OK);
2279 let members = body_json(res).await;
2280 assert_eq!(members[0]["principal"].as_str(), Some("elliott"));
2281 assert_eq!(members[0]["granted_by"].as_str(), Some("root"));
2282
2283 let res = app
2285 .clone()
2286 .oneshot(
2287 Request::builder()
2288 .method("POST")
2289 .uri("/namespaces/revoke")
2290 .header("authorization", admin.clone())
2291 .header("content-type", "application/json")
2292 .body(Body::from(
2293 serde_json::json!({ "namespace": "ns_ia_shared", "principal": "elliott" })
2294 .to_string(),
2295 ))
2296 .unwrap(),
2297 )
2298 .await
2299 .unwrap();
2300 assert_eq!(res.status(), StatusCode::NO_CONTENT);
2301 let res = write_into(app, rw, 4).await;
2302 assert_eq!(
2303 res.status(),
2304 StatusCode::FORBIDDEN,
2305 "revoked member is walled"
2306 );
2307 }
2308
2309 #[tokio::test]
2312 async fn doctrine_and_import_namespaces_stay_open() {
2313 let (app, auth) = app_with_store().await;
2314 let read = bearer(&auth, "elliott", MEMORY_READ);
2315
2316 let res = app
2317 .clone()
2318 .oneshot(
2319 Request::builder()
2320 .uri("/memories?namespace=ns_doctrine&limit=5")
2321 .header("authorization", read.clone())
2322 .body(Body::empty())
2323 .unwrap(),
2324 )
2325 .await
2326 .unwrap();
2327 assert_eq!(res.status(), StatusCode::OK, "doctrine is readable by all");
2328
2329 let res = app
2330 .clone()
2331 .oneshot(
2332 Request::builder()
2333 .uri("/memories?namespace=ns_import_probe&limit=5")
2334 .header("authorization", read)
2335 .body(Body::empty())
2336 .unwrap(),
2337 )
2338 .await
2339 .unwrap();
2340 assert_eq!(res.status(), StatusCode::OK, "import staging is open");
2341 }
2342
2343 #[tokio::test]
2344 async fn cross_principal_personal_namespace_is_forbidden() {
2345 let (app, auth) = app_with_store().await;
2346 let alice_write = bearer(&auth, "alice", MEMORY_WRITE);
2348 let _ = app
2349 .clone()
2350 .oneshot(
2351 Request::builder()
2352 .method("POST")
2353 .uri("/memories")
2354 .header("authorization", &alice_write)
2355 .header("content-type", "application/json")
2356 .body(Body::from(
2357 serde_json::json!({
2358 "id": "mem_a",
2359 "content": "alice only",
2360 "project": "x",
2361 "topic": "x",
2362 "source": "Explicit",
2363 "harness": "Pi",
2364 })
2365 .to_string(),
2366 ))
2367 .unwrap(),
2368 )
2369 .await
2370 .unwrap();
2371
2372 let bob_read = bearer(&auth, "bob", MEMORY_READ);
2374 let res = app
2375 .oneshot(
2376 Request::builder()
2377 .uri("/memories/mem_a?namespace=ns_alice_private")
2378 .header("authorization", &bob_read)
2379 .body(Body::empty())
2380 .unwrap(),
2381 )
2382 .await
2383 .unwrap();
2384 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2385 }
2386
2387 #[tokio::test]
2388 async fn doctrine_ingest_requires_admin_and_is_readable_shared() {
2389 let (app, auth) = app_with_store().await;
2390 let admin = bearer(&auth, "ci", "admin");
2391 let read = bearer(&auth, "anyone", MEMORY_READ);
2392
2393 let res = app
2395 .clone()
2396 .oneshot(
2397 Request::builder()
2398 .method("POST")
2399 .uri("/doctrine")
2400 .header("authorization", &read)
2401 .header("content-type", "application/json")
2402 .body(Body::from(
2403 serde_json::json!({
2404 "id": "d1",
2405 "content": "doctrine body",
2406 "project": "ijima",
2407 "topic": "arch",
2408 })
2409 .to_string(),
2410 ))
2411 .unwrap(),
2412 )
2413 .await
2414 .unwrap();
2415 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2416
2417 let res = app
2419 .clone()
2420 .oneshot(
2421 Request::builder()
2422 .method("POST")
2423 .uri("/doctrine")
2424 .header("authorization", &admin)
2425 .header("content-type", "application/json")
2426 .body(Body::from(
2427 serde_json::json!({
2428 "id": "d1",
2429 "content": "doctrine body",
2430 "project": "ijima",
2431 "topic": "arch",
2432 })
2433 .to_string(),
2434 ))
2435 .unwrap(),
2436 )
2437 .await
2438 .unwrap();
2439 assert_eq!(res.status(), StatusCode::OK);
2440
2441 let res = app
2443 .oneshot(
2444 Request::builder()
2445 .uri("/memories/d1?namespace=ns_doctrine")
2446 .header("authorization", &read)
2447 .body(Body::empty())
2448 .unwrap(),
2449 )
2450 .await
2451 .unwrap();
2452 assert_eq!(res.status(), StatusCode::OK);
2453 let mem: Memory = serde_json::from_slice(
2454 &axum::body::to_bytes(res.into_body(), usize::MAX)
2455 .await
2456 .unwrap(),
2457 )
2458 .unwrap();
2459 assert_eq!(mem.content, "doctrine body");
2460 assert_eq!(mem.source, ijima_core::memory::MemorySource::Doctrine);
2461 }
2462
2463 #[tokio::test]
2464 async fn wakeup_composes_personal_and_doctrine() {
2465 let (app, auth) = app_with_store().await;
2466 let write = bearer(&auth, "elliott", MEMORY_WRITE);
2467 let admin = bearer(&auth, "ci", "admin");
2468 let read = bearer(&auth, "elliott", MEMORY_READ);
2469
2470 let _ = app
2472 .clone()
2473 .oneshot(
2474 Request::builder()
2475 .method("POST")
2476 .uri("/memories")
2477 .header("authorization", &write)
2478 .header("content-type", "application/json")
2479 .body(Body::from(
2480 serde_json::json!({
2481 "id": "mem_p",
2482 "content": "personal essential",
2483 "project": "ijima",
2484 "topic": "x",
2485 "source": "Explicit",
2486 "harness": "Pi",
2487 })
2488 .to_string(),
2489 ))
2490 .unwrap(),
2491 )
2492 .await
2493 .unwrap();
2494
2495 let _ = app
2497 .clone()
2498 .oneshot(
2499 Request::builder()
2500 .method("POST")
2501 .uri("/doctrine")
2502 .header("authorization", &admin)
2503 .header("content-type", "application/json")
2504 .body(Body::from(
2505 serde_json::json!({
2506 "id": "doc_1",
2507 "content": "doctrine baseline",
2508 "project": "ijima",
2509 "topic": "arch",
2510 })
2511 .to_string(),
2512 ))
2513 .unwrap(),
2514 )
2515 .await
2516 .unwrap();
2517
2518 let res = app
2520 .oneshot(
2521 Request::builder()
2522 .uri("/wakeup")
2523 .header("authorization", &read)
2524 .body(Body::empty())
2525 .unwrap(),
2526 )
2527 .await
2528 .unwrap();
2529 assert_eq!(res.status(), StatusCode::OK);
2530 let body: serde_json::Value = serde_json::from_slice(
2531 &axum::body::to_bytes(res.into_body(), usize::MAX)
2532 .await
2533 .unwrap(),
2534 )
2535 .unwrap();
2536 assert_eq!(body["identity"]["principal"], "elliott");
2537 assert_eq!(body["personal_essentials"].as_array().unwrap().len(), 1);
2538 assert_eq!(
2539 body["personal_essentials"][0]["content"],
2540 "personal essential"
2541 );
2542 assert_eq!(body["doctrine"].as_array().unwrap().len(), 1);
2543 assert_eq!(body["doctrine"][0]["content"], "doctrine baseline");
2544 assert_eq!(body["doctrine"][0]["source"], "Doctrine");
2545 }
2546
2547 #[tokio::test]
2548 async fn knowledge_graph_add_query_invalidate() {
2549 let (app, auth) = app_with_store().await;
2550 let write = bearer(&auth, "elliott", "knowledge:write");
2551 let read = bearer(&auth, "elliott", "knowledge:read");
2552
2553 let res = app
2555 .clone()
2556 .oneshot(
2557 Request::builder()
2558 .method("POST")
2559 .uri("/kg/triples")
2560 .header("authorization", &write)
2561 .header("content-type", "application/json")
2562 .body(Body::from(
2563 serde_json::json!({
2564 "subject": "Ijima",
2565 "predicate": "depends_on",
2566 "object": "SurrealDB",
2567 "confidence": 1.0,
2568 })
2569 .to_string(),
2570 ))
2571 .unwrap(),
2572 )
2573 .await
2574 .unwrap();
2575 assert_eq!(res.status(), StatusCode::OK);
2576
2577 let res = app
2579 .clone()
2580 .oneshot(
2581 Request::builder()
2582 .uri("/kg/entities/Ijima")
2583 .header("authorization", &read)
2584 .body(Body::empty())
2585 .unwrap(),
2586 )
2587 .await
2588 .unwrap();
2589 assert_eq!(res.status(), StatusCode::OK);
2590 let body: serde_json::Value = serde_json::from_slice(
2591 &axum::body::to_bytes(res.into_body(), usize::MAX)
2592 .await
2593 .unwrap(),
2594 )
2595 .unwrap();
2596 assert_eq!(body["outgoing"].as_array().unwrap().len(), 1);
2597 assert_eq!(body["outgoing"][0]["object"], "SurrealDB");
2598 assert!(body["incoming"].as_array().unwrap().is_empty());
2599
2600 let res = app
2602 .clone()
2603 .oneshot(
2604 Request::builder()
2605 .uri("/kg/stats")
2606 .header("authorization", &read)
2607 .body(Body::empty())
2608 .unwrap(),
2609 )
2610 .await
2611 .unwrap();
2612 let body: serde_json::Value = serde_json::from_slice(
2613 &axum::body::to_bytes(res.into_body(), usize::MAX)
2614 .await
2615 .unwrap(),
2616 )
2617 .unwrap();
2618 assert_eq!(body["entities"], 2);
2619 assert_eq!(body["triples"], 1);
2620
2621 let res = app
2623 .oneshot(
2624 Request::builder()
2625 .method("POST")
2626 .uri("/kg/triples/Ijima:depends_on:SurrealDB/invalidate")
2627 .header("authorization", &write)
2628 .body(Body::empty())
2629 .unwrap(),
2630 )
2631 .await
2632 .unwrap();
2633 assert_eq!(res.status(), StatusCode::NO_CONTENT);
2634 }
2635
2636 #[tokio::test]
2637 async fn status_requires_admin_and_reports_counts() {
2638 let (app, auth) = app_with_store().await;
2639 let admin = bearer(&auth, "op", "admin");
2640 let read = bearer(&auth, "user", MEMORY_READ);
2641
2642 let _ = app
2644 .clone()
2645 .oneshot(
2646 Request::builder()
2647 .method("POST")
2648 .uri("/memories")
2649 .header("authorization", &admin)
2650 .header("content-type", "application/json")
2651 .body(Body::from(
2652 serde_json::json!({
2653 "id": "m1",
2654 "content": "stat test",
2655 "project": "x",
2656 "topic": "x",
2657 "source": "Explicit",
2658 "harness": "Pi",
2659 })
2660 .to_string(),
2661 ))
2662 .unwrap(),
2663 )
2664 .await
2665 .unwrap();
2666
2667 let res = app
2669 .clone()
2670 .oneshot(
2671 Request::builder()
2672 .uri("/status")
2673 .header("authorization", &read)
2674 .body(Body::empty())
2675 .unwrap(),
2676 )
2677 .await
2678 .unwrap();
2679 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2680
2681 let res = app
2683 .oneshot(
2684 Request::builder()
2685 .uri("/status")
2686 .header("authorization", &admin)
2687 .body(Body::empty())
2688 .unwrap(),
2689 )
2690 .await
2691 .unwrap();
2692 assert_eq!(res.status(), StatusCode::OK);
2693 let body: serde_json::Value = serde_json::from_slice(
2694 &axum::body::to_bytes(res.into_body(), usize::MAX)
2695 .await
2696 .unwrap(),
2697 )
2698 .unwrap();
2699 assert_eq!(body["memories"], 1);
2700 assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
2703 let uptime = body["uptime_secs"].as_u64().expect("uptime is u64");
2704 assert!(uptime < 60, "fresh test app should have tiny uptime");
2705 assert!(
2706 body["started_at_unix"].as_u64().expect("started_at is u64") > 1_000_000_000,
2707 "started_at looks like a unix timestamp"
2708 );
2709 assert!(!body["namespaces"].as_array().unwrap().is_empty());
2710 }
2711
2712 #[tokio::test]
2713 async fn sessions_create_list_end_via_http() {
2714 let (app, auth) = app_with_store().await;
2715 let ingest = bearer(&auth, "op", SESSION_INGEST);
2716 let read = bearer(&auth, "op", MEMORY_READ);
2717
2718 for (id, harness) in [("sess_a", "Pi"), ("sess_b", "Sakamoto")] {
2720 let res = app
2721 .clone()
2722 .oneshot(
2723 Request::builder()
2724 .method("POST")
2725 .uri("/sessions")
2726 .header("authorization", &ingest)
2727 .header("content-type", "application/json")
2728 .body(Body::from(
2729 serde_json::json!({
2730 "id": id,
2731 "harness": harness,
2732 "channel": "thread-1",
2733 "started_at": "2026-07-05T10:00:00Z",
2734 })
2735 .to_string(),
2736 ))
2737 .unwrap(),
2738 )
2739 .await
2740 .unwrap();
2741 assert_eq!(res.status(), StatusCode::OK);
2742 }
2743
2744 let res = app
2746 .clone()
2747 .oneshot(
2748 Request::builder()
2749 .uri("/sessions")
2750 .header("authorization", &read)
2751 .body(Body::empty())
2752 .unwrap(),
2753 )
2754 .await
2755 .unwrap();
2756 assert_eq!(res.status(), StatusCode::OK);
2757 let body: serde_json::Value = serde_json::from_slice(
2758 &axum::body::to_bytes(res.into_body(), usize::MAX)
2759 .await
2760 .unwrap(),
2761 )
2762 .unwrap();
2763 let arr = body.as_array().unwrap();
2764 assert_eq!(arr.len(), 2);
2765
2766 let res = app
2768 .clone()
2769 .oneshot(
2770 Request::builder()
2771 .uri("/sessions?harness=pi")
2772 .header("authorization", &read)
2773 .body(Body::empty())
2774 .unwrap(),
2775 )
2776 .await
2777 .unwrap();
2778 let body: serde_json::Value = serde_json::from_slice(
2779 &axum::body::to_bytes(res.into_body(), usize::MAX)
2780 .await
2781 .unwrap(),
2782 )
2783 .unwrap();
2784 assert_eq!(body.as_array().unwrap().len(), 1);
2785 assert_eq!(body[0]["harness"], "Pi");
2786
2787 let res = app
2789 .clone()
2790 .oneshot(
2791 Request::builder()
2792 .method("POST")
2793 .uri("/sessions/sess_a/end")
2794 .header("authorization", &ingest)
2795 .header("content-type", "application/json")
2796 .body(Body::from(
2797 serde_json::json!({ "ended_at": "2026-07-05T11:00:00Z" }).to_string(),
2798 ))
2799 .unwrap(),
2800 )
2801 .await
2802 .unwrap();
2803 assert_eq!(res.status(), StatusCode::NO_CONTENT);
2804
2805 let res = app
2807 .oneshot(
2808 Request::builder()
2809 .uri("/sessions?harness=pi")
2810 .header("authorization", &read)
2811 .body(Body::empty())
2812 .unwrap(),
2813 )
2814 .await
2815 .unwrap();
2816 let body: serde_json::Value = serde_json::from_slice(
2817 &axum::body::to_bytes(res.into_body(), usize::MAX)
2818 .await
2819 .unwrap(),
2820 )
2821 .unwrap();
2822 assert_eq!(body[0]["ended_at"], "2026-07-05T11:00:00Z");
2823 }
2824
2825 #[tokio::test]
2826 async fn mining_queue_requires_review_capability() {
2827 let (app, auth) = app_with_store().await;
2828 let reviewer = bearer(&auth, "op", MINING_REVIEW);
2829 let reader = bearer(&auth, "op", MEMORY_READ);
2830
2831 let res = app
2833 .clone()
2834 .oneshot(
2835 Request::builder()
2836 .uri("/mining/queue")
2837 .header("authorization", &reader)
2838 .body(Body::empty())
2839 .unwrap(),
2840 )
2841 .await
2842 .unwrap();
2843 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2844
2845 let res = app
2847 .oneshot(
2848 Request::builder()
2849 .uri("/mining/queue")
2850 .header("authorization", &reviewer)
2851 .body(Body::empty())
2852 .unwrap(),
2853 )
2854 .await
2855 .unwrap();
2856 assert_eq!(res.status(), StatusCode::OK);
2857 let body: serde_json::Value = serde_json::from_slice(
2858 &axum::body::to_bytes(res.into_body(), usize::MAX)
2859 .await
2860 .unwrap(),
2861 )
2862 .unwrap();
2863 assert!(body.as_array().unwrap().is_empty());
2864 }
2865
2866 fn hit_mem(id: &str, sim: f32) -> SearchHit {
2867 SearchHit {
2868 memory: Memory {
2869 id: MemoryId(id.into()),
2870 content: id.into(),
2871 project: "p".into(),
2872 topic: "t".into(),
2873 source: ijima_core::MemorySource::Explicit,
2874 harness: ijima_core::harness::Harness::Pi,
2875 session_id: None,
2876 origin: ijima_core::InstanceId::local(),
2877 authority: ijima_core::AuthorityScope::local(),
2878 importance: 0.5,
2879 created_at: "0".into(),
2880 },
2881 similarity: sim,
2882 }
2883 }
2884
2885 #[test]
2886 fn merge_search_hits_ranks_desc_dedups_and_truncates() {
2887 let a = vec![hit_mem("a", 0.9), hit_mem("b", 0.5)];
2890 let b = vec![hit_mem("c", 0.8), hit_mem("a", 0.7)]; let merged = merge_search_hits(a, b, 3);
2892 assert_eq!(merged.len(), 3);
2895 assert_eq!(merged[0].memory.id.0, "a");
2896 assert_eq!((merged[0].similarity * 10.0).round() as i32, 9);
2897 assert_eq!(merged[1].memory.id.0, "c");
2898 assert_eq!(merged[2].memory.id.0, "b");
2899 }
2900
2901 #[test]
2902 fn merge_search_hits_respects_limit() {
2903 let a = vec![hit_mem("a", 0.9), hit_mem("b", 0.8)];
2904 let b = vec![hit_mem("c", 0.7), hit_mem("d", 0.6)];
2905 let merged = merge_search_hits(a, b, 2);
2906 assert_eq!(merged.len(), 2);
2907 assert_eq!(merged[0].memory.id.0, "a");
2908 assert_eq!(merged[1].memory.id.0, "b");
2909 }
2910
2911 #[cfg(feature = "mining")]
2912 #[tokio::test]
2913 async fn trigger_requires_mining_trigger_capability() {
2914 let (app, auth) = app_with_store().await;
2915 let write = bearer(&auth, "elliott", MEMORY_WRITE);
2917 let res = app
2918 .oneshot(
2919 Request::builder()
2920 .method("POST")
2921 .uri("/sessions/sess_x/mine")
2922 .header("authorization", &write)
2923 .body(Body::empty())
2924 .unwrap(),
2925 )
2926 .await
2927 .unwrap();
2928 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2929 }
2930
2931 #[cfg(feature = "mining")]
2932 #[tokio::test]
2933 async fn trigger_mines_decision_and_archives() {
2934 let (app, auth) = app_with_store().await;
2938 let ingest = bearer(&auth, "elliott", SESSION_INGEST);
2939 let trigger = bearer(&auth, "elliott", MINING_TRIGGER);
2940
2941 let turn = serde_json::json!({
2943 "session_id": "sess_mine",
2944 "turn_index": 0,
2945 "role": "User",
2946 "content": "We decided to use SurrealDB for storage.",
2947 "timestamp": "0",
2948 });
2949 let res = app
2950 .clone()
2951 .oneshot(
2952 Request::builder()
2953 .method("POST")
2954 .uri("/sessions/sess_mine/turns")
2955 .header("authorization", &ingest)
2956 .header("content-type", "application/json")
2957 .body(Body::from(turn.to_string()))
2958 .unwrap(),
2959 )
2960 .await
2961 .unwrap();
2962 assert_eq!(res.status(), StatusCode::NO_CONTENT);
2963
2964 let res = app
2966 .oneshot(
2967 Request::builder()
2968 .method("POST")
2969 .uri("/sessions/sess_mine/mine")
2970 .header("authorization", &trigger)
2971 .body(Body::empty())
2972 .unwrap(),
2973 )
2974 .await
2975 .unwrap();
2976 assert_eq!(res.status(), StatusCode::OK);
2977 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2978 .await
2979 .unwrap();
2980 let report: crate::mining_pipeline::MiningReport = serde_json::from_slice(&body).unwrap();
2981 assert!(
2982 report.archived >= 1,
2983 "rules tier should archive the decision: {report:?}"
2984 );
2985 }
2986
2987 async fn body_json(res: axum::response::Response) -> serde_json::Value {
2990 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2991 .await
2992 .unwrap();
2993 serde_json::from_slice(&body).unwrap()
2994 }
2995
2996 async fn seed_memory(app: &Router, auth: &IjimaAuth, id: &str, project: &str, topic: &str) {
2997 let body = serde_json::json!({
2998 "id": id,
2999 "content": format!("{project}/{topic} note"),
3000 "project": project,
3001 "topic": topic,
3002 "source": "Explicit",
3003 "harness": "Pi",
3004 "session_id": "sess_1",
3005 "importance": 0.5,
3006 "created_at": "0",
3007 })
3008 .to_string();
3009 let res = app
3010 .clone()
3011 .oneshot(
3012 Request::builder()
3013 .method("POST")
3014 .uri("/memories")
3015 .header("authorization", bearer(auth, "elliott", MEMORY_WRITE))
3016 .header("content-type", "application/json")
3017 .body(Body::from(body))
3018 .unwrap(),
3019 )
3020 .await
3021 .unwrap();
3022 assert_eq!(res.status(), StatusCode::OK, "seed {id} failed");
3023 }
3024
3025 #[tokio::test]
3026 async fn store_memory_honors_namespace_query() {
3027 let (app, auth) = app_with_store().await;
3028 let body = serde_json::json!({
3029 "id": "mem_nsimp",
3030 "content": "imported via namespace query",
3031 "project": "ijima",
3032 "topic": "import",
3033 "source": "AutoCapture",
3034 "harness": "Pi",
3035 "importance": 0.5,
3036 "created_at": "0",
3037 })
3038 .to_string();
3039 let res = app
3040 .clone()
3041 .oneshot(
3042 Request::builder()
3043 .method("POST")
3044 .uri("/memories?namespace=ns_import_testbox")
3045 .header("authorization", bearer(&auth, "elliott", MEMORY_WRITE))
3046 .header("content-type", "application/json")
3047 .body(Body::from(body))
3048 .unwrap(),
3049 )
3050 .await
3051 .unwrap();
3052 assert_eq!(res.status(), StatusCode::OK);
3053
3054 let read_token = bearer(&auth, "elliott", MEMORY_READ);
3057 let check = |uri: &str| {
3058 let uri = uri.to_string();
3059 let app = app.clone();
3060 let body = serde_json::json!({
3061 "content": "imported via namespace query"
3062 })
3063 .to_string();
3064 let auth_header = read_token.clone();
3065 async move {
3066 app.oneshot(
3067 Request::builder()
3068 .method("POST")
3069 .uri(uri)
3070 .header("authorization", auth_header)
3071 .header("content-type", "application/json")
3072 .body(Body::from(body))
3073 .unwrap(),
3074 )
3075 .await
3076 .unwrap()
3077 }
3078 };
3079 let res = check("/memories/check?namespace=ns_import_testbox").await;
3080 assert_eq!(res.status(), StatusCode::OK);
3081 let found = body_json(res).await;
3082 assert_eq!(
3083 found["duplicate"].as_str(),
3084 Some("mem_nsimp"),
3085 "same-namespace dedup check must find the import"
3086 );
3087 let res = check("/memories/check").await;
3088 assert_eq!(res.status(), StatusCode::OK);
3089 let personal = body_json(res).await;
3090 assert_eq!(
3091 personal["duplicate"].as_str(),
3092 None,
3093 "personal namespace must not see the import"
3094 );
3095 }
3096
3097 #[tokio::test]
3098 async fn store_memory_rejects_foreign_private_namespace() {
3099 let (app, auth) = app_with_store().await;
3100 let body = serde_json::json!({
3101 "id": "mem_sneaky",
3102 "content": "cross-tenant write attempt",
3103 "project": "ijima",
3104 "topic": "security",
3105 "source": "Explicit",
3106 "harness": "Pi",
3107 "importance": 0.5,
3108 "created_at": "0",
3109 })
3110 .to_string();
3111 let res = app
3112 .oneshot(
3113 Request::builder()
3114 .method("POST")
3115 .uri("/memories?namespace=ns_bob_private")
3116 .header("authorization", bearer(&auth, "elliott", MEMORY_WRITE))
3117 .header("content-type", "application/json")
3118 .body(Body::from(body))
3119 .unwrap(),
3120 )
3121 .await
3122 .unwrap();
3123 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3124 }
3125
3126 #[tokio::test]
3127 async fn rooms_taxonomy_stats_reflect_seeded_memories() {
3128 let (app, auth) = app_with_store().await;
3129 seed_memory(&app, &auth, "mem_a", "ijima", "api").await;
3130 seed_memory(&app, &auth, "mem_b", "ijima", "auth").await;
3131 let read = bearer(&auth, "elliott", MEMORY_READ);
3132
3133 let res = app
3135 .clone()
3136 .oneshot(
3137 Request::builder()
3138 .uri("/rooms")
3139 .header("authorization", &read)
3140 .body(Body::empty())
3141 .unwrap(),
3142 )
3143 .await
3144 .unwrap();
3145 assert_eq!(res.status(), StatusCode::OK);
3146 let rooms = body_json(res).await;
3147 let topics: std::collections::HashSet<&str> = rooms
3148 .as_array()
3149 .unwrap()
3150 .iter()
3151 .map(|r| r["topic"].as_str().unwrap())
3152 .collect();
3153 assert!(
3154 topics.contains("api") && topics.contains("auth"),
3155 "rooms: {rooms}"
3156 );
3157
3158 let res = app
3160 .clone()
3161 .oneshot(
3162 Request::builder()
3163 .uri("/memories/stats")
3164 .header("authorization", &read)
3165 .body(Body::empty())
3166 .unwrap(),
3167 )
3168 .await
3169 .unwrap();
3170 assert_eq!(res.status(), StatusCode::OK);
3171 let stats = body_json(res).await;
3172 assert_eq!(stats["total"], 2, "stats: {stats}");
3173 assert_eq!(stats["projects"][0]["project"], "ijima");
3174 assert_eq!(stats["projects"][0]["count"], 2);
3175 }
3176
3177 #[tokio::test]
3178 async fn browse_memories_filters_by_project() {
3179 let (app, auth) = app_with_store().await;
3180 seed_memory(&app, &auth, "mem_a", "ijima", "api").await;
3181 seed_memory(&app, &auth, "mem_b", "possum", "efficiency").await;
3182 let read = bearer(&auth, "elliott", MEMORY_READ);
3183
3184 let res = app
3185 .clone()
3186 .oneshot(
3187 Request::builder()
3188 .uri("/memories?project=possum")
3189 .header("authorization", &read)
3190 .body(Body::empty())
3191 .unwrap(),
3192 )
3193 .await
3194 .unwrap();
3195 assert_eq!(res.status(), StatusCode::OK);
3196 let mems = body_json(res).await;
3197 let arr = mems.as_array().unwrap();
3198 assert_eq!(arr.len(), 1);
3199 assert_eq!(arr[0]["project"], "possum");
3200 }
3201
3202 #[tokio::test]
3203 async fn palace_graph_and_tunnel_link_shared_topic() {
3204 let (app, auth) = app_with_store().await;
3205 seed_memory(&app, &auth, "mem_a", "ijima", "efficiency").await;
3206 seed_memory(&app, &auth, "mem_b", "possum", "efficiency").await;
3207 let read = bearer(&auth, "elliott", MEMORY_READ);
3208
3209 let res = app
3210 .clone()
3211 .oneshot(
3212 Request::builder()
3213 .uri("/palace/graph")
3214 .header("authorization", &read)
3215 .body(Body::empty())
3216 .unwrap(),
3217 )
3218 .await
3219 .unwrap();
3220 assert_eq!(res.status(), StatusCode::OK);
3221 let graph = body_json(res).await;
3222 let projects: std::collections::HashSet<&str> = graph["projects"]
3223 .as_array()
3224 .unwrap()
3225 .iter()
3226 .map(|p| p.as_str().unwrap())
3227 .collect();
3228 assert!(
3229 projects.contains("ijima") && projects.contains("possum"),
3230 "graph: {graph}"
3231 );
3232
3233 let res = app
3234 .clone()
3235 .oneshot(
3236 Request::builder()
3237 .uri("/palace/tunnel?topic=efficiency&project_a=ijima&project_b=possum")
3238 .header("authorization", &read)
3239 .body(Body::empty())
3240 .unwrap(),
3241 )
3242 .await
3243 .unwrap();
3244 assert_eq!(res.status(), StatusCode::OK);
3245 let trav = body_json(res).await;
3246 assert_eq!(trav["memories_a"].as_array().unwrap().len(), 1);
3247 assert_eq!(trav["memories_b"].as_array().unwrap().len(), 1);
3248 }
3249
3250 #[tokio::test]
3251 async fn diary_write_then_read_round_trips() {
3252 let (app, auth) = app_with_store().await;
3253 let write = bearer(&auth, "elliott", MEMORY_WRITE);
3254 let read = bearer(&auth, "elliott", MEMORY_READ);
3255
3256 let body = serde_json::json!({
3257 "agent": "pi",
3258 "content": "shipped the routes",
3259 "topic": "ijima",
3260 "timestamp": "2026-08-09T12:00:00Z"
3261 })
3262 .to_string();
3263 let res = app
3264 .clone()
3265 .oneshot(
3266 Request::builder()
3267 .method("POST")
3268 .uri("/diaries")
3269 .header("authorization", &write)
3270 .header("content-type", "application/json")
3271 .body(Body::from(body))
3272 .unwrap(),
3273 )
3274 .await
3275 .unwrap();
3276 assert_eq!(res.status(), StatusCode::NO_CONTENT);
3277
3278 let res = app
3279 .clone()
3280 .oneshot(
3281 Request::builder()
3282 .uri("/diaries/pi")
3283 .header("authorization", &read)
3284 .body(Body::empty())
3285 .unwrap(),
3286 )
3287 .await
3288 .unwrap();
3289 assert_eq!(res.status(), StatusCode::OK);
3290 let entries = body_json(res).await;
3291 let arr = entries.as_array().unwrap();
3292 assert_eq!(arr.len(), 1);
3293 assert_eq!(arr[0]["content"], "shipped the routes");
3294 }
3295
3296 #[tokio::test]
3297 async fn diary_write_requires_memory_write_not_read() {
3298 let (app, auth) = app_with_store().await;
3299 let read = bearer(&auth, "elliott", MEMORY_READ);
3300 let body = serde_json::json!({"agent": "pi", "content": "x", "timestamp": "t"}).to_string();
3301 let res = app
3302 .clone()
3303 .oneshot(
3304 Request::builder()
3305 .method("POST")
3306 .uri("/diaries")
3307 .header("authorization", &read)
3308 .header("content-type", "application/json")
3309 .body(Body::from(body))
3310 .unwrap(),
3311 )
3312 .await
3313 .unwrap();
3314 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3315 }
3316
3317 #[tokio::test]
3318 async fn repo_register_list_resolve_round_trips() {
3319 let (app, auth) = app_with_store().await;
3320 let admin = bearer(&auth, "elliott", ADMIN);
3321 let read = bearer(&auth, "elliott", MEMORY_READ);
3322
3323 let body = serde_json::json!({
3325 "name": "Ijima",
3326 "path": "/home/x/Ijima",
3327 "remote_url": "git@github.com:Industrial-Algebra/Ijima.git",
3328 "role": "memory-service"
3329 })
3330 .to_string();
3331 let res = app
3332 .clone()
3333 .oneshot(
3334 Request::builder()
3335 .method("POST")
3336 .uri("/repos")
3337 .header("authorization", &admin)
3338 .header("content-type", "application/json")
3339 .body(Body::from(body))
3340 .unwrap(),
3341 )
3342 .await
3343 .unwrap();
3344 assert_eq!(res.status(), StatusCode::NO_CONTENT);
3345
3346 let res = app
3348 .clone()
3349 .oneshot(
3350 Request::builder()
3351 .uri("/repos")
3352 .header("authorization", &read)
3353 .body(Body::empty())
3354 .unwrap(),
3355 )
3356 .await
3357 .unwrap();
3358 assert_eq!(res.status(), StatusCode::OK);
3359 let repos = body_json(res).await;
3360 assert_eq!(repos[0]["name"], "Ijima");
3361 assert_eq!(repos[0]["path"], "/home/x/Ijima");
3362
3363 let res = app
3365 .clone()
3366 .oneshot(
3367 Request::builder()
3368 .uri("/repos/resolve?cwd=/home/x/Ijima/src")
3369 .header("authorization", &read)
3370 .body(Body::empty())
3371 .unwrap(),
3372 )
3373 .await
3374 .unwrap();
3375 assert_eq!(res.status(), StatusCode::OK);
3376 let repo = body_json(res).await;
3377 assert_eq!(repo["name"], "Ijima");
3378
3379 let res = app
3381 .clone()
3382 .oneshot(
3383 Request::builder()
3384 .uri("/repos/resolve?cwd=/nowhere/here")
3385 .header("authorization", &read)
3386 .body(Body::empty())
3387 .unwrap(),
3388 )
3389 .await
3390 .unwrap();
3391 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3392 }
3393
3394 #[tokio::test]
3395 async fn repo_register_requires_admin() {
3396 let (app, auth) = app_with_store().await;
3397 let read = bearer(&auth, "elliott", MEMORY_READ);
3398 let body = serde_json::json!({
3399 "name": "X", "path": "/x", "remote_url": "u", "role": "r"
3400 })
3401 .to_string();
3402 let res = app
3403 .clone()
3404 .oneshot(
3405 Request::builder()
3406 .method("POST")
3407 .uri("/repos")
3408 .header("authorization", &read)
3409 .header("content-type", "application/json")
3410 .body(Body::from(body))
3411 .unwrap(),
3412 )
3413 .await
3414 .unwrap();
3415 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3416 }
3417
3418 #[tokio::test]
3419 async fn token_revocation_kills_the_bearer_immediately() {
3420 let (app, auth) = app_with_store().await;
3421 let admin = bearer(&auth, "op", ADMIN);
3422 let victim = bearer(&auth, "victim", MEMORY_READ);
3423
3424 let res = app
3426 .clone()
3427 .oneshot(
3428 Request::builder()
3429 .uri("/memories")
3430 .header("authorization", &victim)
3431 .body(Body::empty())
3432 .unwrap(),
3433 )
3434 .await
3435 .unwrap();
3436 assert_eq!(res.status(), StatusCode::OK);
3437
3438 let res = app
3440 .clone()
3441 .oneshot(
3442 Request::builder()
3443 .method("POST")
3444 .uri("/tokens/revoke")
3445 .header("authorization", &victim)
3446 .header("content-type", "application/json")
3447 .body(Body::from(
3448 serde_json::json!({ "token": victim, "reason": "test" }).to_string(),
3449 ))
3450 .unwrap(),
3451 )
3452 .await
3453 .unwrap();
3454 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3455
3456 let res = app
3458 .clone()
3459 .oneshot(
3460 Request::builder()
3461 .method("POST")
3462 .uri("/tokens/revoke")
3463 .header("authorization", &admin)
3464 .header("content-type", "application/json")
3465 .body(Body::from(
3466 serde_json::json!({ "token": victim, "reason": "leaked in test" })
3467 .to_string(),
3468 ))
3469 .unwrap(),
3470 )
3471 .await
3472 .unwrap();
3473 assert_eq!(res.status(), StatusCode::NO_CONTENT);
3474
3475 let res = app
3477 .clone()
3478 .oneshot(
3479 Request::builder()
3480 .uri("/memories")
3481 .header("authorization", &victim)
3482 .body(Body::empty())
3483 .unwrap(),
3484 )
3485 .await
3486 .unwrap();
3487 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
3488
3489 let res = app
3491 .clone()
3492 .oneshot(
3493 Request::builder()
3494 .uri("/tokens/revocations")
3495 .header("authorization", &admin)
3496 .body(Body::empty())
3497 .unwrap(),
3498 )
3499 .await
3500 .unwrap();
3501 assert_eq!(res.status(), StatusCode::OK);
3502 let body: serde_json::Value = serde_json::from_slice(
3503 &axum::body::to_bytes(res.into_body(), usize::MAX)
3504 .await
3505 .unwrap(),
3506 )
3507 .unwrap();
3508 let revs = body.as_array().expect("list response");
3509 assert_eq!(revs.len(), 1);
3510 assert_eq!(revs[0]["reason"], "leaked in test");
3511 assert_eq!(
3512 revs[0]["token_hash"].as_str().expect("hash"),
3513 crate::auth::bearer_hash(&victim)
3514 );
3515 assert!(!revs[0].to_string().contains(&victim), "no raw bearer");
3516
3517 let listed: Vec<TokenRevocation> =
3520 serde_json::from_value(body).expect("deserializes as TokenRevocation");
3521 auth.hydrate_revocations(&listed);
3522 assert!(auth.is_revoked(&victim));
3523 }
3524}