feat: browse Subject Journals and persistent Memory links
This commit is contained in:
@@ -28,6 +28,7 @@ use subjektiv::api::*;
|
||||
mod backend_job_profile;
|
||||
mod subjektiv_job;
|
||||
mod subjektiv_journal;
|
||||
mod subjektiv_journal_browser;
|
||||
mod subjektiv_memory_journal;
|
||||
mod worker_operations;
|
||||
mod worker_protocol;
|
||||
@@ -3133,7 +3134,11 @@ async fn authorize_scoped_workspace_request(
|
||||
));
|
||||
}
|
||||
}
|
||||
if mutating || request.headers().contains_key(axum::http::header::UPGRADE) {
|
||||
// The Journal browser uses POST only to carry bounded typed read selectors. Its
|
||||
// action set is read-only and follows the same user read authority as Memory GET.
|
||||
if (mutating && !subjektiv_journal_browser::is_read_path(&request_path, workspace_id))
|
||||
|| request.headers().contains_key(axum::http::header::UPGRADE)
|
||||
{
|
||||
let workspace = api
|
||||
.store
|
||||
.get_workspace(workspace_id)
|
||||
@@ -3309,7 +3314,8 @@ async fn authorize_workspace_api_request(
|
||||
}
|
||||
}
|
||||
if !public_server_api
|
||||
&& (mutating || request.headers().contains_key(axum::http::header::UPGRADE))
|
||||
&& ((mutating && !subjektiv_journal_browser::is_read_path(&request_path, &workspace_id))
|
||||
|| request.headers().contains_key(axum::http::header::UPGRADE))
|
||||
{
|
||||
let workspace = match api.store.get_workspace(&workspace_id).await {
|
||||
Ok(workspace) => workspace,
|
||||
@@ -13151,6 +13157,7 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
.route("/api/w/{workspace_id}/subjektiv/journal/compaction", post(subjektiv_journal::compaction_route))
|
||||
.route("/api/w/{workspace_id}/subjektiv/journal/startup", get(subjektiv_journal::startup_route))
|
||||
.route("/api/w/{workspace_id}/subjektiv/journal/recording", post(subjektiv_journal::recording_route))
|
||||
.route("/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/journal-browser", post(subjektiv_journal_browser::route))
|
||||
.route("/api/w/{workspace_id}/subjektiv/journal/memory", post(subjektiv_memory_journal::route))
|
||||
.route("/api/w/{workspace_id}/subjektiv/journal/job", post(subjektiv_journal::job_route))
|
||||
.route("/api/w/{workspace_id}/subjektiv/journal/job/finish", post(subjektiv_journal::finish_route))
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
//! User-authenticated, read-only Web Journal API. Saved Journal and immutable Memory edges
|
||||
//! never depend on a live source Worker, original history reads, or model execution.
|
||||
use super::*;
|
||||
use subjektiv::journal::memory_links::*;
|
||||
use subjektiv::journal::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub(super) enum BrowserRequest {
|
||||
Sessions {
|
||||
after: Option<String>,
|
||||
limit: usize,
|
||||
},
|
||||
Entries {
|
||||
journal_id: Option<String>,
|
||||
query: Option<String>,
|
||||
after: Option<String>,
|
||||
limit: usize,
|
||||
},
|
||||
Entry {
|
||||
journal_id: String,
|
||||
entry_id: String,
|
||||
},
|
||||
Text {
|
||||
journal_id: String,
|
||||
entry_id: Option<String>,
|
||||
field: JournalTextField,
|
||||
expected_token: Option<String>,
|
||||
byte_offset: usize,
|
||||
max_bytes: usize,
|
||||
},
|
||||
Corrections {
|
||||
journal_id: String,
|
||||
entry_id: String,
|
||||
after: Option<String>,
|
||||
limit: usize,
|
||||
},
|
||||
MemorySources {
|
||||
selector: MemoryJournalSelector,
|
||||
source_offset: usize,
|
||||
limit: usize,
|
||||
},
|
||||
RelatedMemory {
|
||||
journal_id: String,
|
||||
entry_id: String,
|
||||
after: Option<RelatedMemoryCursor>,
|
||||
limit: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub(super) enum BrowserResponse {
|
||||
Sessions(JournalPage<BrowserSession>),
|
||||
Entries(JournalPage<JournalEntrySummary>),
|
||||
Entry(BrowserEntry),
|
||||
Text(JournalTextPage),
|
||||
Corrections(JournalPage<JournalEntrySummary>),
|
||||
MemorySources(BrowserMemorySources),
|
||||
RelatedMemory(BrowserRelatedMemories),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(super) enum SourceState {
|
||||
Missing,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct SourceStatus {
|
||||
/// `missing` requires confirmed metadata absence; `unavailable` includes unknown readability.
|
||||
pub state: SourceState,
|
||||
/// Only supplied for a retained, exact Session mapping, under this user's Workspace authority.
|
||||
/// A navigation option is not an attestation that all original bytes still exist.
|
||||
pub history_href: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct BrowserSession {
|
||||
pub journal: JournalMetadata,
|
||||
pub unreflected_sources: bool,
|
||||
pub original_source: SourceStatus,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct BrowserEntry {
|
||||
pub journal: JournalMetadata,
|
||||
pub entry: JournalEntrySummary,
|
||||
pub correction_targets: Vec<JournalEntrySummary>,
|
||||
pub original_source: SourceStatus,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct BrowserMemorySource {
|
||||
pub link: MemoryJournalSourceLink,
|
||||
pub entry: Option<JournalEntrySummary>,
|
||||
pub original_source: SourceStatus,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct BrowserMemorySources {
|
||||
pub selector: MemoryJournalSelector,
|
||||
pub sources: Vec<BrowserMemorySource>,
|
||||
pub total_sources: usize,
|
||||
pub next_source_index: Option<usize>,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct BrowserRelatedMemory {
|
||||
pub related: RelatedMemory,
|
||||
pub claim: String,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct BrowserRelatedMemories {
|
||||
pub items: Vec<BrowserRelatedMemory>,
|
||||
pub next: Option<RelatedMemoryCursor>,
|
||||
}
|
||||
|
||||
fn domain(error: JournalError) -> Error {
|
||||
match error {
|
||||
JournalError::PermissionDenied(message) => Error::WorkspacePermissionDenied(message),
|
||||
JournalError::Conflict(message) => Error::RepositoryConflict(message),
|
||||
JournalError::InvalidInput(message) => Error::InvalidInput(message),
|
||||
JournalError::NotFound(id) => {
|
||||
Error::InvalidInput(format!("Journal record not found: {id}"))
|
||||
}
|
||||
JournalError::Subject(error) => subjektiv_store_error(error),
|
||||
other => Error::Store(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn source_status(
|
||||
api: &WorkspaceApi,
|
||||
store: &crate::subjektiv::SubjektivStore,
|
||||
subject_id: &str,
|
||||
session: Option<&str>,
|
||||
) -> SourceStatus {
|
||||
let unavailable = || SourceStatus {
|
||||
state: SourceState::Unavailable,
|
||||
history_href: None,
|
||||
};
|
||||
let Some(session) = session else {
|
||||
return unavailable();
|
||||
};
|
||||
let Ok(Some(attr)) = store.session_attribution(session) else {
|
||||
return unavailable();
|
||||
};
|
||||
if attr.subject_id != subject_id {
|
||||
return unavailable();
|
||||
}
|
||||
let worker = RuntimeWorkerRef::new(&attr.runtime_id, &attr.worker_id);
|
||||
let Ok(archive) =
|
||||
api.config_store
|
||||
.worker_session_archive(&api.config.workspace_id, &worker, session)
|
||||
else {
|
||||
return unavailable();
|
||||
};
|
||||
let Ok(registry) = api
|
||||
.store
|
||||
.get_worker_registry(&api.config.workspace_id, &worker)
|
||||
else {
|
||||
return unavailable();
|
||||
};
|
||||
if archive.is_none() && registry.is_none() {
|
||||
return SourceStatus {
|
||||
state: SourceState::Missing,
|
||||
history_href: None,
|
||||
};
|
||||
}
|
||||
// The console addresses the current Session only. Never point an old attributed
|
||||
// Session at a different current conversation, or restore a Worker to manufacture a link.
|
||||
let exact = registry
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.session_ref.as_deref() == Some(session));
|
||||
let history_href = if exact {
|
||||
api.store
|
||||
.worker_resource_key(&api.config.workspace_id, &worker)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|key| browser_worker_console_href(&api.config.workspace_id, &key))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
SourceStatus {
|
||||
state: SourceState::Unavailable,
|
||||
history_href,
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact route recognition for the normal user read-authority check. Do not include
|
||||
/// any action that writes or reconciles state in this endpoint's tagged request type.
|
||||
pub(super) fn is_read_path(path: &str, workspace: &str) -> bool {
|
||||
path.strip_prefix(&format!("/api/w/{workspace}/subjektiv/subjects/"))
|
||||
.and_then(|tail| tail.strip_suffix("/journal-browser"))
|
||||
.is_some_and(|subject| !subject.is_empty() && !subject.contains('/'))
|
||||
}
|
||||
|
||||
pub(super) async fn route(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
|
||||
actor: Option<Extension<RequestActor>>,
|
||||
Json(request): Json<BrowserRequest>,
|
||||
) -> ApiResult<Json<BrowserResponse>> {
|
||||
// Runtime signatures, singleton leases and Job grants are not user browser authority.
|
||||
if actor.is_none() {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Journal browser requires user authentication".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let store = open_subjektiv_store(&api)?;
|
||||
require_subjektiv_subject(&store, &path.subject_id)?;
|
||||
let host =
|
||||
JournalHostContext::validated_reader(&store, &path.subject_id).map_err(domain)?;
|
||||
let response =
|
||||
execute(&api, &store, &host, &path.subject_id, request).map_err(domain)?;
|
||||
if serde_json::to_vec(&response)
|
||||
.map_err(|e| Error::Store(e.to_string()))?
|
||||
.len()
|
||||
> MAX_JOURNAL_RESPONSE_BYTES
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"Journal browser response exceeds byte budget; request a smaller page".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(Json(response))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn execute(
|
||||
api: &WorkspaceApi,
|
||||
store: &crate::subjektiv::SubjektivStore,
|
||||
host: &JournalHostContext,
|
||||
subject_id: &str,
|
||||
request: BrowserRequest,
|
||||
) -> JournalResult<BrowserResponse> {
|
||||
Ok(match request {
|
||||
BrowserRequest::Sessions { after, limit } => {
|
||||
if after.as_ref().is_some_and(|s| s.len() > 256) {
|
||||
return Err(JournalError::InvalidInput(
|
||||
"Journal cursor exceeds byte limit".into(),
|
||||
));
|
||||
}
|
||||
let page = store.list_journals(host, after.as_deref(), limit)?;
|
||||
let mut items = Vec::new();
|
||||
for journal in page.items {
|
||||
let unreflected_sources =
|
||||
store.journal_has_unreflected_sources(host, &journal.id)?;
|
||||
let original_source =
|
||||
source_status(api, store, subject_id, Some(&journal.source_session_id));
|
||||
items.push(BrowserSession {
|
||||
journal,
|
||||
unreflected_sources,
|
||||
original_source,
|
||||
});
|
||||
}
|
||||
BrowserResponse::Sessions(JournalPage {
|
||||
items,
|
||||
next: page.next,
|
||||
})
|
||||
}
|
||||
BrowserRequest::Entries {
|
||||
journal_id,
|
||||
query,
|
||||
after,
|
||||
limit,
|
||||
} => BrowserResponse::Entries(store.browse_journal_entries(
|
||||
host,
|
||||
journal_id.as_deref(),
|
||||
query.as_deref(),
|
||||
None,
|
||||
after.as_deref(),
|
||||
limit,
|
||||
)?),
|
||||
BrowserRequest::Entry {
|
||||
journal_id,
|
||||
entry_id,
|
||||
} => {
|
||||
let journal = store.read_journal(host, &journal_id)?;
|
||||
let entry = store.journal_entry_summary(host, &journal_id, &entry_id)?;
|
||||
let original_source =
|
||||
source_status(api, store, subject_id, Some(&journal.source_session_id));
|
||||
let correction_targets =
|
||||
store.journal_correction_targets(host, &journal_id, &entry_id)?;
|
||||
BrowserResponse::Entry(BrowserEntry {
|
||||
journal,
|
||||
entry,
|
||||
correction_targets,
|
||||
original_source,
|
||||
})
|
||||
}
|
||||
BrowserRequest::Text {
|
||||
journal_id,
|
||||
entry_id,
|
||||
field,
|
||||
expected_token,
|
||||
byte_offset,
|
||||
max_bytes,
|
||||
} => BrowserResponse::Text(store.read_journal_text(
|
||||
host,
|
||||
&journal_id,
|
||||
entry_id.as_deref(),
|
||||
field,
|
||||
expected_token.as_deref(),
|
||||
byte_offset,
|
||||
max_bytes,
|
||||
)?),
|
||||
BrowserRequest::Corrections {
|
||||
journal_id,
|
||||
entry_id,
|
||||
after,
|
||||
limit,
|
||||
} => BrowserResponse::Corrections(store.browse_journal_entries(
|
||||
host,
|
||||
Some(&journal_id),
|
||||
None,
|
||||
Some(&entry_id),
|
||||
after.as_deref(),
|
||||
limit,
|
||||
)?),
|
||||
BrowserRequest::MemorySources {
|
||||
selector,
|
||||
source_offset,
|
||||
limit,
|
||||
} => {
|
||||
if !(1..=MAX_JOURNAL_PAGE).contains(&limit) {
|
||||
return Err(JournalError::InvalidInput(
|
||||
"page limit must be 1..32".into(),
|
||||
));
|
||||
}
|
||||
let response = store.execute_memory_journal(
|
||||
host,
|
||||
MemoryJournalRequest::LinksPage {
|
||||
selector: selector.clone(),
|
||||
source_offset,
|
||||
limit: limit.min(crate::subjektiv::MAX_STAGING_ANCHORS),
|
||||
},
|
||||
)?;
|
||||
let MemoryJournalResponse::Links(page) = response else {
|
||||
unreachable!()
|
||||
};
|
||||
let candidate = store.staging_candidate(subject_id, &selector.candidate_id)?;
|
||||
let mut sources = Vec::new();
|
||||
for mut link in page.sources {
|
||||
let source_session = candidate
|
||||
.as_ref()
|
||||
.and_then(|c| c.source_refs.get(link.source_index))
|
||||
.and_then(|s| s.session_id.as_deref());
|
||||
let mut original_source = source_status(api, store, subject_id, source_session);
|
||||
let entry = if let SourceConnection::Linked {
|
||||
journal_id,
|
||||
entry_id,
|
||||
target: JournalTargetAvailability::Available,
|
||||
..
|
||||
} = &link.connection
|
||||
{
|
||||
let journal = store.read_journal(host, journal_id)?;
|
||||
original_source =
|
||||
source_status(api, store, subject_id, Some(&journal.source_session_id));
|
||||
Some(store.journal_entry_summary(host, journal_id, entry_id)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if link.original_source == OriginalSourceAvailability::Missing {
|
||||
// A saved missing-byte attestation does not prove deletion of the whole
|
||||
// original conversation. Keep only independently confirmed metadata absence
|
||||
// as `missing`; otherwise expose unavailable material without a history link.
|
||||
if original_source.state != SourceState::Missing {
|
||||
original_source.state = SourceState::Unavailable;
|
||||
}
|
||||
original_source.history_href = None;
|
||||
}
|
||||
if original_source.state == SourceState::Missing {
|
||||
link.original_source = OriginalSourceAvailability::Missing;
|
||||
}
|
||||
sources.push(BrowserMemorySource {
|
||||
link,
|
||||
entry,
|
||||
original_source,
|
||||
});
|
||||
}
|
||||
BrowserResponse::MemorySources(BrowserMemorySources {
|
||||
selector,
|
||||
sources,
|
||||
total_sources: page.total_sources,
|
||||
next_source_index: page.next_source_index,
|
||||
})
|
||||
}
|
||||
BrowserRequest::RelatedMemory {
|
||||
journal_id,
|
||||
entry_id,
|
||||
after,
|
||||
limit,
|
||||
} => {
|
||||
let response = store.execute_memory_journal(
|
||||
host,
|
||||
MemoryJournalRequest::Related {
|
||||
journal_id,
|
||||
entry_id,
|
||||
after,
|
||||
limit,
|
||||
},
|
||||
)?;
|
||||
let MemoryJournalResponse::Related(page) = response else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut items = Vec::new();
|
||||
for related in page.items {
|
||||
let memory = store
|
||||
.memory_change(
|
||||
subject_id,
|
||||
&related.selector.memory_id,
|
||||
related.selector.change_id.clone(),
|
||||
)?
|
||||
.ok_or_else(|| JournalError::NotFound("related Memory change".into()))?;
|
||||
items.push(BrowserRelatedMemory {
|
||||
related,
|
||||
claim: bounded_memory_excerpt(&memory.claim, 256),
|
||||
});
|
||||
}
|
||||
BrowserResponse::RelatedMemory(BrowserRelatedMemories {
|
||||
items,
|
||||
next: page.next,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
//! Real user-authenticated production router tests, sharing the Memory/Journal provenance fixture.
|
||||
//! There is deliberately no Runtime adapter: reads must use saved state, not a source Worker.
|
||||
use super::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn subject(f: &Fixture) -> String {
|
||||
f.store
|
||||
.read_journal(&f.host, &f.journal_id)
|
||||
.unwrap()
|
||||
.subject_id
|
||||
}
|
||||
fn browser_path(subject: &str) -> String {
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/journal-browser")
|
||||
}
|
||||
fn browser_request(subject: &str, token: Option<&str>, body: serde_json::Value) -> Request<Body> {
|
||||
let mut request = Request::builder()
|
||||
.method("POST")
|
||||
.uri(browser_path(subject))
|
||||
.header(CONTENT_TYPE, "application/json");
|
||||
if let Some(token) = token {
|
||||
request = request.header("authorization", format!("Bearer {token}"));
|
||||
}
|
||||
request
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap()
|
||||
}
|
||||
async fn browser_call(f: &Fixture, token: &str, body: serde_json::Value) -> serde_json::Value {
|
||||
let (status, bytes) = response(
|
||||
build_router(f.api.clone()),
|
||||
browser_request(&subject(f), Some(token), body),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
);
|
||||
assert!(bytes.len() <= MAX_JOURNAL_RESPONSE_BYTES);
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
fn detail(f: &Fixture) -> serde_json::Value {
|
||||
json!({"action":"entry","journal_id":f.journal_id,"entry_id":f.entry_id})
|
||||
}
|
||||
fn text(f: &Fixture, field: &str) -> serde_json::Value {
|
||||
json!({"action":"text","journal_id":f.journal_id,
|
||||
"entry_id": if field == "current_position" { None } else { Some(&f.entry_id) },
|
||||
"field":field,"expected_token":null,"byte_offset":0,"max_bytes":8192})
|
||||
}
|
||||
|
||||
/// Adds another real saved Session/Journal, optionally correcting an entry in the first Session.
|
||||
fn later_journal(f: &Fixture, correcting: Vec<String>) -> (String, String) {
|
||||
let session = Uuid::new_v4().to_string();
|
||||
let attr = f
|
||||
.store
|
||||
.record_session_attribution(
|
||||
crate::subjektiv::SubjectSessionAttribution::new(
|
||||
subject(f),
|
||||
RUNTIME,
|
||||
&f.source_worker.worker_id,
|
||||
&session,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let writer = f
|
||||
.store
|
||||
.claim_journal_writer(&subject(f), RUNTIME, &f.source_worker.worker_id)
|
||||
.unwrap();
|
||||
let host = f.store.bind_journal_session(&writer, attr).unwrap();
|
||||
let content = "A later Session revisits the constraint.";
|
||||
let range = JournalSourceRange {
|
||||
source: JournalSource {
|
||||
session_id: session,
|
||||
pointer: JournalSourcePointer {
|
||||
segment_id: Uuid::new_v4(),
|
||||
entry_ref: "E0".into(),
|
||||
},
|
||||
order: 0,
|
||||
content_sha256: Sha256::digest(content.as_bytes())
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect(),
|
||||
total_bytes: content.len() as u64,
|
||||
},
|
||||
bytes: JournalByteRange {
|
||||
start: 0,
|
||||
end: content.len() as u64,
|
||||
},
|
||||
};
|
||||
let id = f
|
||||
.store
|
||||
.accept_journal_sources(&host, "Later Session", &[range.clone()])
|
||||
.unwrap();
|
||||
let binding = subjektiv::api::JobAttemptBinding {
|
||||
job_id: "later-fixture".into(),
|
||||
attempt_id: "one".into(),
|
||||
};
|
||||
let generation = f
|
||||
.store
|
||||
.prepare_journal_generation(&host, &id, binding.clone(), vec![range.clone()])
|
||||
.unwrap();
|
||||
let job = JournalJobContext::validated(&f.store, &host, &generation.id, binding).unwrap();
|
||||
subjektiv::journal::service::execute_bounded(
|
||||
&f.store,
|
||||
&job,
|
||||
JournalJobRequest::ReadText {
|
||||
read: JournalTextRead {
|
||||
target: JournalTextTarget::Source {
|
||||
pointer: range.source.pointer.clone(),
|
||||
},
|
||||
offset: 0,
|
||||
limit: 8192,
|
||||
content_token: None,
|
||||
},
|
||||
},
|
||||
|_, _, _| {
|
||||
Ok(subjektiv::journal::recording::JournalMaterial {
|
||||
range: range.clone(),
|
||||
text: content.into(),
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let entry = f
|
||||
.store
|
||||
.execute_journal(
|
||||
&job,
|
||||
JournalOperation {
|
||||
request_id: "later-entry".into(),
|
||||
expected_state: generation.initial_journal.state_token,
|
||||
action: JournalAction::Create {
|
||||
title: "Later qualification".into(),
|
||||
body: content.into(),
|
||||
ongoing: String::new(),
|
||||
state: JournalEntryState::Fixed,
|
||||
source_end: Some(range.source.pointer.clone()),
|
||||
correction_refs: correcting,
|
||||
},
|
||||
covered_ranges: vec![range],
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
(id, entry)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_user_auth_subject_boundary_and_read_only_actions() {
|
||||
let f = Fixture::new().await;
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "journal-browser-user").await;
|
||||
let app = build_router(f.api.clone());
|
||||
assert_eq!(
|
||||
response(app.clone(), browser_request(&subject(&f), None, detail(&f)))
|
||||
.await
|
||||
.0,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let signed = runtime_source_request(
|
||||
&f.identity,
|
||||
Some(&f.worker.worker_id),
|
||||
"POST",
|
||||
&browser_path(&subject(&f)),
|
||||
serde_json::to_vec(&detail(&f)).unwrap(),
|
||||
);
|
||||
assert_eq!(response(app.clone(), signed).await.0, StatusCode::FORBIDDEN);
|
||||
let other = f
|
||||
.store
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("other-browser").unwrap())
|
||||
.unwrap();
|
||||
for body in [
|
||||
detail(&f),
|
||||
text(&f, "body"),
|
||||
json!({"action":"entries","journal_id":f.journal_id,"query":null,"after":null,"limit":10}),
|
||||
json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":10}),
|
||||
json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":10}),
|
||||
] {
|
||||
let (status, bytes) =
|
||||
response(app.clone(), browser_request(&other.id, Some(&token), body)).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(!String::from_utf8_lossy(&bytes).contains(JOURNAL_BODY));
|
||||
}
|
||||
let empty = response(
|
||||
app.clone(),
|
||||
browser_request(
|
||||
&other.id,
|
||||
Some(&token),
|
||||
json!({"action":"sessions","after":null,"limit":10}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(empty.0, StatusCode::OK);
|
||||
assert!(
|
||||
serde_json::from_slice::<serde_json::Value>(&empty.1).unwrap()["value"]["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let unknown_action = browser_request(
|
||||
&subject(&f),
|
||||
Some(&token),
|
||||
json!({"action":"reconcile","selector":f.selector}),
|
||||
);
|
||||
assert_eq!(
|
||||
response(app, unknown_action).await.0,
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
let entry = browser_call(&f, &token, detail(&f)).await;
|
||||
assert_eq!(entry["value"]["entry"]["state"], "open");
|
||||
assert_eq!(entry["value"]["entry"]["title"], "Choosing the approach");
|
||||
assert_eq!(entry["value"]["original_source"]["state"], "unavailable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_multiple_sessions_recent_search_and_filter_bound_paging() {
|
||||
let f = Fixture::new().await;
|
||||
f.fix_initial();
|
||||
let (later, later_entry) = later_journal(&f, vec![]);
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-pages").await;
|
||||
let first = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"sessions","after":null,"limit":1}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first["value"]["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(first["value"]["items"][0]["unreflected_sources"], true);
|
||||
let second = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"sessions","after":first["value"]["next"],"limit":1}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(second["value"]["items"].as_array().unwrap().len(), 1);
|
||||
assert_ne!(
|
||||
first["value"]["items"][0]["journal"]["id"],
|
||||
second["value"]["items"][0]["journal"]["id"]
|
||||
);
|
||||
assert!(second["value"]["next"].is_null());
|
||||
let first = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"entries","journal_id":null,"query":null,"after":null,"limit":1}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first["value"]["items"][0]["id"], later_entry);
|
||||
let second = browser_call(&f, &token, json!({"action":"entries","journal_id":null,"query":null,"after":first["value"]["next"],"limit":1})).await;
|
||||
assert_eq!(second["value"]["items"][0]["id"], f.entry_id);
|
||||
assert!(second["value"]["next"].is_null());
|
||||
let filtered = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"entries","journal_id":later,"query":"REVISITS","after":null,"limit":32}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(filtered["value"]["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(filtered["value"]["items"][0]["id"], later_entry);
|
||||
for query in ["no such event", "%"] {
|
||||
let empty = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"entries","journal_id":null,"query":query,"after":null,"limit":32}),
|
||||
)
|
||||
.await;
|
||||
assert!(empty["value"]["items"].as_array().unwrap().is_empty());
|
||||
}
|
||||
let changed_filter = browser_request(
|
||||
&subject(&f),
|
||||
Some(&token),
|
||||
json!({"action":"entries","journal_id":later,
|
||||
"query":null,"after":first["value"]["next"],"limit":1}),
|
||||
);
|
||||
assert_eq!(
|
||||
response(build_router(f.api.clone()), changed_filter)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
let other = f
|
||||
.store
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("cursor-other").unwrap())
|
||||
.unwrap();
|
||||
let cross_subject = browser_request(
|
||||
&other.id,
|
||||
Some(&token),
|
||||
json!({"action":"entries","journal_id":null,
|
||||
"query":null,"after":first["value"]["next"],"limit":1}),
|
||||
);
|
||||
assert_eq!(
|
||||
response(build_router(f.api.clone()), cross_subject).await.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_text_bounds_tokens_position_and_ongoing_are_independent() {
|
||||
let f = Fixture::new().await;
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-text").await;
|
||||
assert_eq!(
|
||||
browser_call(&f, &token, text(&f, "ongoing")).await["value"]["text"],
|
||||
"Validation pending"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_call(&f, &token, text(&f, "current_position")).await["value"]["text"],
|
||||
""
|
||||
);
|
||||
let mut request = text(&f, "body");
|
||||
request["max_bytes"] = json!(8);
|
||||
let first = browser_call(&f, &token, request.clone()).await;
|
||||
assert_eq!(first["value"]["text"], &JOURNAL_BODY[..8]);
|
||||
request["byte_offset"] = first["value"]["next_byte"].clone();
|
||||
let (status, _) = response(
|
||||
build_router(f.api.clone()),
|
||||
browser_request(&subject(&f), Some(&token), request.clone()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
request["expected_token"] = first["value"]["content_token"].clone();
|
||||
let next = browser_call(&f, &token, request.clone()).await;
|
||||
assert_eq!(next["value"]["text"], &JOURNAL_BODY[8..16]);
|
||||
f.fix_initial(); // changes entry token, never tears the old continuation
|
||||
assert_eq!(
|
||||
response(
|
||||
build_router(f.api.clone()),
|
||||
browser_request(&subject(&f), Some(&token), request)
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
for body in [
|
||||
json!({"action":"sessions","after":null,"limit":0}),
|
||||
json!({"action":"entries","journal_id":null,"query":null,"after":null,"limit":33}),
|
||||
json!({"action":"entries","journal_id":null,"query":"x".repeat(257),"after":null,"limit":10}),
|
||||
{
|
||||
let mut body = text(&f, "body");
|
||||
body["max_bytes"] = json!(16385);
|
||||
body
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
response(
|
||||
build_router(f.api.clone()),
|
||||
browser_request(&subject(&f), Some(&token), body)
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_saved_bidirectional_links_titles_and_cross_session_corrections_survive_deletion()
|
||||
{
|
||||
let f = Fixture::new().await;
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-links").await;
|
||||
let sources =
|
||||
json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":1});
|
||||
let unconnected = browser_call(&f, &token, sources.clone()).await;
|
||||
assert_eq!(
|
||||
unconnected["value"]["sources"][0]["link"]["connection"]["state"],
|
||||
"unconnected"
|
||||
);
|
||||
assert_eq!(
|
||||
unconnected["value"]["sources"][0]["link"]["connection"]["reason"],
|
||||
"not_reconciled"
|
||||
);
|
||||
f.fix_initial();
|
||||
f.call(MemoryJournalRequest::Reconcile {
|
||||
selector: f.selector.clone(),
|
||||
})
|
||||
.await;
|
||||
let (later, correction) = later_journal(&f, vec![f.entry_id.clone()]);
|
||||
let linked = browser_call(&f, &token, sources.clone()).await;
|
||||
assert_eq!(
|
||||
linked["value"]["sources"][0]["entry"]["title"],
|
||||
"Choosing the approach"
|
||||
);
|
||||
assert!(linked["value"]["sources"][0]["entry"]["created_at"].is_string());
|
||||
assert_eq!(
|
||||
linked["value"]["sources"][0]["link"]["connection"]["entry_id"],
|
||||
f.entry_id
|
||||
);
|
||||
let corrections = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"corrections","journal_id":f.journal_id,
|
||||
"entry_id":f.entry_id,"after":null,"limit":1}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(corrections["value"]["items"][0]["id"], correction);
|
||||
assert_eq!(corrections["value"]["items"][0]["journal_id"], later);
|
||||
let corrected = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"entry","journal_id":later,"entry_id":correction}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
corrected["value"]["correction_targets"][0]["id"],
|
||||
f.entry_id
|
||||
);
|
||||
assert_eq!(
|
||||
corrected["value"]["correction_targets"][0]["journal_id"],
|
||||
f.journal_id
|
||||
);
|
||||
let related_request = json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":1});
|
||||
let related = browser_call(&f, &token, related_request.clone()).await;
|
||||
assert_eq!(
|
||||
related["value"]["items"][0]["claim"],
|
||||
"Check the constraint before choosing"
|
||||
);
|
||||
assert_eq!(
|
||||
related["value"]["items"][0]["related"]["selector"]["change_id"],
|
||||
f.selector.change_id
|
||||
);
|
||||
assert_eq!(
|
||||
related["value"]["items"][0]["related"]["reconsideration"],
|
||||
true
|
||||
);
|
||||
fs::remove_dir_all(&f.source_root).unwrap();
|
||||
f.api
|
||||
.store
|
||||
.delete_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap();
|
||||
let deleted = browser_call(&f, &token, detail(&f)).await;
|
||||
assert_eq!(deleted["value"]["original_source"]["state"], "missing");
|
||||
assert!(deleted["value"]["original_source"]["history_href"].is_null());
|
||||
assert_eq!(
|
||||
browser_call(&f, &token, text(&f, "body")).await["value"]["text"],
|
||||
JOURNAL_BODY
|
||||
);
|
||||
let linked = browser_call(&f, &token, sources).await;
|
||||
assert_eq!(
|
||||
linked["value"]["sources"][0]["original_source"]["state"],
|
||||
"missing"
|
||||
);
|
||||
assert_eq!(linked["value"]["sources"][0]["entry"]["id"], f.entry_id);
|
||||
assert_eq!(
|
||||
browser_call(&f, &token, related_request).await["value"]["items"][0]["claim"],
|
||||
"Check the constraint before choosing"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_history_navigation_requires_exact_session_mapping_not_registry_presence() {
|
||||
let f = Fixture::new().await;
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-original").await;
|
||||
let mut registry = f
|
||||
.api
|
||||
.store
|
||||
.get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let initial = browser_call(&f, &token, detail(&f)).await;
|
||||
assert!(initial["value"]["original_source"]["history_href"].is_null());
|
||||
registry.session_ref = Some(f.ranges[0].source.session_id.clone());
|
||||
f.api.store.upsert_worker_registry(®istry).unwrap();
|
||||
let exact = browser_call(&f, &token, detail(&f)).await;
|
||||
let resource_key = f
|
||||
.api
|
||||
.store
|
||||
.worker_resource_key(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
exact["value"]["original_source"]["history_href"],
|
||||
browser_worker_console_href(TEST_WORKSPACE_ID, &resource_key)
|
||||
);
|
||||
assert!(resource_key.starts_with("W-"));
|
||||
// Metadata does not attest original bytes. A later Session must not be linked as the old one.
|
||||
assert_eq!(exact["value"]["original_source"]["state"], "unavailable");
|
||||
registry.session_ref = Some("different-session".into());
|
||||
f.api.store.upsert_worker_registry(®istry).unwrap();
|
||||
assert!(
|
||||
browser_call(&f, &token, detail(&f)).await["value"]["original_source"]["history_href"]
|
||||
.is_null()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_many_saved_links_page_without_reconciliation_and_use_immutable_claims() {
|
||||
let f = Fixture::new().await;
|
||||
f.fix_initial();
|
||||
let correction = f.add_correction();
|
||||
f.call(MemoryJournalRequest::Reconcile {
|
||||
selector: f.selector.clone(),
|
||||
})
|
||||
.await;
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-many-links").await;
|
||||
let mut candidate = f
|
||||
.store
|
||||
.staging_candidate(&subject(&f), &f.selector.candidate_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
candidate.id = "second-browser-candidate".into();
|
||||
candidate.claim = "Another immutable lesson from both events".into();
|
||||
let mut source = candidate.source_refs[0].clone();
|
||||
source.evidence_id = Some(f.ranges[1].source.pointer.entry_ref.clone());
|
||||
source.entry_range = Some([1, 1]);
|
||||
let mut evidence = candidate.evidence[0].clone();
|
||||
evidence.id = source.evidence_id.clone().unwrap();
|
||||
evidence.entry_range = Some([1, 1]);
|
||||
candidate.source_refs.push(source);
|
||||
candidate.evidence.push(evidence);
|
||||
let (candidate, _) = f
|
||||
.store
|
||||
.stage_candidate_with_attribution(
|
||||
candidate,
|
||||
f.store
|
||||
.session_attribution(&f.ranges[0].source.session_id)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let (memory, _) = f
|
||||
.store
|
||||
.apply_candidate(
|
||||
&subject(&f),
|
||||
&candidate.id,
|
||||
crate::subjektiv::MemoryChangeTarget::Create,
|
||||
crate::subjektiv::MemoryDraft::active(
|
||||
candidate.kind.clone(),
|
||||
&candidate.claim,
|
||||
"Saved Memory body",
|
||||
&candidate.why_useful,
|
||||
"Second adoption",
|
||||
),
|
||||
"Reviewed",
|
||||
)
|
||||
.unwrap();
|
||||
let selector = MemoryJournalSelector {
|
||||
memory_id: memory.id.clone(),
|
||||
change_id: memory.change_id.clone(),
|
||||
candidate_id: candidate.id.clone(),
|
||||
};
|
||||
let first = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"memory_sources","selector":selector,"source_offset":0,"limit":1}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first["value"]["total_sources"], 2);
|
||||
assert_eq!(first["value"]["next_source_index"], 1);
|
||||
assert_eq!(
|
||||
first["value"]["sources"][0]["link"]["connection"]["reason"],
|
||||
"not_reconciled"
|
||||
);
|
||||
// Reading a browser page never reconciles. Only the existing domain fixture explicitly does so.
|
||||
f.call(MemoryJournalRequest::Reconcile {
|
||||
selector: selector.clone(),
|
||||
})
|
||||
.await;
|
||||
let first = browser_call(
|
||||
&f,
|
||||
&token,
|
||||
json!({"action":"memory_sources","selector":selector,"source_offset":0,"limit":1}),
|
||||
)
|
||||
.await;
|
||||
let next = browser_call(&f,&token,json!({"action":"memory_sources","selector":selector,"source_offset":first["value"]["next_source_index"],"limit":1})).await;
|
||||
assert_eq!(first["value"]["sources"][0]["entry"]["id"], f.entry_id);
|
||||
assert_eq!(next["value"]["sources"][0]["entry"]["id"], correction);
|
||||
assert!(next["value"]["next_source_index"].is_null());
|
||||
f.store
|
||||
.revise_memory(
|
||||
&subject(&f),
|
||||
&memory.id,
|
||||
memory.change_id,
|
||||
crate::subjektiv::MemoryDraft::active(
|
||||
candidate.kind,
|
||||
"Changed current claim",
|
||||
"New body",
|
||||
"Updated lesson",
|
||||
"Later qualification",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let first = browser_call(&f,&token,json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":1})).await;
|
||||
let next = browser_call(&f,&token,json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":first["value"]["next"],"limit":1})).await;
|
||||
assert!(first["value"]["next"].is_object());
|
||||
assert!(next["value"]["next"].is_null());
|
||||
let claims = [
|
||||
first["value"]["items"][0]["claim"].as_str().unwrap(),
|
||||
next["value"]["items"][0]["claim"].as_str().unwrap(),
|
||||
];
|
||||
assert!(claims.contains(&"Another immutable lesson from both events"));
|
||||
assert!(claims.contains(&"Check the constraint before choosing"));
|
||||
assert!(!claims.contains(&"Changed current claim"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_read_post_has_memory_get_user_authority_but_no_mutation_authority() {
|
||||
let f = Fixture::new().await;
|
||||
let token = seed_test_api_token(f.api.store.as_ref(), "browser-reader-not-owner");
|
||||
// Ordinary authenticated read authority is the same as Memory GET, even though browser
|
||||
// selectors use POST. This exception is exact-route only; no mutations gain authority.
|
||||
assert_eq!(
|
||||
browser_call(&f, &token, detail(&f)).await["value"]["entry"]["id"],
|
||||
f.entry_id
|
||||
);
|
||||
let request = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::from(r#"{"role":"not-allowed"}"#))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response(build_router(f.api.clone()), request).await.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_saved_missing_bytes_do_not_claim_original_conversation_deleted() {
|
||||
let f = Fixture::new().await;
|
||||
f.fix_initial();
|
||||
f.call(MemoryJournalRequest::Reconcile {
|
||||
selector: f.selector.clone(),
|
||||
})
|
||||
.await;
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-missing-byte").await;
|
||||
let mut registry = f
|
||||
.api
|
||||
.store
|
||||
.get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
registry.session_ref = Some(f.ranges[0].source.session_id.clone());
|
||||
f.api.store.upsert_worker_registry(®istry).unwrap();
|
||||
let sources =
|
||||
json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":1});
|
||||
let present = browser_call(&f, &token, sources.clone()).await;
|
||||
assert!(present["value"]["sources"][0]["original_source"]["history_href"].is_string());
|
||||
|
||||
// The Host attests only a missing byte of the referenced source. Original Session
|
||||
// metadata and material still exist; neither this attestation nor its saved link
|
||||
// projection certifies deletion of the conversation as a whole.
|
||||
let mut missing_byte = f.ranges[0].clone();
|
||||
missing_byte.bytes.end = missing_byte.bytes.start + 1;
|
||||
f.store
|
||||
.mark_journal_sources_unavailable(&f.host, &f.journal_id, &[missing_byte])
|
||||
.unwrap();
|
||||
assert!(f.source_root.exists());
|
||||
assert!(
|
||||
f.api
|
||||
.store
|
||||
.get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
let partial = browser_call(&f, &token, sources.clone()).await;
|
||||
let source = &partial["value"]["sources"][0];
|
||||
assert_eq!(source["link"]["original_source"], "missing");
|
||||
assert_eq!(source["original_source"]["state"], "unavailable");
|
||||
assert!(source["original_source"]["history_href"].is_null());
|
||||
assert_eq!(source["entry"]["id"], f.entry_id);
|
||||
assert_eq!(
|
||||
browser_call(&f, &token, text(&f, "body")).await["value"]["text"],
|
||||
JOURNAL_BODY
|
||||
);
|
||||
|
||||
// Only independently confirmed registry/archive absence changes the conversation
|
||||
// status to missing. The same saved Journal and immutable link remain readable.
|
||||
fs::remove_dir_all(&f.source_root).unwrap();
|
||||
f.api
|
||||
.store
|
||||
.delete_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap();
|
||||
let deleted = browser_call(&f, &token, sources).await;
|
||||
assert_eq!(
|
||||
deleted["value"]["sources"][0]["original_source"]["state"],
|
||||
"missing"
|
||||
);
|
||||
assert!(deleted["value"]["sources"][0]["original_source"]["history_href"].is_null());
|
||||
assert_eq!(deleted["value"]["sources"][0]["entry"]["id"], f.entry_id);
|
||||
}
|
||||
|
||||
/// Emits actual authenticated router/store responses for the production Web contract runner.
|
||||
/// The paired `test:journal:backend-contract` task validates all action payloads with the real
|
||||
/// Web parser; this Rust test remains usable without a JavaScript runtime or product process.
|
||||
#[tokio::test]
|
||||
async fn journal_browser_web_contract_serializes_saved_router_responses() {
|
||||
let f = Fixture::new().await;
|
||||
f.fix_initial();
|
||||
f.call(MemoryJournalRequest::Reconcile {
|
||||
selector: f.selector.clone(),
|
||||
})
|
||||
.await;
|
||||
let (later, correction) = later_journal(&f, vec![f.entry_id.clone()]);
|
||||
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "browser-web-contract").await;
|
||||
let mut registry = f
|
||||
.api
|
||||
.store
|
||||
.get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
registry.session_ref = Some(f.ranges[0].source.session_id.clone());
|
||||
f.api.store.upsert_worker_registry(®istry).unwrap();
|
||||
let sources =
|
||||
json!({"action":"memory_sources","selector":f.selector,"source_offset":0,"limit":3});
|
||||
let requests = [
|
||||
json!({"action":"sessions","after":null,"limit":20}),
|
||||
json!({"action":"entries","journal_id":null,"query":null,"after":null,"limit":20}),
|
||||
detail(&f),
|
||||
text(&f, "body"),
|
||||
json!({"action":"corrections","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":10}),
|
||||
sources.clone(),
|
||||
json!({"action":"related_memory","journal_id":f.journal_id,"entry_id":f.entry_id,"after":null,"limit":3}),
|
||||
json!({"action":"entry","journal_id":later,"entry_id":correction}),
|
||||
];
|
||||
for request in requests {
|
||||
let response = browser_call(&f, &token, request.clone()).await;
|
||||
println!(
|
||||
"JOURNAL_BROWSER_CONTRACT {}",
|
||||
json!({"workspace_id":TEST_WORKSPACE_ID,"subject_id":subject(&f),"request":request,"response":response})
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(&f.source_root).unwrap();
|
||||
f.api
|
||||
.store
|
||||
.delete_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
|
||||
.unwrap();
|
||||
for request in [detail(&f), sources] {
|
||||
let response = browser_call(&f, &token, request.clone()).await;
|
||||
println!(
|
||||
"JOURNAL_BROWSER_CONTRACT {}",
|
||||
json!({"workspace_id":TEST_WORKSPACE_ID,"subject_id":subject(&f),"request":request,"response":response})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_browser_cookie_read_post_retains_origin_check_for_non_owner_reader() {
|
||||
let f = Fixture::new().await;
|
||||
seed_test_api_token(f.api.store.as_ref(), "journal-cookie-reader");
|
||||
f.api
|
||||
.store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
token_hash: crate::auth::token_hash("journal-cookie-session"),
|
||||
session_id: "journal-cookie-session-id".into(),
|
||||
user_id: "user-journal-cookie-reader".into(),
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
expires_at: "2099-01-01T00:00:00Z".into(),
|
||||
revoked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
let AuthConfig::Passkey {
|
||||
origin,
|
||||
cookie_name,
|
||||
..
|
||||
} = &f.api.config.auth;
|
||||
for (presented, expected) in [
|
||||
(None, StatusCode::FORBIDDEN),
|
||||
(Some("https://foreign.example"), StatusCode::FORBIDDEN),
|
||||
(Some(origin.as_str()), StatusCode::OK),
|
||||
] {
|
||||
let mut request = Request::builder()
|
||||
.method("POST")
|
||||
.uri(browser_path(&subject(&f)))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
format!("{cookie_name}=journal-cookie-session"),
|
||||
);
|
||||
if let Some(origin) = presented {
|
||||
request = request.header(axum::http::header::ORIGIN, origin);
|
||||
}
|
||||
let (status, bytes) = response(
|
||||
build_router(f.api.clone()),
|
||||
request
|
||||
.body(Body::from(serde_json::to_vec(&detail(&f)).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, expected);
|
||||
if status == StatusCode::OK {
|
||||
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(value["value"]["entry"]["id"], f.entry_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -729,3 +729,6 @@ async fn memory_journal_router_correction_reverse_lookup_preserves_memory_and_or
|
||||
before
|
||||
);
|
||||
}
|
||||
|
||||
#[path = "subjektiv_journal_browser_tests.rs"]
|
||||
mod browser_tests;
|
||||
|
||||
Reference in New Issue
Block a user