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 Query(q): Query<NsQuery>,
809 Json(req): Json<AddTripleRequest>,
810) -> Result<Json<ijima_core::Triple>, ApiError> {
811 if !principal.0.may(ijima_core::capabilities::KNOWLEDGE_WRITE) {
812 return Err(ApiError::Forbidden);
813 }
814 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
815 let triple = kg
816 .add_triple(
817 &ns,
818 EntityId::new(req.subject),
819 &req.predicate,
820 EntityId::new(req.object),
821 req.valid_from.as_deref(),
822 req.confidence.unwrap_or(1.0),
823 req.source_memory_id.as_deref(),
824 )
825 .await
826 .map_err(internal)?;
827 let _ = store;
829 Ok(Json(triple))
830}
831
832async fn query_entity(
833 principal: AuthPrincipal,
834 Extension(store): Extension<Arc<dyn Store>>,
835 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
836 Path(id): Path<String>,
837 Query(q): Query<NsQuery>,
838) -> Result<Json<ijima_core::EntityRecord>, ApiError> {
839 if !principal.0.may(KNOWLEDGE_READ) {
840 return Err(ApiError::Forbidden);
841 }
842 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
843 let rec = kg
844 .query_entity(&ns, &EntityId::new(id))
845 .await
846 .map_err(internal)?;
847 Ok(Json(rec))
848}
849
850async fn invalidate_triple(
851 principal: AuthPrincipal,
852 Extension(store): Extension<Arc<dyn Store>>,
853 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
854 Path(id): Path<String>,
855 Query(q): Query<NsQuery>,
856) -> Result<StatusCode, ApiError> {
857 if !principal.0.may(ijima_core::capabilities::KNOWLEDGE_WRITE) {
858 return Err(ApiError::Forbidden);
859 }
860 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
861 kg.invalidate_triple(&ns, &id).await.map_err(internal)?;
862 Ok(StatusCode::NO_CONTENT)
863}
864
865#[derive(Deserialize, Default)]
866struct FindTriplesQuery {
867 namespace: Option<String>,
868 subject: Option<String>,
869 predicate: Option<String>,
870 object: Option<String>,
871}
872
873async fn find_triples(
874 principal: AuthPrincipal,
875 Extension(store): Extension<Arc<dyn Store>>,
876 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
877 Query(q): Query<FindTriplesQuery>,
878) -> Result<Json<Vec<ijima_core::Triple>>, ApiError> {
879 if !principal.0.may(KNOWLEDGE_READ) {
880 return Err(ApiError::Forbidden);
881 }
882 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
883 let triples = kg
884 .find_triples(
885 &ns,
886 q.subject.as_deref().map(EntityId::new).as_ref(),
887 q.predicate.as_deref(),
888 q.object.as_deref().map(EntityId::new).as_ref(),
889 )
890 .await
891 .map_err(internal)?;
892 Ok(Json(triples))
893}
894
895async fn kg_timeline(
896 principal: AuthPrincipal,
897 Extension(store): Extension<Arc<dyn Store>>,
898 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
899 Query(q): Query<NsQuery>,
900) -> Result<Json<Vec<ijima_core::Triple>>, ApiError> {
901 if !principal.0.may(KNOWLEDGE_READ) {
902 return Err(ApiError::Forbidden);
903 }
904 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
905 let triples = kg
906 .kg_timeline(&ns, q.limit.unwrap_or(50))
907 .await
908 .map_err(internal)?;
909 Ok(Json(triples))
910}
911
912async fn kg_stats(
913 principal: AuthPrincipal,
914 Extension(store): Extension<Arc<dyn Store>>,
915 Extension(kg): Extension<Arc<dyn KnowledgeGraph>>,
916 Query(q): Query<NsQuery>,
917) -> Result<Json<ijima_core::KgStats>, ApiError> {
918 if !principal.0.may(KNOWLEDGE_READ) {
919 return Err(ApiError::Forbidden);
920 }
921 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
922 let stats = kg.knowledge_stats(&ns).await.map_err(internal)?;
923 Ok(Json(stats))
924}
925
926async fn ingest_turn(
927 principal: AuthPrincipal,
928 Extension(store): Extension<Arc<dyn Store>>,
929 Path(session_id): Path<String>,
930 Json(mut turn): Json<SessionTurn>,
931) -> Result<StatusCode, ApiError> {
932 if !principal.0.may(SESSION_INGEST) {
933 return Err(ApiError::Forbidden);
934 }
935 let ns = principal.0.personal_namespace();
936 turn.session_id = SessionId::new(session_id);
937 store.ingest_turn(&ns, turn).await.map_err(internal)?;
938 Ok(StatusCode::NO_CONTENT)
939}
940
941#[derive(Serialize)]
944struct TurnsResponse {
945 turns: Vec<SessionTurn>,
946}
947
948async fn session_turns(
949 principal: AuthPrincipal,
950 Extension(store): Extension<Arc<dyn Store>>,
951 Path(session_id): Path<String>,
952 Query(q): Query<NsQuery>,
953) -> Result<Json<TurnsResponse>, ApiError> {
954 if !principal.0.may(MEMORY_READ) {
955 return Err(ApiError::Forbidden);
956 }
957 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
958 let turns = store
959 .session_turns(&ns, &SessionId::new(session_id), q.limit.unwrap_or(50))
960 .await
961 .map_err(internal)?;
962 Ok(Json(TurnsResponse { turns }))
963}
964
965async fn create_session(
970 principal: AuthPrincipal,
971 Extension(store): Extension<Arc<dyn Store>>,
972 Json(mut session): Json<Session>,
973) -> Result<Json<IdResponse>, ApiError> {
974 if !principal.0.may(SESSION_INGEST) {
975 return Err(ApiError::Forbidden);
976 }
977 let ns = principal.0.personal_namespace();
978 if session.started_at.is_empty() {
979 session.started_at = std::time::SystemTime::now()
980 .duration_since(std::time::UNIX_EPOCH)
981 .map(|d| d.as_secs().to_string())
982 .unwrap_or_default();
983 }
984 session.ended_at = None;
985 let id = store.create_session(&ns, session).await.map_err(internal)?;
986 Ok(Json(IdResponse { id: id.0 }))
987}
988
989#[derive(Deserialize)]
990struct SessionListQuery {
991 namespace: Option<String>,
992 harness: Option<String>,
994 limit: Option<usize>,
995}
996
997async fn list_sessions(
1001 principal: AuthPrincipal,
1002 Extension(store): Extension<Arc<dyn Store>>,
1003 Query(q): Query<SessionListQuery>,
1004) -> Result<Json<Vec<Session>>, ApiError> {
1005 if !principal.0.may(MEMORY_READ) {
1006 return Err(ApiError::Forbidden);
1007 }
1008 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1009 let harness = q.harness.as_deref().map(Harness::from_wire_str);
1010 let limit = q.limit.unwrap_or(50).min(500);
1011 let sessions = store
1012 .list_sessions(&ns, harness.as_ref(), limit)
1013 .await
1014 .map_err(internal)?;
1015 Ok(Json(sessions))
1016}
1017
1018#[derive(Deserialize)]
1019struct EndSessionRequest {
1020 ended_at: String,
1021}
1022
1023async fn end_session(
1026 principal: AuthPrincipal,
1027 Extension(store): Extension<Arc<dyn Store>>,
1028 Path(session_id): Path<String>,
1029 Json(req): Json<EndSessionRequest>,
1030) -> Result<StatusCode, ApiError> {
1031 if !principal.0.may(SESSION_INGEST) {
1032 return Err(ApiError::Forbidden);
1033 }
1034 let ns = principal.0.personal_namespace();
1035 store
1036 .end_session(&ns, &SessionId::new(session_id), req.ended_at)
1037 .await
1038 .map_err(internal)?;
1039 Ok(StatusCode::NO_CONTENT)
1040}
1041
1042async fn list_pending(
1047 principal: AuthPrincipal,
1048 Extension(store): Extension<Arc<dyn Store>>,
1049 Query(q): Query<NsQuery>,
1050) -> Result<Json<Vec<QueuedExtraction>>, ApiError> {
1051 if !principal.0.may(MINING_REVIEW) {
1052 return Err(ApiError::Forbidden);
1053 }
1054 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1055 let limit = q.limit.unwrap_or(50).min(500);
1056 let pending = store.list_pending(&ns, limit).await.map_err(internal)?;
1057 Ok(Json(pending))
1058}
1059
1060async fn accept_extraction(
1063 principal: AuthPrincipal,
1064 Extension(store): Extension<Arc<dyn Store>>,
1065 Path(id): Path<String>,
1066) -> Result<Json<AcceptedExtraction>, ApiError> {
1067 if !principal.0.may(MINING_REVIEW) {
1068 return Err(ApiError::Forbidden);
1069 }
1070 let ns = principal.0.personal_namespace();
1071 let accepted = store.accept_extraction(&ns, &id).await.map_err(internal)?;
1072 Ok(Json(accepted))
1073}
1074
1075async fn reject_extraction(
1078 principal: AuthPrincipal,
1079 Extension(store): Extension<Arc<dyn Store>>,
1080 Path(id): Path<String>,
1081) -> Result<StatusCode, ApiError> {
1082 if !principal.0.may(MINING_REVIEW) {
1083 return Err(ApiError::Forbidden);
1084 }
1085 let ns = principal.0.personal_namespace();
1086 store.reject_extraction(&ns, &id).await.map_err(internal)?;
1087 Ok(StatusCode::NO_CONTENT)
1088}
1089
1090#[cfg(feature = "mining")]
1104async fn trigger_mine(
1105 principal: AuthPrincipal,
1106 Extension(store): Extension<Arc<dyn Store>>,
1107 Path(session_id): Path<String>,
1108 Query(q): Query<NsQuery>,
1109) -> Result<Json<crate::mining_pipeline::MiningReport>, ApiError> {
1110 use proserpina_agent::http::HttpAgent;
1111
1112 if !principal.0.may(MINING_TRIGGER) {
1113 return Err(ApiError::Forbidden);
1114 }
1115 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1116
1117 let turns = store
1119 .session_turns(&ns, &SessionId::new(session_id.clone()), 10_000)
1120 .await
1121 .map_err(internal)?;
1122 let turn_texts: Vec<String> = turns.into_iter().map(|t| t.content).collect();
1123 let ctx = crate::mining_pipeline::mining_context(&session_id, "general", Harness::Other);
1124
1125 let extractions = tokio::task::spawn_blocking(move || {
1132 let mut agent: Option<HttpAgent> = build_mining_agent();
1133 let agent_dyn: Option<&mut dyn proserpina_agent::Agent> = agent
1134 .as_mut()
1135 .map(|a| a as &mut dyn proserpina_agent::Agent);
1136 ijima_miner::mine_all(&turn_texts, &ctx, agent_dyn)
1137 })
1138 .await
1139 .map_err(|e| {
1140 internal(ijima_core::IjimaError::Mining {
1141 detail: format!("extraction task failed: {e}"),
1142 })
1143 })?
1144 .map_err(internal)?;
1145
1146 let report = crate::mining_pipeline::ingest_extractions(store.as_ref(), &ns, extractions)
1147 .await
1148 .map_err(internal)?;
1149 Ok(Json(report))
1150}
1151
1152#[cfg(feature = "mining")]
1162fn build_mining_agent() -> Option<proserpina_agent::http::HttpAgent> {
1163 use proserpina_agent::{
1164 AgentId, Persona,
1165 http::{HttpAgent, HttpConfig},
1166 };
1167
1168 let base_url = std::env::var("IJIMA_LLM_BASE_URL")
1169 .unwrap_or_else(|_| "https://api.deepseek.com/v1".to_string());
1170 let model = std::env::var("IJIMA_LLM_MODEL").ok()?;
1171 let api_key = std::env::var("IJIMA_LLM_API_KEY").ok()?;
1172
1173 let persona = Persona::new("Session Mining Extractor")
1174 .with_framing(
1175 "You mine session transcripts for durable facts and recurring \
1176 patterns. Output one JSON object per line, each \
1177 {\"content\",\"project\",\"topic\",\"confidence\"}. Omit all \
1178 preamble. If nothing worth extracting, output nothing.",
1179 )
1180 .with_focus(
1181 "decisions, chosen tools, stated constraints, measurements, recurring workflows",
1182 );
1183
1184 Some(HttpAgent::new(
1185 AgentId::new("ijima-miner"),
1186 persona,
1187 HttpConfig {
1188 base_url,
1189 model,
1190 api_key,
1191 },
1192 ))
1193}
1194
1195#[derive(Deserialize)]
1198struct NamespaceQuery {
1199 namespace: Option<String>,
1200}
1201
1202#[derive(Deserialize)]
1203struct RoomsQuery {
1204 namespace: Option<String>,
1205 project: Option<String>,
1206 limit: Option<usize>,
1207}
1208
1209#[derive(Deserialize)]
1210struct TunnelQuery {
1211 namespace: Option<String>,
1212 topic: String,
1213 project_a: String,
1214 project_b: String,
1215 limit: Option<usize>,
1216}
1217
1218#[derive(Deserialize)]
1219struct DiaryQuery {
1220 namespace: Option<String>,
1221 limit: Option<usize>,
1222}
1223
1224#[derive(Deserialize)]
1225struct MemoryBrowseQuery {
1226 namespace: Option<String>,
1227 project: Option<String>,
1228 topic: Option<String>,
1229 limit: Option<usize>,
1230}
1231
1232#[derive(Deserialize)]
1233struct ResolveRepoQuery {
1234 cwd: String,
1235}
1236
1237async fn list_rooms(
1239 principal: AuthPrincipal,
1240 Extension(store): Extension<Arc<dyn Store>>,
1241 Query(q): Query<RoomsQuery>,
1242) -> Result<Json<Vec<Room>>, ApiError> {
1243 if !principal.0.may(MEMORY_READ) {
1244 return Err(ApiError::Forbidden);
1245 }
1246 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1247 let limit = q.limit.unwrap_or(50).min(500);
1248 let rooms = store
1249 .list_rooms(&ns, q.project.as_deref(), limit)
1250 .await
1251 .map_err(internal)?;
1252 Ok(Json(rooms))
1253}
1254
1255async fn taxonomy(
1257 principal: AuthPrincipal,
1258 Extension(store): Extension<Arc<dyn Store>>,
1259 Query(q): Query<NamespaceQuery>,
1260) -> Result<Json<Vec<ProjectTaxon>>, ApiError> {
1261 if !principal.0.may(MEMORY_READ) {
1262 return Err(ApiError::Forbidden);
1263 }
1264 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1265 Ok(Json(store.taxonomy(&ns).await.map_err(internal)?))
1266}
1267
1268async fn palace_graph(
1270 principal: AuthPrincipal,
1271 Extension(store): Extension<Arc<dyn Store>>,
1272 Query(q): Query<NamespaceQuery>,
1273) -> Result<Json<PalaceGraph>, ApiError> {
1274 if !principal.0.may(MEMORY_READ) {
1275 return Err(ApiError::Forbidden);
1276 }
1277 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1278 Ok(Json(store.palace_graph(&ns).await.map_err(internal)?))
1279}
1280
1281async fn traverse_tunnel(
1283 principal: AuthPrincipal,
1284 Extension(store): Extension<Arc<dyn Store>>,
1285 Query(q): Query<TunnelQuery>,
1286) -> Result<Json<TunnelTraversal>, ApiError> {
1287 if !principal.0.may(MEMORY_READ) {
1288 return Err(ApiError::Forbidden);
1289 }
1290 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1291 let limit = q.limit.unwrap_or(50).min(500);
1292 Ok(Json(
1293 store
1294 .traverse_tunnel(&ns, &q.topic, &q.project_a, &q.project_b, limit)
1295 .await
1296 .map_err(internal)?,
1297 ))
1298}
1299
1300async fn write_diary(
1302 principal: AuthPrincipal,
1303 Extension(store): Extension<Arc<dyn Store>>,
1304 Json(entry): Json<DiaryEntry>,
1305) -> Result<StatusCode, ApiError> {
1306 if !principal.0.may(MEMORY_WRITE) {
1307 return Err(ApiError::Forbidden);
1308 }
1309 let ns = principal.0.personal_namespace();
1310 store.write_diary(&ns, entry).await.map_err(internal)?;
1311 Ok(StatusCode::NO_CONTENT)
1312}
1313
1314async fn read_diary(
1316 principal: AuthPrincipal,
1317 Extension(store): Extension<Arc<dyn Store>>,
1318 Path(agent): Path<String>,
1319 Query(q): Query<DiaryQuery>,
1320) -> Result<Json<Vec<DiaryEntry>>, ApiError> {
1321 if !principal.0.may(MEMORY_READ) {
1322 return Err(ApiError::Forbidden);
1323 }
1324 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1325 let limit = q.limit.unwrap_or(50).min(500);
1326 Ok(Json(
1327 store
1328 .read_diary(&ns, &agent, limit)
1329 .await
1330 .map_err(internal)?,
1331 ))
1332}
1333
1334async fn browse_memories(
1337 principal: AuthPrincipal,
1338 Extension(store): Extension<Arc<dyn Store>>,
1339 Query(q): Query<MemoryBrowseQuery>,
1340) -> Result<Json<Vec<Memory>>, ApiError> {
1341 if !principal.0.may(MEMORY_READ) {
1342 return Err(ApiError::Forbidden);
1343 }
1344 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1345 let limit = q.limit.unwrap_or(50).min(500);
1346 Ok(Json(
1347 store
1348 .list_memories_filtered(&ns, q.project.as_deref(), q.topic.as_deref(), limit)
1349 .await
1350 .map_err(internal)?,
1351 ))
1352}
1353
1354#[derive(Serialize)]
1355struct NamespaceStats {
1356 total: usize,
1357 projects: Vec<ProjectCount>,
1358}
1359
1360#[derive(Serialize)]
1361struct ProjectCount {
1362 project: String,
1363 count: usize,
1364}
1365
1366async fn memory_stats(
1369 principal: AuthPrincipal,
1370 Extension(store): Extension<Arc<dyn Store>>,
1371 Query(q): Query<NamespaceQuery>,
1372) -> Result<Json<NamespaceStats>, ApiError> {
1373 if !principal.0.may(MEMORY_READ) {
1374 return Err(ApiError::Forbidden);
1375 }
1376 let ns = resolve_ns(&principal, store.as_ref(), q.namespace.as_deref()).await?;
1377 let rooms = store.list_rooms(&ns, None, 1000).await.map_err(internal)?;
1378 let total: usize = rooms.iter().map(|r| r.count).sum();
1379 let mut by_project: std::collections::BTreeMap<String, usize> =
1380 std::collections::BTreeMap::new();
1381 for r in &rooms {
1382 *by_project.entry(r.project.clone()).or_default() += r.count;
1383 }
1384 let projects = by_project
1385 .into_iter()
1386 .map(|(project, count)| ProjectCount { project, count })
1387 .collect();
1388 Ok(Json(NamespaceStats { total, projects }))
1389}
1390
1391async fn register_repo(
1395 principal: AuthPrincipal,
1396 Extension(store): Extension<Arc<dyn Store>>,
1397 Json(repo): Json<RepoDirectory>,
1398) -> Result<StatusCode, ApiError> {
1399 if !principal.0.may(ADMIN) {
1400 return Err(ApiError::Forbidden);
1401 }
1402 store.register_repo(repo).await.map_err(internal)?;
1403 Ok(StatusCode::NO_CONTENT)
1404}
1405
1406async fn list_repos(
1408 principal: AuthPrincipal,
1409 Extension(store): Extension<Arc<dyn Store>>,
1410) -> Result<Json<Vec<RepoDirectory>>, ApiError> {
1411 if !principal.0.may(MEMORY_READ) {
1412 return Err(ApiError::Forbidden);
1413 }
1414 Ok(Json(store.list_repos().await.map_err(internal)?))
1415}
1416
1417async fn resolve_repo(
1419 principal: AuthPrincipal,
1420 Extension(store): Extension<Arc<dyn Store>>,
1421 Query(q): Query<ResolveRepoQuery>,
1422) -> Result<Json<RepoDirectory>, ApiError> {
1423 if !principal.0.may(MEMORY_READ) {
1424 return Err(ApiError::Forbidden);
1425 }
1426 match store.resolve_repo(&q.cwd).await.map_err(internal)? {
1427 Some(repo) => Ok(Json(repo)),
1428 None => Err(ApiError::NotFound),
1429 }
1430}
1431
1432#[cfg(feature = "federation")]
1436async fn federation_state(
1437 Extension(cfg): Extension<Arc<InstanceFederationConfig>>,
1438) -> Json<FederationState> {
1439 Json(cfg.to_state())
1440}
1441
1442#[cfg(feature = "federation")]
1450async fn routed_write(
1451 principal: AuthPrincipal,
1452 Extension(store): Extension<Arc<dyn Store>>,
1453 Extension(cfg): Extension<Arc<InstanceFederationConfig>>,
1454 Json(write): Json<RoutedWrite>,
1455) -> Result<Json<RoutedWriteReceipt>, ApiError> {
1456 if !principal.0.may(MEMORY_WRITE) {
1457 return Err(ApiError::Forbidden);
1458 }
1459 let RoutedWrite {
1460 target: _,
1461 scope,
1462 operation: _,
1463 payload,
1464 } = write;
1465
1466 if cfg.role == ijima_core::federation::InstanceRole::Airgapped {
1469 return Err(ApiError::Forbidden);
1470 }
1471 if !cfg.accepts_scope(&scope) {
1474 return Err(ApiError::BadRequest(format!(
1475 "out of authoritative scope: {}/{}",
1476 scope.namespace, scope.project
1477 )));
1478 }
1479
1480 let mut memory: Memory = serde_json::from_value(payload)
1481 .map_err(|e| ApiError::BadRequest(format!("payload is not a Memory: {e}")))?;
1482 memory.origin = ijima_core::provenance::InstanceId::local();
1485 memory.authority =
1486 ijima_core::provenance::AuthorityScope(format!("{}/{}", scope.namespace, scope.project));
1487 if memory.created_at.is_empty() {
1488 memory.created_at = std::time::SystemTime::now()
1489 .duration_since(std::time::UNIX_EPOCH)
1490 .map(|d| d.as_secs().to_string())
1491 .unwrap_or_default();
1492 }
1493 let ns = principal.0.personal_namespace();
1494
1495 let (commit, mut warnings) = if memory.source == MemorySource::Doctrine {
1499 let pending = store
1500 .enqueue_extraction(&ns, memory, 0.5)
1501 .await
1502 .map_err(internal)?;
1503 (
1504 pending,
1505 vec!["doctrine downgraded to PendingReview (trust-tier ingress rule)".into()],
1506 )
1507 } else {
1508 let id = store.store_memory(&ns, memory).await.map_err(internal)?;
1509 (id.0, Vec::new())
1510 };
1511 warnings.push("boundary enforcement: scope + airgap + doctrine-downgrade applied".into());
1512 Ok(Json(RoutedWriteReceipt {
1513 accepted: true,
1514 instance: cfg.instance_id.clone(),
1515 scope,
1516 commit: Some(commit),
1517 warnings,
1518 }))
1519}
1520
1521#[cfg(feature = "federation")]
1526async fn conflict_signal(
1527 Json(_scope): Json<AuthoritativeScope>,
1528) -> Result<Json<ConflictSignal>, ApiError> {
1529 Err(ApiError::NotFound)
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534 use super::*;
1535 use crate::IjimaAuth;
1536 use axum::body::Body;
1537 use axum::http::{Request, StatusCode};
1538 use ijima_core::{harness::Harness, memory::MemorySource};
1539 use tower::ServiceExt;
1540
1541 async fn app_with_store() -> (Router, Arc<IjimaAuth>) {
1542 let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy"));
1543 let store_inner = Arc::new(crate::SurrealStore::open_embedded().await.expect("open"));
1544 let store: Arc<dyn Store> = store_inner.clone();
1545 let kg: Arc<dyn KnowledgeGraph> = store_inner;
1546 (
1547 app(
1548 auth.clone(),
1549 store,
1550 kg,
1551 None,
1552 Arc::new(crate::redaction::Redactor::new()),
1553 #[cfg(feature = "rate-limit")]
1554 None,
1555 #[cfg(feature = "federation")]
1556 Arc::new(InstanceFederationConfig::default()),
1557 ),
1558 auth,
1559 )
1560 }
1561
1562 #[cfg(feature = "federation")]
1565 async fn app_with_federation_config(
1566 config: InstanceFederationConfig,
1567 ) -> (Router, Arc<IjimaAuth>) {
1568 let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy"));
1569 let store_inner = Arc::new(crate::SurrealStore::open_embedded().await.expect("open"));
1570 let store: Arc<dyn Store> = store_inner.clone();
1571 let kg: Arc<dyn KnowledgeGraph> = store_inner;
1572 (
1573 app(
1574 auth.clone(),
1575 store,
1576 kg,
1577 None,
1578 Arc::new(crate::redaction::Redactor::new()),
1579 #[cfg(feature = "rate-limit")]
1580 None,
1581 Arc::new(config),
1582 ),
1583 auth,
1584 )
1585 }
1586
1587 fn bearer(auth: &IjimaAuth, principal: &str, cap: &str) -> String {
1588 format!(
1589 "Bearer {}",
1590 auth.issue_bearer(principal, cap).expect("issue")
1591 )
1592 }
1593
1594 #[cfg(feature = "federation")]
1595 #[tokio::test]
1596 async fn federation_state_returns_local_config() {
1597 let (app, _auth) = app_with_store().await;
1598 let res = app
1599 .oneshot(
1600 Request::builder()
1601 .uri("/federation/state")
1602 .body(Body::empty())
1603 .unwrap(),
1604 )
1605 .await
1606 .unwrap();
1607 assert_eq!(res.status(), StatusCode::OK);
1608 let state = body_json(res).await;
1609 assert_eq!(state["instance_id"], "local");
1610 assert_eq!(state["role"], "Unifying");
1611 }
1612
1613 #[cfg(feature = "federation")]
1614 #[tokio::test]
1615 async fn routed_write_applies_a_memory() {
1616 let (app, auth) = app_with_store().await;
1617 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1618 let body = serde_json::json!({
1619 "target": "local",
1620 "scope": {"namespace": "local", "project": "Dominic"},
1621 "operation": "Create",
1622 "payload": {
1623 "id": "mem_fed_test",
1624 "content": "federated hello",
1625 "project": "Dominic",
1626 "topic": "federated",
1627 "source": "Explicit",
1628 "harness": "Dominic"
1629 }
1630 })
1631 .to_string();
1632 let res = app
1633 .oneshot(
1634 Request::builder()
1635 .method("POST")
1636 .uri("/federation/routed-write")
1637 .header("authorization", &write)
1638 .header("content-type", "application/json")
1639 .body(Body::from(body))
1640 .unwrap(),
1641 )
1642 .await
1643 .unwrap();
1644 assert_eq!(res.status(), StatusCode::OK);
1645 let receipt = body_json(res).await;
1646 assert_eq!(receipt["accepted"], true);
1647 assert!(receipt["commit"].as_str().is_some());
1648 assert_eq!(
1649 receipt["warnings"][0],
1650 "boundary enforcement: scope + airgap + doctrine-downgrade applied"
1651 );
1652 }
1653
1654 #[cfg(feature = "federation")]
1655 #[tokio::test]
1656 async fn routed_write_requires_memory_write() {
1657 let (app, auth) = app_with_store().await;
1658 let read = bearer(&auth, "elliott", MEMORY_READ); let body = serde_json::json!({
1660 "target": "local",
1661 "scope": {"namespace": "local", "project": "Dominic"},
1662 "operation": "Create",
1663 "payload": {
1664 "id": "x", "content": "c", "project": "p",
1665 "topic": "t", "source": "Explicit", "harness": "Dominic"
1666 }
1667 })
1668 .to_string();
1669 let res = app
1670 .oneshot(
1671 Request::builder()
1672 .method("POST")
1673 .uri("/federation/routed-write")
1674 .header("authorization", &read)
1675 .header("content-type", "application/json")
1676 .body(Body::from(body))
1677 .unwrap(),
1678 )
1679 .await
1680 .unwrap();
1681 assert_eq!(res.status(), StatusCode::FORBIDDEN);
1682 }
1683
1684 #[cfg(feature = "federation")]
1685 #[tokio::test]
1686 async fn routed_write_rejects_out_of_scope() {
1687 let (app, auth) = app_with_store().await;
1688 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1689 let body = serde_json::json!({
1691 "target": "local",
1692 "scope": {"namespace": "shared", "project": "Dominic"},
1693 "operation": "Create",
1694 "payload": {
1695 "id": "x", "content": "c", "project": "p",
1696 "topic": "t", "source": "Explicit", "harness": "Dominic"
1697 }
1698 })
1699 .to_string();
1700 let res = app
1701 .oneshot(
1702 Request::builder()
1703 .method("POST")
1704 .uri("/federation/routed-write")
1705 .header("authorization", &write)
1706 .header("content-type", "application/json")
1707 .body(Body::from(body))
1708 .unwrap(),
1709 )
1710 .await
1711 .unwrap();
1712 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
1713 }
1714
1715 #[cfg(feature = "federation")]
1716 #[tokio::test]
1717 async fn routed_write_rejects_when_airgapped() {
1718 let cfg = InstanceFederationConfig {
1719 role: ijima_core::federation::InstanceRole::Airgapped,
1720 ..InstanceFederationConfig::default()
1721 };
1722 let (app, auth) = app_with_federation_config(cfg).await;
1723 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1724 let body = serde_json::json!({
1725 "target": "local",
1726 "scope": {"namespace": "local", "project": "Dominic"},
1727 "operation": "Create",
1728 "payload": {
1729 "id": "x", "content": "c", "project": "p",
1730 "topic": "t", "source": "Explicit", "harness": "Dominic"
1731 }
1732 })
1733 .to_string();
1734 let res = app
1735 .oneshot(
1736 Request::builder()
1737 .method("POST")
1738 .uri("/federation/routed-write")
1739 .header("authorization", &write)
1740 .header("content-type", "application/json")
1741 .body(Body::from(body))
1742 .unwrap(),
1743 )
1744 .await
1745 .unwrap();
1746 assert_eq!(res.status(), StatusCode::FORBIDDEN);
1747 }
1748
1749 #[cfg(feature = "federation")]
1750 #[tokio::test]
1751 async fn routed_write_downgrades_doctrine_to_pending() {
1752 let (app, auth) = app_with_store().await;
1753 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1754 let body = serde_json::json!({
1755 "target": "local",
1756 "scope": {"namespace": "local", "project": "Dominic"},
1757 "operation": "Create",
1758 "payload": {
1759 "id": "mem_doctrine",
1760 "content": "peer-claimed doctrine",
1761 "project": "Dominic",
1762 "topic": "federated",
1763 "source": "Doctrine",
1764 "harness": "Dominic"
1765 }
1766 })
1767 .to_string();
1768 let res = app
1769 .oneshot(
1770 Request::builder()
1771 .method("POST")
1772 .uri("/federation/routed-write")
1773 .header("authorization", &write)
1774 .header("content-type", "application/json")
1775 .body(Body::from(body))
1776 .unwrap(),
1777 )
1778 .await
1779 .unwrap();
1780 assert_eq!(res.status(), StatusCode::OK);
1781 let receipt = body_json(res).await;
1782 assert_eq!(receipt["accepted"], true);
1783 assert_eq!(
1784 receipt["warnings"][0],
1785 "doctrine downgraded to PendingReview (trust-tier ingress rule)"
1786 );
1787 }
1788
1789 #[cfg(feature = "federation")]
1790 #[tokio::test]
1791 async fn conflict_signal_returns_404_when_none() {
1792 let (app, _auth) = app_with_store().await;
1793 let body = serde_json::json!({"namespace": "shared", "project": "Dominic"}).to_string();
1794 let res = app
1795 .oneshot(
1796 Request::builder()
1797 .method("POST")
1798 .uri("/federation/conflict-signal")
1799 .header("content-type", "application/json")
1800 .body(Body::from(body))
1801 .unwrap(),
1802 )
1803 .await
1804 .unwrap();
1805 assert_eq!(res.status(), StatusCode::NOT_FOUND);
1806 }
1807
1808 fn sample_memory_json(id: &str) -> String {
1809 serde_json::json!({
1810 "id": id,
1811 "content": "decided to wire the daemon",
1812 "project": "ijima",
1813 "topic": "api",
1814 "source": "Explicit",
1815 "harness": "Pi",
1816 "session_id": "sess_1",
1817 "importance": 0.5,
1818 "created_at": "0",
1819 })
1820 .to_string()
1821 }
1822
1823 #[tokio::test]
1824 async fn health_is_public() {
1825 let (app, _) = app_with_store().await;
1826 let res = app
1827 .oneshot(
1828 Request::builder()
1829 .uri("/health")
1830 .body(Body::empty())
1831 .unwrap(),
1832 )
1833 .await
1834 .unwrap();
1835 assert_eq!(res.status(), StatusCode::OK);
1836 }
1837
1838 #[tokio::test]
1839 async fn recall_without_auth_is_401() {
1840 let (app, _) = app_with_store().await;
1841 let res = app
1842 .oneshot(
1843 Request::builder()
1844 .uri("/memories/mem_1")
1845 .body(Body::empty())
1846 .unwrap(),
1847 )
1848 .await
1849 .unwrap();
1850 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
1851 }
1852
1853 #[tokio::test]
1854 async fn store_then_recall_round_trips() {
1855 let (app, auth) = app_with_store().await;
1856 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1857 let read = bearer(&auth, "elliott", MEMORY_READ);
1858
1859 let res = app
1861 .clone()
1862 .oneshot(
1863 Request::builder()
1864 .method("POST")
1865 .uri("/memories")
1866 .header("authorization", &write)
1867 .header("content-type", "application/json")
1868 .body(Body::from(sample_memory_json("mem_1")))
1869 .unwrap(),
1870 )
1871 .await
1872 .unwrap();
1873 assert_eq!(res.status(), StatusCode::OK);
1874
1875 let res = app
1877 .oneshot(
1878 Request::builder()
1879 .uri("/memories/mem_1")
1880 .header("authorization", &read)
1881 .body(Body::empty())
1882 .unwrap(),
1883 )
1884 .await
1885 .unwrap();
1886 assert_eq!(res.status(), StatusCode::OK);
1887 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
1888 .await
1889 .unwrap();
1890 let mem: Memory = serde_json::from_slice(&body).unwrap();
1891 assert_eq!(mem.content, "decided to wire the daemon");
1892 assert_eq!(mem.harness, Harness::Pi);
1893 assert_eq!(mem.source, MemorySource::Explicit);
1894 }
1895
1896 #[tokio::test]
1897 async fn store_with_read_only_token_is_403() {
1898 let (app, auth) = app_with_store().await;
1899 let read = bearer(&auth, "elliott", MEMORY_READ);
1900 let res = app
1901 .oneshot(
1902 Request::builder()
1903 .method("POST")
1904 .uri("/memories")
1905 .header("authorization", &read)
1906 .header("content-type", "application/json")
1907 .body(Body::from(sample_memory_json("mem_x")))
1908 .unwrap(),
1909 )
1910 .await
1911 .unwrap();
1912 assert_eq!(res.status(), StatusCode::FORBIDDEN);
1913 }
1914
1915 #[tokio::test]
1916 async fn namespace_isolation_across_principals() {
1917 let (app, auth) = app_with_store().await;
1918 let alice_write = bearer(&auth, "alice", MEMORY_WRITE);
1920 let _ = app
1921 .clone()
1922 .oneshot(
1923 Request::builder()
1924 .method("POST")
1925 .uri("/memories")
1926 .header("authorization", &alice_write)
1927 .header("content-type", "application/json")
1928 .body(Body::from(sample_memory_json("mem_a")))
1929 .unwrap(),
1930 )
1931 .await
1932 .unwrap();
1933 let bob_read = bearer(&auth, "bob", MEMORY_READ);
1935 let res = app
1936 .oneshot(
1937 Request::builder()
1938 .uri("/memories/mem_a")
1939 .header("authorization", &bob_read)
1940 .body(Body::empty())
1941 .unwrap(),
1942 )
1943 .await
1944 .unwrap();
1945 assert_eq!(res.status(), StatusCode::NOT_FOUND);
1946 }
1947
1948 #[tokio::test]
1949 async fn promote_redacts_secrets_and_leaves_original_intact() {
1950 let (app, auth) = app_with_store().await;
1951 let write = bearer(&auth, "elliott", MEMORY_WRITE);
1952 let read = bearer(&auth, "elliott", MEMORY_READ);
1953 let promote = bearer(&auth, "elliott", TRUST_PROMOTE);
1954
1955 let admin = bearer(&auth, "root", ADMIN);
1958 let res = app
1959 .clone()
1960 .oneshot(
1961 Request::builder()
1962 .method("POST")
1963 .uri("/namespaces/grant")
1964 .header("authorization", &admin)
1965 .header("content-type", "application/json")
1966 .body(Body::from(
1967 serde_json::json!({
1968 "namespace": "ns_team_shared",
1969 "principal": "elliott"
1970 })
1971 .to_string(),
1972 ))
1973 .unwrap(),
1974 )
1975 .await
1976 .unwrap();
1977 assert_eq!(res.status(), StatusCode::OK, "membership grant");
1978
1979 let body = serde_json::json!({
1981 "id": "mem_secret",
1982 "content": "deploy key sk-abcdefghijklmnopqrstuvwxyz1234567890 contact ops@test.com",
1983 "project": "ijima",
1984 "topic": "ops",
1985 "source": "Explicit",
1986 "harness": "Pi",
1987 })
1988 .to_string();
1989 let res = app
1990 .clone()
1991 .oneshot(
1992 Request::builder()
1993 .method("POST")
1994 .uri("/memories")
1995 .header("authorization", &write)
1996 .header("content-type", "application/json")
1997 .body(Body::from(body))
1998 .unwrap(),
1999 )
2000 .await
2001 .unwrap();
2002 assert_eq!(res.status(), StatusCode::OK);
2003
2004 let promote_body = serde_json::json!({
2006 "target_namespace": "ns_team_shared",
2007 })
2008 .to_string();
2009 let res = app
2010 .clone()
2011 .oneshot(
2012 Request::builder()
2013 .method("POST")
2014 .uri("/memories/mem_secret/promote")
2015 .header("authorization", &promote)
2016 .header("content-type", "application/json")
2017 .body(Body::from(promote_body))
2018 .unwrap(),
2019 )
2020 .await
2021 .unwrap();
2022 assert_eq!(res.status(), StatusCode::OK);
2023 let resp: serde_json::Value = serde_json::from_slice(
2024 &axum::body::to_bytes(res.into_body(), usize::MAX)
2025 .await
2026 .unwrap(),
2027 )
2028 .unwrap();
2029 let new_id = resp["id"].as_str().unwrap();
2030 assert_eq!(new_id, "mem_secret__shared");
2031 let cats: Vec<&str> = resp["redactions"]
2032 .as_array()
2033 .unwrap()
2034 .iter()
2035 .map(|r| r["category"].as_str().unwrap())
2036 .collect();
2037 assert!(cats.contains(&"api_key"));
2038 assert!(cats.contains(&"email"));
2039
2040 let res = app
2042 .clone()
2043 .oneshot(
2044 Request::builder()
2045 .uri("/memories/mem_secret")
2046 .header("authorization", &read)
2047 .body(Body::empty())
2048 .unwrap(),
2049 )
2050 .await
2051 .unwrap();
2052 let orig: Memory = serde_json::from_slice(
2053 &axum::body::to_bytes(res.into_body(), usize::MAX)
2054 .await
2055 .unwrap(),
2056 )
2057 .unwrap();
2058 assert!(orig.content.contains("sk-abcdef"));
2059 assert!(orig.content.contains("ops@test.com"));
2060
2061 let res = app
2064 .oneshot(
2065 Request::builder()
2066 .uri("/memories/mem_secret__shared?namespace=ns_team_shared")
2067 .header("authorization", &read)
2068 .body(Body::empty())
2069 .unwrap(),
2070 )
2071 .await
2072 .unwrap();
2073 assert_eq!(res.status(), StatusCode::OK);
2074 let shared: Memory = serde_json::from_slice(
2075 &axum::body::to_bytes(res.into_body(), usize::MAX)
2076 .await
2077 .unwrap(),
2078 )
2079 .unwrap();
2080 assert!(shared.content.contains("[REDACTED:api_key]"));
2081 assert!(shared.content.contains("[REDACTED:email]"));
2082 assert!(!shared.content.contains("sk-abcdef"));
2083 assert!(!shared.content.contains("ops@test.com"));
2084 assert_eq!(shared.session_id.as_deref(), Some("mem_secret"));
2086 }
2087
2088 #[tokio::test]
2089 async fn promote_requires_trust_promote_not_memory_write() {
2090 let (app, auth) = app_with_store().await;
2094 let write = bearer(&auth, "elliott", MEMORY_WRITE);
2095 let body = serde_json::json!({
2096 "id": "mem_p",
2097 "content": "provenance tier test",
2098 "project": "ijima",
2099 "topic": "t",
2100 "source": "Explicit",
2101 "harness": "Pi",
2102 })
2103 .to_string();
2104 let res = app
2106 .clone()
2107 .oneshot(
2108 Request::builder()
2109 .method("POST")
2110 .uri("/memories")
2111 .header("authorization", &write)
2112 .header("content-type", "application/json")
2113 .body(Body::from(body))
2114 .unwrap(),
2115 )
2116 .await
2117 .unwrap();
2118 assert_eq!(res.status(), StatusCode::OK);
2119
2120 let promote_body = serde_json::json!({ "target_namespace": "ns_team_shared" }).to_string();
2122 let res = app
2123 .clone()
2124 .oneshot(
2125 Request::builder()
2126 .method("POST")
2127 .uri("/memories/mem_p/promote")
2128 .header("authorization", &write)
2129 .header("content-type", "application/json")
2130 .body(Body::from(promote_body))
2131 .unwrap(),
2132 )
2133 .await
2134 .unwrap();
2135 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2136
2137 let admin = bearer(&auth, "root", ADMIN);
2141 let res = app
2142 .clone()
2143 .oneshot(
2144 Request::builder()
2145 .method("POST")
2146 .uri("/namespaces/grant")
2147 .header("authorization", &admin)
2148 .header("content-type", "application/json")
2149 .body(Body::from(
2150 serde_json::json!({
2151 "namespace": "ns_team_shared",
2152 "principal": "elliott"
2153 })
2154 .to_string(),
2155 ))
2156 .unwrap(),
2157 )
2158 .await
2159 .unwrap();
2160 assert_eq!(res.status(), StatusCode::OK, "membership grant");
2161
2162 let promote = bearer(&auth, "elliott", TRUST_PROMOTE);
2163 let promote_body = serde_json::json!({ "target_namespace": "ns_team_shared" }).to_string();
2164 let res = app
2165 .oneshot(
2166 Request::builder()
2167 .method("POST")
2168 .uri("/memories/mem_p/promote")
2169 .header("authorization", &promote)
2170 .header("content-type", "application/json")
2171 .body(Body::from(promote_body))
2172 .unwrap(),
2173 )
2174 .await
2175 .unwrap();
2176 assert_eq!(res.status(), StatusCode::OK);
2177 }
2178
2179 #[tokio::test]
2184 async fn shared_namespace_membership_lifecycle() {
2185 let (app, auth) = app_with_store().await;
2186 let rw = bearer(&auth, "elliott", MEMORY_WRITE);
2187 let admin = bearer(&auth, "root", ADMIN);
2188
2189 let write_into = |app: Router, token: String, n: u8| async move {
2190 app.oneshot(
2191 Request::builder()
2192 .method("POST")
2193 .uri("/memories?namespace=ns_ia_shared")
2194 .header("authorization", token)
2195 .header("content-type", "application/json")
2196 .body(Body::from(
2197 serde_json::json!({
2198 "id": format!("mem_wall_{n}"),
2199 "content": format!("org-wall probe {n}"),
2200 "project": "ijima",
2201 "topic": "ws3",
2202 "source": "Explicit",
2203 "harness": "Pi",
2204 "importance": 0.5,
2205 "created_at": "0",
2206 })
2207 .to_string(),
2208 ))
2209 .unwrap(),
2210 )
2211 .await
2212 .unwrap()
2213 };
2214
2215 let res = write_into(app.clone(), rw.clone(), 1).await;
2217 assert_eq!(
2218 res.status(),
2219 StatusCode::FORBIDDEN,
2220 "non-member must be walled"
2221 );
2222
2223 let res = write_into(app.clone(), admin.clone(), 2).await;
2225 assert_eq!(res.status(), StatusCode::OK, "admin bypass");
2226
2227 let res = app
2229 .clone()
2230 .oneshot(
2231 Request::builder()
2232 .method("POST")
2233 .uri("/namespaces/grant")
2234 .header("authorization", rw.clone())
2235 .header("content-type", "application/json")
2236 .body(Body::from(
2237 serde_json::json!({ "namespace": "ns_ia_shared", "principal": "elliott" })
2238 .to_string(),
2239 ))
2240 .unwrap(),
2241 )
2242 .await
2243 .unwrap();
2244 assert_eq!(res.status(), StatusCode::FORBIDDEN, "grant requires admin");
2245
2246 let res = app
2248 .clone()
2249 .oneshot(
2250 Request::builder()
2251 .method("POST")
2252 .uri("/namespaces/grant")
2253 .header("authorization", admin.clone())
2254 .header("content-type", "application/json")
2255 .body(Body::from(
2256 serde_json::json!({ "namespace": "ns_ia_shared", "principal": "elliott" })
2257 .to_string(),
2258 ))
2259 .unwrap(),
2260 )
2261 .await
2262 .unwrap();
2263 assert_eq!(res.status(), StatusCode::OK);
2264 let res = write_into(app.clone(), rw.clone(), 3).await;
2265 assert_eq!(res.status(), StatusCode::OK, "member passes");
2266
2267 let res = app
2269 .clone()
2270 .oneshot(
2271 Request::builder()
2272 .uri("/namespaces/members?namespace=ns_ia_shared")
2273 .header("authorization", admin.clone())
2274 .body(Body::empty())
2275 .unwrap(),
2276 )
2277 .await
2278 .unwrap();
2279 assert_eq!(res.status(), StatusCode::OK);
2280 let members = body_json(res).await;
2281 assert_eq!(members[0]["principal"].as_str(), Some("elliott"));
2282 assert_eq!(members[0]["granted_by"].as_str(), Some("root"));
2283
2284 let res = app
2286 .clone()
2287 .oneshot(
2288 Request::builder()
2289 .method("POST")
2290 .uri("/namespaces/revoke")
2291 .header("authorization", admin.clone())
2292 .header("content-type", "application/json")
2293 .body(Body::from(
2294 serde_json::json!({ "namespace": "ns_ia_shared", "principal": "elliott" })
2295 .to_string(),
2296 ))
2297 .unwrap(),
2298 )
2299 .await
2300 .unwrap();
2301 assert_eq!(res.status(), StatusCode::NO_CONTENT);
2302 let res = write_into(app, rw, 4).await;
2303 assert_eq!(
2304 res.status(),
2305 StatusCode::FORBIDDEN,
2306 "revoked member is walled"
2307 );
2308 }
2309
2310 #[tokio::test]
2313 async fn doctrine_and_import_namespaces_stay_open() {
2314 let (app, auth) = app_with_store().await;
2315 let read = bearer(&auth, "elliott", MEMORY_READ);
2316
2317 let res = app
2318 .clone()
2319 .oneshot(
2320 Request::builder()
2321 .uri("/memories?namespace=ns_doctrine&limit=5")
2322 .header("authorization", read.clone())
2323 .body(Body::empty())
2324 .unwrap(),
2325 )
2326 .await
2327 .unwrap();
2328 assert_eq!(res.status(), StatusCode::OK, "doctrine is readable by all");
2329
2330 let res = app
2331 .clone()
2332 .oneshot(
2333 Request::builder()
2334 .uri("/memories?namespace=ns_import_probe&limit=5")
2335 .header("authorization", read)
2336 .body(Body::empty())
2337 .unwrap(),
2338 )
2339 .await
2340 .unwrap();
2341 assert_eq!(res.status(), StatusCode::OK, "import staging is open");
2342 }
2343
2344 #[tokio::test]
2345 async fn cross_principal_personal_namespace_is_forbidden() {
2346 let (app, auth) = app_with_store().await;
2347 let alice_write = bearer(&auth, "alice", MEMORY_WRITE);
2349 let _ = app
2350 .clone()
2351 .oneshot(
2352 Request::builder()
2353 .method("POST")
2354 .uri("/memories")
2355 .header("authorization", &alice_write)
2356 .header("content-type", "application/json")
2357 .body(Body::from(
2358 serde_json::json!({
2359 "id": "mem_a",
2360 "content": "alice only",
2361 "project": "x",
2362 "topic": "x",
2363 "source": "Explicit",
2364 "harness": "Pi",
2365 })
2366 .to_string(),
2367 ))
2368 .unwrap(),
2369 )
2370 .await
2371 .unwrap();
2372
2373 let bob_read = bearer(&auth, "bob", MEMORY_READ);
2375 let res = app
2376 .oneshot(
2377 Request::builder()
2378 .uri("/memories/mem_a?namespace=ns_alice_private")
2379 .header("authorization", &bob_read)
2380 .body(Body::empty())
2381 .unwrap(),
2382 )
2383 .await
2384 .unwrap();
2385 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2386 }
2387
2388 #[tokio::test]
2389 async fn doctrine_ingest_requires_admin_and_is_readable_shared() {
2390 let (app, auth) = app_with_store().await;
2391 let admin = bearer(&auth, "ci", "admin");
2392 let read = bearer(&auth, "anyone", MEMORY_READ);
2393
2394 let res = app
2396 .clone()
2397 .oneshot(
2398 Request::builder()
2399 .method("POST")
2400 .uri("/doctrine")
2401 .header("authorization", &read)
2402 .header("content-type", "application/json")
2403 .body(Body::from(
2404 serde_json::json!({
2405 "id": "d1",
2406 "content": "doctrine body",
2407 "project": "ijima",
2408 "topic": "arch",
2409 })
2410 .to_string(),
2411 ))
2412 .unwrap(),
2413 )
2414 .await
2415 .unwrap();
2416 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2417
2418 let res = app
2420 .clone()
2421 .oneshot(
2422 Request::builder()
2423 .method("POST")
2424 .uri("/doctrine")
2425 .header("authorization", &admin)
2426 .header("content-type", "application/json")
2427 .body(Body::from(
2428 serde_json::json!({
2429 "id": "d1",
2430 "content": "doctrine body",
2431 "project": "ijima",
2432 "topic": "arch",
2433 })
2434 .to_string(),
2435 ))
2436 .unwrap(),
2437 )
2438 .await
2439 .unwrap();
2440 assert_eq!(res.status(), StatusCode::OK);
2441
2442 let res = app
2444 .oneshot(
2445 Request::builder()
2446 .uri("/memories/d1?namespace=ns_doctrine")
2447 .header("authorization", &read)
2448 .body(Body::empty())
2449 .unwrap(),
2450 )
2451 .await
2452 .unwrap();
2453 assert_eq!(res.status(), StatusCode::OK);
2454 let mem: Memory = serde_json::from_slice(
2455 &axum::body::to_bytes(res.into_body(), usize::MAX)
2456 .await
2457 .unwrap(),
2458 )
2459 .unwrap();
2460 assert_eq!(mem.content, "doctrine body");
2461 assert_eq!(mem.source, ijima_core::memory::MemorySource::Doctrine);
2462 }
2463
2464 #[tokio::test]
2465 async fn wakeup_composes_personal_and_doctrine() {
2466 let (app, auth) = app_with_store().await;
2467 let write = bearer(&auth, "elliott", MEMORY_WRITE);
2468 let admin = bearer(&auth, "ci", "admin");
2469 let read = bearer(&auth, "elliott", MEMORY_READ);
2470
2471 let _ = app
2473 .clone()
2474 .oneshot(
2475 Request::builder()
2476 .method("POST")
2477 .uri("/memories")
2478 .header("authorization", &write)
2479 .header("content-type", "application/json")
2480 .body(Body::from(
2481 serde_json::json!({
2482 "id": "mem_p",
2483 "content": "personal essential",
2484 "project": "ijima",
2485 "topic": "x",
2486 "source": "Explicit",
2487 "harness": "Pi",
2488 })
2489 .to_string(),
2490 ))
2491 .unwrap(),
2492 )
2493 .await
2494 .unwrap();
2495
2496 let _ = app
2498 .clone()
2499 .oneshot(
2500 Request::builder()
2501 .method("POST")
2502 .uri("/doctrine")
2503 .header("authorization", &admin)
2504 .header("content-type", "application/json")
2505 .body(Body::from(
2506 serde_json::json!({
2507 "id": "doc_1",
2508 "content": "doctrine baseline",
2509 "project": "ijima",
2510 "topic": "arch",
2511 })
2512 .to_string(),
2513 ))
2514 .unwrap(),
2515 )
2516 .await
2517 .unwrap();
2518
2519 let res = app
2521 .oneshot(
2522 Request::builder()
2523 .uri("/wakeup")
2524 .header("authorization", &read)
2525 .body(Body::empty())
2526 .unwrap(),
2527 )
2528 .await
2529 .unwrap();
2530 assert_eq!(res.status(), StatusCode::OK);
2531 let body: serde_json::Value = serde_json::from_slice(
2532 &axum::body::to_bytes(res.into_body(), usize::MAX)
2533 .await
2534 .unwrap(),
2535 )
2536 .unwrap();
2537 assert_eq!(body["identity"]["principal"], "elliott");
2538 assert_eq!(body["personal_essentials"].as_array().unwrap().len(), 1);
2539 assert_eq!(
2540 body["personal_essentials"][0]["content"],
2541 "personal essential"
2542 );
2543 assert_eq!(body["doctrine"].as_array().unwrap().len(), 1);
2544 assert_eq!(body["doctrine"][0]["content"], "doctrine baseline");
2545 assert_eq!(body["doctrine"][0]["source"], "Doctrine");
2546 }
2547
2548 #[cfg(feature = "rate-limit")]
2549 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2550 async fn import_memories_backs_off_through_rate_limit() {
2551 let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy"));
2556 let store_inner = Arc::new(crate::SurrealStore::open_embedded().await.expect("open"));
2557 let store: Arc<dyn Store> = store_inner.clone();
2558 let kg: Arc<dyn KnowledgeGraph> = store_inner;
2559 let app = app(
2560 auth.clone(),
2561 store,
2562 kg,
2563 None,
2564 Arc::new(crate::redaction::Redactor::new()),
2565 Some(crate::rate_limit::make_rate_limiter(1.0, 1.0)),
2566 #[cfg(feature = "federation")]
2567 Arc::new(InstanceFederationConfig::default()),
2568 );
2569 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2570 let addr = listener.local_addr().unwrap();
2571 tokio::spawn(async move {
2572 axum::serve(listener, app).await.unwrap();
2573 });
2574
2575 let token = auth
2577 .issue_grant_bearer("elliott", &[MEMORY_READ, MEMORY_WRITE])
2578 .expect("issue");
2579 let client = ijima_client::Client::new(
2580 ijima_client::ClientConfig::new(format!("http://{addr}"), Harness::Pi)
2581 .with_token(token),
2582 );
2583 let ns = format!("ns_import_ratelimit_{}", std::process::id());
2584 let memories: Vec<Memory> = (0..5)
2585 .map(|i| Memory {
2586 id: MemoryId(format!("mem_backoff_{i}")),
2587 content: format!("backoff corpus row {i} for rate-limit regression"),
2588 project: "ijima".into(),
2589 topic: "test".into(),
2590 source: ijima_core::memory::MemorySource::AutoCapture,
2591 harness: Harness::Pi,
2592 session_id: None,
2593 origin: ijima_core::InstanceId::local(),
2594 authority: ijima_core::AuthorityScope::local(),
2595 importance: 0.5,
2596 created_at: "0".into(),
2597 })
2598 .collect();
2599 let counts = client.import_memories(&ns, memories).await.expect("import");
2600 assert_eq!(counts.attempted, 5);
2601 assert_eq!(counts.added, 5, "no memory may be lost to 429s");
2602 assert_eq!(counts.skipped, 0);
2603 }
2604
2605 #[tokio::test]
2606 async fn knowledge_graph_add_honors_namespace_param() {
2607 let (app, auth) = app_with_store().await;
2608 let write = bearer(&auth, "elliott", "knowledge:write");
2609 let read = bearer(&auth, "elliott", "knowledge:read");
2610
2611 let res = app
2613 .clone()
2614 .oneshot(
2615 Request::builder()
2616 .method("POST")
2617 .uri("/kg/triples?namespace=ns_import_stage")
2618 .header("authorization", &write)
2619 .header("content-type", "application/json")
2620 .body(Body::from(
2621 serde_json::json!({
2622 "subject": "Ijima",
2623 "predicate": "depends_on",
2624 "object": "Schubert",
2625 })
2626 .to_string(),
2627 ))
2628 .unwrap(),
2629 )
2630 .await
2631 .unwrap();
2632 assert_eq!(res.status(), StatusCode::OK);
2633
2634 let res = app
2636 .clone()
2637 .oneshot(
2638 Request::builder()
2639 .uri("/kg/stats")
2640 .header("authorization", &read)
2641 .body(Body::empty())
2642 .unwrap(),
2643 )
2644 .await
2645 .unwrap();
2646 let personal: serde_json::Value = serde_json::from_slice(
2647 &axum::body::to_bytes(res.into_body(), usize::MAX)
2648 .await
2649 .unwrap(),
2650 )
2651 .unwrap();
2652 assert_eq!(personal["triples"], 0);
2653
2654 let res = app
2656 .clone()
2657 .oneshot(
2658 Request::builder()
2659 .uri("/kg/stats?namespace=ns_import_stage")
2660 .header("authorization", &read)
2661 .body(Body::empty())
2662 .unwrap(),
2663 )
2664 .await
2665 .unwrap();
2666 let staged: serde_json::Value = serde_json::from_slice(
2667 &axum::body::to_bytes(res.into_body(), usize::MAX)
2668 .await
2669 .unwrap(),
2670 )
2671 .unwrap();
2672 assert_eq!(staged["triples"], 1);
2673 }
2674
2675 #[tokio::test]
2676 async fn knowledge_graph_add_query_invalidate() {
2677 let (app, auth) = app_with_store().await;
2678 let write = bearer(&auth, "elliott", "knowledge:write");
2679 let read = bearer(&auth, "elliott", "knowledge:read");
2680
2681 let res = app
2683 .clone()
2684 .oneshot(
2685 Request::builder()
2686 .method("POST")
2687 .uri("/kg/triples")
2688 .header("authorization", &write)
2689 .header("content-type", "application/json")
2690 .body(Body::from(
2691 serde_json::json!({
2692 "subject": "Ijima",
2693 "predicate": "depends_on",
2694 "object": "SurrealDB",
2695 "confidence": 1.0,
2696 })
2697 .to_string(),
2698 ))
2699 .unwrap(),
2700 )
2701 .await
2702 .unwrap();
2703 assert_eq!(res.status(), StatusCode::OK);
2704
2705 let res = app
2707 .clone()
2708 .oneshot(
2709 Request::builder()
2710 .uri("/kg/entities/Ijima")
2711 .header("authorization", &read)
2712 .body(Body::empty())
2713 .unwrap(),
2714 )
2715 .await
2716 .unwrap();
2717 assert_eq!(res.status(), StatusCode::OK);
2718 let body: serde_json::Value = serde_json::from_slice(
2719 &axum::body::to_bytes(res.into_body(), usize::MAX)
2720 .await
2721 .unwrap(),
2722 )
2723 .unwrap();
2724 assert_eq!(body["outgoing"].as_array().unwrap().len(), 1);
2725 assert_eq!(body["outgoing"][0]["object"], "SurrealDB");
2726 assert!(body["incoming"].as_array().unwrap().is_empty());
2727
2728 let res = app
2730 .clone()
2731 .oneshot(
2732 Request::builder()
2733 .uri("/kg/stats")
2734 .header("authorization", &read)
2735 .body(Body::empty())
2736 .unwrap(),
2737 )
2738 .await
2739 .unwrap();
2740 let body: serde_json::Value = serde_json::from_slice(
2741 &axum::body::to_bytes(res.into_body(), usize::MAX)
2742 .await
2743 .unwrap(),
2744 )
2745 .unwrap();
2746 assert_eq!(body["entities"], 2);
2747 assert_eq!(body["triples"], 1);
2748
2749 let res = app
2751 .oneshot(
2752 Request::builder()
2753 .method("POST")
2754 .uri("/kg/triples/Ijima:depends_on:SurrealDB/invalidate")
2755 .header("authorization", &write)
2756 .body(Body::empty())
2757 .unwrap(),
2758 )
2759 .await
2760 .unwrap();
2761 assert_eq!(res.status(), StatusCode::NO_CONTENT);
2762 }
2763
2764 #[tokio::test]
2765 async fn status_requires_admin_and_reports_counts() {
2766 let (app, auth) = app_with_store().await;
2767 let admin = bearer(&auth, "op", "admin");
2768 let read = bearer(&auth, "user", MEMORY_READ);
2769
2770 let _ = app
2772 .clone()
2773 .oneshot(
2774 Request::builder()
2775 .method("POST")
2776 .uri("/memories")
2777 .header("authorization", &admin)
2778 .header("content-type", "application/json")
2779 .body(Body::from(
2780 serde_json::json!({
2781 "id": "m1",
2782 "content": "stat test",
2783 "project": "x",
2784 "topic": "x",
2785 "source": "Explicit",
2786 "harness": "Pi",
2787 })
2788 .to_string(),
2789 ))
2790 .unwrap(),
2791 )
2792 .await
2793 .unwrap();
2794
2795 let res = app
2797 .clone()
2798 .oneshot(
2799 Request::builder()
2800 .uri("/status")
2801 .header("authorization", &read)
2802 .body(Body::empty())
2803 .unwrap(),
2804 )
2805 .await
2806 .unwrap();
2807 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2808
2809 let res = app
2811 .oneshot(
2812 Request::builder()
2813 .uri("/status")
2814 .header("authorization", &admin)
2815 .body(Body::empty())
2816 .unwrap(),
2817 )
2818 .await
2819 .unwrap();
2820 assert_eq!(res.status(), StatusCode::OK);
2821 let body: serde_json::Value = serde_json::from_slice(
2822 &axum::body::to_bytes(res.into_body(), usize::MAX)
2823 .await
2824 .unwrap(),
2825 )
2826 .unwrap();
2827 assert_eq!(body["memories"], 1);
2828 assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
2831 let uptime = body["uptime_secs"].as_u64().expect("uptime is u64");
2832 assert!(uptime < 60, "fresh test app should have tiny uptime");
2833 assert!(
2834 body["started_at_unix"].as_u64().expect("started_at is u64") > 1_000_000_000,
2835 "started_at looks like a unix timestamp"
2836 );
2837 assert!(!body["namespaces"].as_array().unwrap().is_empty());
2838 }
2839
2840 #[tokio::test]
2841 async fn sessions_create_list_end_via_http() {
2842 let (app, auth) = app_with_store().await;
2843 let ingest = bearer(&auth, "op", SESSION_INGEST);
2844 let read = bearer(&auth, "op", MEMORY_READ);
2845
2846 for (id, harness) in [("sess_a", "Pi"), ("sess_b", "Sakamoto")] {
2848 let res = app
2849 .clone()
2850 .oneshot(
2851 Request::builder()
2852 .method("POST")
2853 .uri("/sessions")
2854 .header("authorization", &ingest)
2855 .header("content-type", "application/json")
2856 .body(Body::from(
2857 serde_json::json!({
2858 "id": id,
2859 "harness": harness,
2860 "channel": "thread-1",
2861 "started_at": "2026-07-05T10:00:00Z",
2862 })
2863 .to_string(),
2864 ))
2865 .unwrap(),
2866 )
2867 .await
2868 .unwrap();
2869 assert_eq!(res.status(), StatusCode::OK);
2870 }
2871
2872 let res = app
2874 .clone()
2875 .oneshot(
2876 Request::builder()
2877 .uri("/sessions")
2878 .header("authorization", &read)
2879 .body(Body::empty())
2880 .unwrap(),
2881 )
2882 .await
2883 .unwrap();
2884 assert_eq!(res.status(), StatusCode::OK);
2885 let body: serde_json::Value = serde_json::from_slice(
2886 &axum::body::to_bytes(res.into_body(), usize::MAX)
2887 .await
2888 .unwrap(),
2889 )
2890 .unwrap();
2891 let arr = body.as_array().unwrap();
2892 assert_eq!(arr.len(), 2);
2893
2894 let res = app
2896 .clone()
2897 .oneshot(
2898 Request::builder()
2899 .uri("/sessions?harness=pi")
2900 .header("authorization", &read)
2901 .body(Body::empty())
2902 .unwrap(),
2903 )
2904 .await
2905 .unwrap();
2906 let body: serde_json::Value = serde_json::from_slice(
2907 &axum::body::to_bytes(res.into_body(), usize::MAX)
2908 .await
2909 .unwrap(),
2910 )
2911 .unwrap();
2912 assert_eq!(body.as_array().unwrap().len(), 1);
2913 assert_eq!(body[0]["harness"], "Pi");
2914
2915 let res = app
2917 .clone()
2918 .oneshot(
2919 Request::builder()
2920 .method("POST")
2921 .uri("/sessions/sess_a/end")
2922 .header("authorization", &ingest)
2923 .header("content-type", "application/json")
2924 .body(Body::from(
2925 serde_json::json!({ "ended_at": "2026-07-05T11:00:00Z" }).to_string(),
2926 ))
2927 .unwrap(),
2928 )
2929 .await
2930 .unwrap();
2931 assert_eq!(res.status(), StatusCode::NO_CONTENT);
2932
2933 let res = app
2935 .oneshot(
2936 Request::builder()
2937 .uri("/sessions?harness=pi")
2938 .header("authorization", &read)
2939 .body(Body::empty())
2940 .unwrap(),
2941 )
2942 .await
2943 .unwrap();
2944 let body: serde_json::Value = serde_json::from_slice(
2945 &axum::body::to_bytes(res.into_body(), usize::MAX)
2946 .await
2947 .unwrap(),
2948 )
2949 .unwrap();
2950 assert_eq!(body[0]["ended_at"], "2026-07-05T11:00:00Z");
2951 }
2952
2953 #[tokio::test]
2954 async fn mining_queue_requires_review_capability() {
2955 let (app, auth) = app_with_store().await;
2956 let reviewer = bearer(&auth, "op", MINING_REVIEW);
2957 let reader = bearer(&auth, "op", MEMORY_READ);
2958
2959 let res = app
2961 .clone()
2962 .oneshot(
2963 Request::builder()
2964 .uri("/mining/queue")
2965 .header("authorization", &reader)
2966 .body(Body::empty())
2967 .unwrap(),
2968 )
2969 .await
2970 .unwrap();
2971 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2972
2973 let res = app
2975 .oneshot(
2976 Request::builder()
2977 .uri("/mining/queue")
2978 .header("authorization", &reviewer)
2979 .body(Body::empty())
2980 .unwrap(),
2981 )
2982 .await
2983 .unwrap();
2984 assert_eq!(res.status(), StatusCode::OK);
2985 let body: serde_json::Value = serde_json::from_slice(
2986 &axum::body::to_bytes(res.into_body(), usize::MAX)
2987 .await
2988 .unwrap(),
2989 )
2990 .unwrap();
2991 assert!(body.as_array().unwrap().is_empty());
2992 }
2993
2994 fn hit_mem(id: &str, sim: f32) -> SearchHit {
2995 SearchHit {
2996 memory: Memory {
2997 id: MemoryId(id.into()),
2998 content: id.into(),
2999 project: "p".into(),
3000 topic: "t".into(),
3001 source: ijima_core::MemorySource::Explicit,
3002 harness: ijima_core::harness::Harness::Pi,
3003 session_id: None,
3004 origin: ijima_core::InstanceId::local(),
3005 authority: ijima_core::AuthorityScope::local(),
3006 importance: 0.5,
3007 created_at: "0".into(),
3008 },
3009 similarity: sim,
3010 }
3011 }
3012
3013 #[test]
3014 fn merge_search_hits_ranks_desc_dedups_and_truncates() {
3015 let a = vec![hit_mem("a", 0.9), hit_mem("b", 0.5)];
3018 let b = vec![hit_mem("c", 0.8), hit_mem("a", 0.7)]; let merged = merge_search_hits(a, b, 3);
3020 assert_eq!(merged.len(), 3);
3023 assert_eq!(merged[0].memory.id.0, "a");
3024 assert_eq!((merged[0].similarity * 10.0).round() as i32, 9);
3025 assert_eq!(merged[1].memory.id.0, "c");
3026 assert_eq!(merged[2].memory.id.0, "b");
3027 }
3028
3029 #[test]
3030 fn merge_search_hits_respects_limit() {
3031 let a = vec![hit_mem("a", 0.9), hit_mem("b", 0.8)];
3032 let b = vec![hit_mem("c", 0.7), hit_mem("d", 0.6)];
3033 let merged = merge_search_hits(a, b, 2);
3034 assert_eq!(merged.len(), 2);
3035 assert_eq!(merged[0].memory.id.0, "a");
3036 assert_eq!(merged[1].memory.id.0, "b");
3037 }
3038
3039 #[cfg(feature = "mining")]
3040 #[tokio::test]
3041 async fn trigger_requires_mining_trigger_capability() {
3042 let (app, auth) = app_with_store().await;
3043 let write = bearer(&auth, "elliott", MEMORY_WRITE);
3045 let res = app
3046 .oneshot(
3047 Request::builder()
3048 .method("POST")
3049 .uri("/sessions/sess_x/mine")
3050 .header("authorization", &write)
3051 .body(Body::empty())
3052 .unwrap(),
3053 )
3054 .await
3055 .unwrap();
3056 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3057 }
3058
3059 #[cfg(feature = "mining")]
3060 #[tokio::test]
3061 async fn trigger_mines_decision_and_archives() {
3062 let (app, auth) = app_with_store().await;
3066 let ingest = bearer(&auth, "elliott", SESSION_INGEST);
3067 let trigger = bearer(&auth, "elliott", MINING_TRIGGER);
3068
3069 let turn = serde_json::json!({
3071 "session_id": "sess_mine",
3072 "turn_index": 0,
3073 "role": "User",
3074 "content": "We decided to use SurrealDB for storage.",
3075 "timestamp": "0",
3076 });
3077 let res = app
3078 .clone()
3079 .oneshot(
3080 Request::builder()
3081 .method("POST")
3082 .uri("/sessions/sess_mine/turns")
3083 .header("authorization", &ingest)
3084 .header("content-type", "application/json")
3085 .body(Body::from(turn.to_string()))
3086 .unwrap(),
3087 )
3088 .await
3089 .unwrap();
3090 assert_eq!(res.status(), StatusCode::NO_CONTENT);
3091
3092 let res = app
3094 .oneshot(
3095 Request::builder()
3096 .method("POST")
3097 .uri("/sessions/sess_mine/mine")
3098 .header("authorization", &trigger)
3099 .body(Body::empty())
3100 .unwrap(),
3101 )
3102 .await
3103 .unwrap();
3104 assert_eq!(res.status(), StatusCode::OK);
3105 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
3106 .await
3107 .unwrap();
3108 let report: crate::mining_pipeline::MiningReport = serde_json::from_slice(&body).unwrap();
3109 assert!(
3110 report.archived >= 1,
3111 "rules tier should archive the decision: {report:?}"
3112 );
3113 }
3114
3115 async fn body_json(res: axum::response::Response) -> serde_json::Value {
3118 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
3119 .await
3120 .unwrap();
3121 serde_json::from_slice(&body).unwrap()
3122 }
3123
3124 async fn seed_memory(app: &Router, auth: &IjimaAuth, id: &str, project: &str, topic: &str) {
3125 let body = serde_json::json!({
3126 "id": id,
3127 "content": format!("{project}/{topic} note"),
3128 "project": project,
3129 "topic": topic,
3130 "source": "Explicit",
3131 "harness": "Pi",
3132 "session_id": "sess_1",
3133 "importance": 0.5,
3134 "created_at": "0",
3135 })
3136 .to_string();
3137 let res = app
3138 .clone()
3139 .oneshot(
3140 Request::builder()
3141 .method("POST")
3142 .uri("/memories")
3143 .header("authorization", bearer(auth, "elliott", MEMORY_WRITE))
3144 .header("content-type", "application/json")
3145 .body(Body::from(body))
3146 .unwrap(),
3147 )
3148 .await
3149 .unwrap();
3150 assert_eq!(res.status(), StatusCode::OK, "seed {id} failed");
3151 }
3152
3153 #[tokio::test]
3154 async fn store_memory_honors_namespace_query() {
3155 let (app, auth) = app_with_store().await;
3156 let body = serde_json::json!({
3157 "id": "mem_nsimp",
3158 "content": "imported via namespace query",
3159 "project": "ijima",
3160 "topic": "import",
3161 "source": "AutoCapture",
3162 "harness": "Pi",
3163 "importance": 0.5,
3164 "created_at": "0",
3165 })
3166 .to_string();
3167 let res = app
3168 .clone()
3169 .oneshot(
3170 Request::builder()
3171 .method("POST")
3172 .uri("/memories?namespace=ns_import_testbox")
3173 .header("authorization", bearer(&auth, "elliott", MEMORY_WRITE))
3174 .header("content-type", "application/json")
3175 .body(Body::from(body))
3176 .unwrap(),
3177 )
3178 .await
3179 .unwrap();
3180 assert_eq!(res.status(), StatusCode::OK);
3181
3182 let read_token = bearer(&auth, "elliott", MEMORY_READ);
3185 let check = |uri: &str| {
3186 let uri = uri.to_string();
3187 let app = app.clone();
3188 let body = serde_json::json!({
3189 "content": "imported via namespace query"
3190 })
3191 .to_string();
3192 let auth_header = read_token.clone();
3193 async move {
3194 app.oneshot(
3195 Request::builder()
3196 .method("POST")
3197 .uri(uri)
3198 .header("authorization", auth_header)
3199 .header("content-type", "application/json")
3200 .body(Body::from(body))
3201 .unwrap(),
3202 )
3203 .await
3204 .unwrap()
3205 }
3206 };
3207 let res = check("/memories/check?namespace=ns_import_testbox").await;
3208 assert_eq!(res.status(), StatusCode::OK);
3209 let found = body_json(res).await;
3210 assert_eq!(
3211 found["duplicate"].as_str(),
3212 Some("mem_nsimp"),
3213 "same-namespace dedup check must find the import"
3214 );
3215 let res = check("/memories/check").await;
3216 assert_eq!(res.status(), StatusCode::OK);
3217 let personal = body_json(res).await;
3218 assert_eq!(
3219 personal["duplicate"].as_str(),
3220 None,
3221 "personal namespace must not see the import"
3222 );
3223 }
3224
3225 #[tokio::test]
3226 async fn store_memory_rejects_foreign_private_namespace() {
3227 let (app, auth) = app_with_store().await;
3228 let body = serde_json::json!({
3229 "id": "mem_sneaky",
3230 "content": "cross-tenant write attempt",
3231 "project": "ijima",
3232 "topic": "security",
3233 "source": "Explicit",
3234 "harness": "Pi",
3235 "importance": 0.5,
3236 "created_at": "0",
3237 })
3238 .to_string();
3239 let res = app
3240 .oneshot(
3241 Request::builder()
3242 .method("POST")
3243 .uri("/memories?namespace=ns_bob_private")
3244 .header("authorization", bearer(&auth, "elliott", MEMORY_WRITE))
3245 .header("content-type", "application/json")
3246 .body(Body::from(body))
3247 .unwrap(),
3248 )
3249 .await
3250 .unwrap();
3251 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3252 }
3253
3254 #[tokio::test]
3255 async fn rooms_taxonomy_stats_reflect_seeded_memories() {
3256 let (app, auth) = app_with_store().await;
3257 seed_memory(&app, &auth, "mem_a", "ijima", "api").await;
3258 seed_memory(&app, &auth, "mem_b", "ijima", "auth").await;
3259 let read = bearer(&auth, "elliott", MEMORY_READ);
3260
3261 let res = app
3263 .clone()
3264 .oneshot(
3265 Request::builder()
3266 .uri("/rooms")
3267 .header("authorization", &read)
3268 .body(Body::empty())
3269 .unwrap(),
3270 )
3271 .await
3272 .unwrap();
3273 assert_eq!(res.status(), StatusCode::OK);
3274 let rooms = body_json(res).await;
3275 let topics: std::collections::HashSet<&str> = rooms
3276 .as_array()
3277 .unwrap()
3278 .iter()
3279 .map(|r| r["topic"].as_str().unwrap())
3280 .collect();
3281 assert!(
3282 topics.contains("api") && topics.contains("auth"),
3283 "rooms: {rooms}"
3284 );
3285
3286 let res = app
3288 .clone()
3289 .oneshot(
3290 Request::builder()
3291 .uri("/memories/stats")
3292 .header("authorization", &read)
3293 .body(Body::empty())
3294 .unwrap(),
3295 )
3296 .await
3297 .unwrap();
3298 assert_eq!(res.status(), StatusCode::OK);
3299 let stats = body_json(res).await;
3300 assert_eq!(stats["total"], 2, "stats: {stats}");
3301 assert_eq!(stats["projects"][0]["project"], "ijima");
3302 assert_eq!(stats["projects"][0]["count"], 2);
3303 }
3304
3305 #[tokio::test]
3306 async fn browse_memories_filters_by_project() {
3307 let (app, auth) = app_with_store().await;
3308 seed_memory(&app, &auth, "mem_a", "ijima", "api").await;
3309 seed_memory(&app, &auth, "mem_b", "possum", "efficiency").await;
3310 let read = bearer(&auth, "elliott", MEMORY_READ);
3311
3312 let res = app
3313 .clone()
3314 .oneshot(
3315 Request::builder()
3316 .uri("/memories?project=possum")
3317 .header("authorization", &read)
3318 .body(Body::empty())
3319 .unwrap(),
3320 )
3321 .await
3322 .unwrap();
3323 assert_eq!(res.status(), StatusCode::OK);
3324 let mems = body_json(res).await;
3325 let arr = mems.as_array().unwrap();
3326 assert_eq!(arr.len(), 1);
3327 assert_eq!(arr[0]["project"], "possum");
3328 }
3329
3330 #[tokio::test]
3331 async fn palace_graph_and_tunnel_link_shared_topic() {
3332 let (app, auth) = app_with_store().await;
3333 seed_memory(&app, &auth, "mem_a", "ijima", "efficiency").await;
3334 seed_memory(&app, &auth, "mem_b", "possum", "efficiency").await;
3335 let read = bearer(&auth, "elliott", MEMORY_READ);
3336
3337 let res = app
3338 .clone()
3339 .oneshot(
3340 Request::builder()
3341 .uri("/palace/graph")
3342 .header("authorization", &read)
3343 .body(Body::empty())
3344 .unwrap(),
3345 )
3346 .await
3347 .unwrap();
3348 assert_eq!(res.status(), StatusCode::OK);
3349 let graph = body_json(res).await;
3350 let projects: std::collections::HashSet<&str> = graph["projects"]
3351 .as_array()
3352 .unwrap()
3353 .iter()
3354 .map(|p| p.as_str().unwrap())
3355 .collect();
3356 assert!(
3357 projects.contains("ijima") && projects.contains("possum"),
3358 "graph: {graph}"
3359 );
3360
3361 let res = app
3362 .clone()
3363 .oneshot(
3364 Request::builder()
3365 .uri("/palace/tunnel?topic=efficiency&project_a=ijima&project_b=possum")
3366 .header("authorization", &read)
3367 .body(Body::empty())
3368 .unwrap(),
3369 )
3370 .await
3371 .unwrap();
3372 assert_eq!(res.status(), StatusCode::OK);
3373 let trav = body_json(res).await;
3374 assert_eq!(trav["memories_a"].as_array().unwrap().len(), 1);
3375 assert_eq!(trav["memories_b"].as_array().unwrap().len(), 1);
3376 }
3377
3378 #[tokio::test]
3379 async fn diary_write_then_read_round_trips() {
3380 let (app, auth) = app_with_store().await;
3381 let write = bearer(&auth, "elliott", MEMORY_WRITE);
3382 let read = bearer(&auth, "elliott", MEMORY_READ);
3383
3384 let body = serde_json::json!({
3385 "agent": "pi",
3386 "content": "shipped the routes",
3387 "topic": "ijima",
3388 "timestamp": "2026-08-09T12:00:00Z"
3389 })
3390 .to_string();
3391 let res = app
3392 .clone()
3393 .oneshot(
3394 Request::builder()
3395 .method("POST")
3396 .uri("/diaries")
3397 .header("authorization", &write)
3398 .header("content-type", "application/json")
3399 .body(Body::from(body))
3400 .unwrap(),
3401 )
3402 .await
3403 .unwrap();
3404 assert_eq!(res.status(), StatusCode::NO_CONTENT);
3405
3406 let res = app
3407 .clone()
3408 .oneshot(
3409 Request::builder()
3410 .uri("/diaries/pi")
3411 .header("authorization", &read)
3412 .body(Body::empty())
3413 .unwrap(),
3414 )
3415 .await
3416 .unwrap();
3417 assert_eq!(res.status(), StatusCode::OK);
3418 let entries = body_json(res).await;
3419 let arr = entries.as_array().unwrap();
3420 assert_eq!(arr.len(), 1);
3421 assert_eq!(arr[0]["content"], "shipped the routes");
3422 }
3423
3424 #[tokio::test]
3425 async fn diary_write_requires_memory_write_not_read() {
3426 let (app, auth) = app_with_store().await;
3427 let read = bearer(&auth, "elliott", MEMORY_READ);
3428 let body = serde_json::json!({"agent": "pi", "content": "x", "timestamp": "t"}).to_string();
3429 let res = app
3430 .clone()
3431 .oneshot(
3432 Request::builder()
3433 .method("POST")
3434 .uri("/diaries")
3435 .header("authorization", &read)
3436 .header("content-type", "application/json")
3437 .body(Body::from(body))
3438 .unwrap(),
3439 )
3440 .await
3441 .unwrap();
3442 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3443 }
3444
3445 #[tokio::test]
3446 async fn repo_register_list_resolve_round_trips() {
3447 let (app, auth) = app_with_store().await;
3448 let admin = bearer(&auth, "elliott", ADMIN);
3449 let read = bearer(&auth, "elliott", MEMORY_READ);
3450
3451 let body = serde_json::json!({
3453 "name": "Ijima",
3454 "path": "/home/x/Ijima",
3455 "remote_url": "git@github.com:Industrial-Algebra/Ijima.git",
3456 "role": "memory-service"
3457 })
3458 .to_string();
3459 let res = app
3460 .clone()
3461 .oneshot(
3462 Request::builder()
3463 .method("POST")
3464 .uri("/repos")
3465 .header("authorization", &admin)
3466 .header("content-type", "application/json")
3467 .body(Body::from(body))
3468 .unwrap(),
3469 )
3470 .await
3471 .unwrap();
3472 assert_eq!(res.status(), StatusCode::NO_CONTENT);
3473
3474 let res = app
3476 .clone()
3477 .oneshot(
3478 Request::builder()
3479 .uri("/repos")
3480 .header("authorization", &read)
3481 .body(Body::empty())
3482 .unwrap(),
3483 )
3484 .await
3485 .unwrap();
3486 assert_eq!(res.status(), StatusCode::OK);
3487 let repos = body_json(res).await;
3488 assert_eq!(repos[0]["name"], "Ijima");
3489 assert_eq!(repos[0]["path"], "/home/x/Ijima");
3490
3491 let res = app
3493 .clone()
3494 .oneshot(
3495 Request::builder()
3496 .uri("/repos/resolve?cwd=/home/x/Ijima/src")
3497 .header("authorization", &read)
3498 .body(Body::empty())
3499 .unwrap(),
3500 )
3501 .await
3502 .unwrap();
3503 assert_eq!(res.status(), StatusCode::OK);
3504 let repo = body_json(res).await;
3505 assert_eq!(repo["name"], "Ijima");
3506
3507 let res = app
3509 .clone()
3510 .oneshot(
3511 Request::builder()
3512 .uri("/repos/resolve?cwd=/nowhere/here")
3513 .header("authorization", &read)
3514 .body(Body::empty())
3515 .unwrap(),
3516 )
3517 .await
3518 .unwrap();
3519 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3520 }
3521
3522 #[tokio::test]
3523 async fn repo_register_requires_admin() {
3524 let (app, auth) = app_with_store().await;
3525 let read = bearer(&auth, "elliott", MEMORY_READ);
3526 let body = serde_json::json!({
3527 "name": "X", "path": "/x", "remote_url": "u", "role": "r"
3528 })
3529 .to_string();
3530 let res = app
3531 .clone()
3532 .oneshot(
3533 Request::builder()
3534 .method("POST")
3535 .uri("/repos")
3536 .header("authorization", &read)
3537 .header("content-type", "application/json")
3538 .body(Body::from(body))
3539 .unwrap(),
3540 )
3541 .await
3542 .unwrap();
3543 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3544 }
3545
3546 #[tokio::test]
3547 async fn token_revocation_kills_the_bearer_immediately() {
3548 let (app, auth) = app_with_store().await;
3549 let admin = bearer(&auth, "op", ADMIN);
3550 let victim = bearer(&auth, "victim", MEMORY_READ);
3551
3552 let res = app
3554 .clone()
3555 .oneshot(
3556 Request::builder()
3557 .uri("/memories")
3558 .header("authorization", &victim)
3559 .body(Body::empty())
3560 .unwrap(),
3561 )
3562 .await
3563 .unwrap();
3564 assert_eq!(res.status(), StatusCode::OK);
3565
3566 let res = app
3568 .clone()
3569 .oneshot(
3570 Request::builder()
3571 .method("POST")
3572 .uri("/tokens/revoke")
3573 .header("authorization", &victim)
3574 .header("content-type", "application/json")
3575 .body(Body::from(
3576 serde_json::json!({ "token": victim, "reason": "test" }).to_string(),
3577 ))
3578 .unwrap(),
3579 )
3580 .await
3581 .unwrap();
3582 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3583
3584 let res = app
3586 .clone()
3587 .oneshot(
3588 Request::builder()
3589 .method("POST")
3590 .uri("/tokens/revoke")
3591 .header("authorization", &admin)
3592 .header("content-type", "application/json")
3593 .body(Body::from(
3594 serde_json::json!({ "token": victim, "reason": "leaked in test" })
3595 .to_string(),
3596 ))
3597 .unwrap(),
3598 )
3599 .await
3600 .unwrap();
3601 assert_eq!(res.status(), StatusCode::NO_CONTENT);
3602
3603 let res = app
3605 .clone()
3606 .oneshot(
3607 Request::builder()
3608 .uri("/memories")
3609 .header("authorization", &victim)
3610 .body(Body::empty())
3611 .unwrap(),
3612 )
3613 .await
3614 .unwrap();
3615 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
3616
3617 let res = app
3619 .clone()
3620 .oneshot(
3621 Request::builder()
3622 .uri("/tokens/revocations")
3623 .header("authorization", &admin)
3624 .body(Body::empty())
3625 .unwrap(),
3626 )
3627 .await
3628 .unwrap();
3629 assert_eq!(res.status(), StatusCode::OK);
3630 let body: serde_json::Value = serde_json::from_slice(
3631 &axum::body::to_bytes(res.into_body(), usize::MAX)
3632 .await
3633 .unwrap(),
3634 )
3635 .unwrap();
3636 let revs = body.as_array().expect("list response");
3637 assert_eq!(revs.len(), 1);
3638 assert_eq!(revs[0]["reason"], "leaked in test");
3639 assert_eq!(
3640 revs[0]["token_hash"].as_str().expect("hash"),
3641 crate::auth::bearer_hash(&victim)
3642 );
3643 assert!(!revs[0].to_string().contains(&victim), "no raw bearer");
3644
3645 let listed: Vec<TokenRevocation> =
3648 serde_json::from_value(body).expect("deserializes as TokenRevocation");
3649 auth.hydrate_revocations(&listed);
3650 assert!(auth.is_revoked(&victim));
3651 }
3652}