Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.d/export-collection-get.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `tepp_api` loopback `GET /v1/exports` enumerates authorized purpose-bound export identities on `AnalysisRunLiveService` / `tepp-loopback` (ADR 0075). Metric-free receipts only. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. LineageWeave refused. `NaruonLiveService` stays POST-only. Not export retrieval GET, not GAP-010 Figma/export, not persistence.
1 change: 1 addition & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin
| naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) |
| contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) |
| Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) |
| Export collection GET doctoring | [`docs/research/export-collection-http.md`](docs/research/export-collection-http.md) |
| UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) |
| Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) |
| Security policy | [`SECURITY.md`](SECURITY.md) |
Expand Down
72 changes: 55 additions & 17 deletions crates/tepp_api/src/analysis_run_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
//! This module keeps the Naruon compatibility listener intact while providing
//! the shared `/v1/analysis-runs` and cutoff-safe `/v1/temporal-context`
//! boundaries needed by Naruon and `LineageWeave`. Naruon may also POST and
//! GET `/v1/exports/{export_id}` for metric-free purpose-bound retrieval.
//! GET `/v1/exports/{export_id}` for metric-free purpose-bound retrieval and
//! `GET /v1/exports` to enumerate those identities.
//! It accepts transport acknowledgements, temporal evidence context, and
//! export identities only; completed psychometric results remain outside this
//! crate.
Expand All @@ -12,22 +13,26 @@ use std::collections::HashMap;
use std::io::Write;
use std::net::{SocketAddr, TcpListener};

use crate::export_collection_http::{
is_export_collection_path, page_export_collection_items, parse_export_collection_page_cursor,
parse_export_collection_page_limit, ExportCollection,
};
use crate::export_http::{export_retrieval_path_id, refuse_metrics_on_export_retrieval_payload};
use crate::lineageweave_http::{
LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, consumer_is_supported,
consumer_is_supported, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE,
};
use crate::live_http::{
header_value, map_io_error, parse_headers, parse_request_line, read_http_request_with_limit,
split_request_with_limit, validate_common_headers,
};
use crate::naruon_http::{NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH};
use crate::{
AnalysisRunAccepted, AnalysisRunRequest, AnalyticalPurpose, ApiError,
DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, ErrorEnvelope, ExportAuthorizationRequest, ExportRetrieval,
NARUON_LIVE_IO_TIMEOUT, NaruonLiveResponse, PROJECT_HISTORY_PATH, ProjectHistoryProjection,
ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, TemporalContextRequest, authorize_export,
build_temporal_context, project_history_projection, requests_are_idempotent_matches,
require_export_allowed,
authorize_export, build_temporal_context, project_history_projection,
requests_are_idempotent_matches, require_export_allowed, AnalysisRunAccepted,
AnalysisRunRequest, AnalyticalPurpose, ApiError, ErrorEnvelope, ExportAuthorizationRequest,
ExportRetrieval, NaruonLiveResponse, ProjectHistoryProjection, ProjectHistoryRequest,
TemporalContextRequest, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, NARUON_LIVE_IO_TIMEOUT,
PROJECT_HISTORY_PATH, TEMPORAL_CONTEXT_PATH,
};

const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT;
Expand Down Expand Up @@ -162,6 +167,9 @@ impl AnalysisRunLiveService {
let (method, path) = parse_request_line(lines.next().unwrap_or(""))?;
let headers = parse_headers(&mut lines)?;
if method == "GET" {
if is_export_collection_path(path) {
return self.list_exports(&headers, body);
}
if matches!(
export_retrieval_path_id(path),
Ok(_) | Err(ApiError::LimitExceeded)
Expand Down Expand Up @@ -342,6 +350,37 @@ impl AnalysisRunLiveService {
Ok(json_response(200, "OK", response_body))
}

fn list_exports(
&self,
headers: &HashMap<String, String>,
body: &str,
) -> Result<NaruonLiveResponse, ApiError> {
if !body.trim().is_empty() {
return Err(ApiError::InvalidWirePayload);
}
refuse_metrics_on_export_retrieval_payload(body)?;
let consumer = require_headers(headers, self.bound_addr, false)?;
if consumer != NARUON_CONSUMER_CODE {
return Err(ApiError::InvalidWirePayload);
}
if headers.contains_key("idempotency-key") {
return Err(ApiError::InvalidWirePayload);
}
let limit =
parse_export_collection_page_limit(headers.get("tepp-page-limit").map(String::as_str))?;
let cursor = parse_export_collection_page_cursor(
headers.get("tepp-page-cursor").map(String::as_str),
)?;
Comment on lines +369 to +373

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Unknown headers bypass collection contract

list_exports ignores arbitrary and misspelled pagination headers. ADR 0075 requires unknown collection keys to fail closed, so these requests need rejection.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

let items = self
.authorized_exports
.values()
.map(|stored| stored.retrieval.clone())
.collect();
let (page, next_cursor) = page_export_collection_items(items, cursor.as_deref(), limit);
Comment on lines +374 to +379

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Current service avoids page races

list_exports clones one current view while the single-request service holds exclusive access. Export insertion cannot interleave during page construction.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

let collection = ExportCollection::new(page, next_cursor)?;
Comment on lines +362 to +380

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Export capabilities exposed without authorization

Any local process claiming naruon can enumerate all export capabilities. This bypasses per-export capability possession and exposes every artifact receipt across tenants.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Ok(json_response(200, "OK", collection.to_json()?))
Comment on lines +379 to +381

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Valid maximum pages return errors

With 64 valid receipts containing heavily escaped identifiers, page_export_collection_items builds a response beyond 64 KiB. The accepted maximum limit then returns 413.

Prompt for agents
Build collection pages against both the requested item count and DEFAULT_ANALYSIS_RUN_BYTE_LIMIT. If the requested rows exceed the serialized response bound, return the largest nonempty prefix that fits and set next_cursor to its last export_id. Ensure a single valid receipt always fits, and add an endpoint test with 64 receipts whose artifact IDs and idempotency keys require maximal JSON escaping.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

fn response_from_error(&mut self, error: ApiError) -> NaruonLiveResponse {
let request_id = format!("analysis-run-live-{}", self.next_request_serial);
self.next_request_serial += 1;
Expand Down Expand Up @@ -417,17 +456,16 @@ mod tests {
use std::time::Duration;

use super::{
AnalysisRunLiveService, consumer_tenant_idempotency_key, declared_content_length,
error_envelope_json, host_implies_table_access, map_io_error, parse_headers,
require_headers, split_header_line, status_for,
consumer_tenant_idempotency_key, declared_content_length, error_envelope_json,
host_implies_table_access, map_io_error, parse_headers, require_headers, split_header_line,
status_for, AnalysisRunLiveService,
};
use crate::live_http::{host_is_loopback, read_http_request, split_request};
use crate::{
ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError,
DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, LINEAGEWEAVE_CONSUMER_CODE,
NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_EXPORT_PATH,
NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT,
TEMPORAL_CONTEXT_PATH,
AnalysisRunRequest, ApiError, ErrorEnvelope, ANALYSIS_RUN_CONTRACT_VERSION,
DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH,
NARUON_CONSUMER_CODE, NARUON_EXPORT_PATH, NARUON_LIVE_HEADER_BYTE_LIMIT,
NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, TEMPORAL_CONTEXT_PATH,
};

fn sample_run() -> AnalysisRunRequest {
Expand Down Expand Up @@ -1135,7 +1173,7 @@ mod tests {
"GET {NARUON_EXPORT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n"
))
.status_code,
400
200
);
assert_eq!(
service
Expand Down
204 changes: 204 additions & 0 deletions crates/tepp_api/src/export_collection_http.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
//! Provider-owned export collection GET contracts.
//!
//! GAP-003A unique slice: `GET /v1/exports` enumerates metric-free identities
//! of purpose-bound exports that `AnalysisRunLiveService` / `tepp-loopback`
//! already authorized. Operators do not guess `export_id` values. This module
//! does not duplicate export retrieval GET (#411), export-retrieval CLI
//! (#417), export-authorize CLI (#410), interpretation-run collection GET
//! (#433), project-history collection GET (#424), GET-by-id (#359), Leiden,
//! or GAP-010 Figma/export. Persistence remains GAP-003B. `LineageWeave` is
//! refused. `NaruonLiveService` stays POST-only.

use serde::{Deserialize, Serialize};

use crate::export_http::{
refuse_metrics_on_export_retrieval_payload, ExportRetrieval, EXPORT_RETRIEVAL_ID_MAX_LEN,
};
use crate::naruon_http::{compose_https_target, NaruonHttpExchange, NARUON_EXPORT_PATH};
use crate::wire::{require_byte_limit, require_nonempty, to_json};
use crate::{ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT};

/// Default page size for export collection GET.
pub const EXPORT_COLLECTION_DEFAULT_LIMIT: usize = 32;
/// Maximum page size for export collection GET.
pub const EXPORT_COLLECTION_MAX_LIMIT: usize = 64;
/// Maximum opaque cursor length on export collection GET.
pub const EXPORT_COLLECTION_CURSOR_MAX_LEN: usize = EXPORT_RETRIEVAL_ID_MAX_LEN;

/// Metric-free export collection page.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ExportCollection {
/// Metric-free authorized export identities on this page.
pub items: Vec<ExportRetrieval>,
/// Exclusive `export_id` cursor for the next page, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}

impl ExportCollection {
/// Construct a validated collection page.
///
/// # Errors
///
/// Returns a fail-closed error for oversized pages or hostile cursors.
pub fn new(items: Vec<ExportRetrieval>, next_cursor: Option<String>) -> Result<Self, ApiError> {
if items.len() > EXPORT_COLLECTION_MAX_LIMIT {
return Err(ApiError::LimitExceeded);
}
if let Some(cursor) = next_cursor.as_deref() {
require_nonempty(cursor)?;
if cursor.len() > EXPORT_COLLECTION_CURSOR_MAX_LEN {
return Err(ApiError::LimitExceeded);
}
if cursor.contains('/') || cursor.contains('\0') {
return Err(ApiError::InvalidWirePayload);
}
}
let collection = Self { items, next_cursor };
let payload = to_json(&collection)?;
require_byte_limit(&payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT)?;
refuse_metrics_on_export_retrieval_payload(&payload)?;
Ok(collection)
Comment on lines +45 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Invalid receipts enter valid collections

ExportCollection::new accepts malformed public receipt values without item validation. Callers can publish denied decisions, empty identities, or unsupported versions as valid pages.

Prompt for agents
Validate every ExportRetrieval in ExportCollection::new and to_json before accepting or serializing the page. ExportRetrieval currently keeps validate private, so expose an appropriate crate-level validation method or reconstruct each item through its validated API. Also provide a validated collection deserialization entry point if collections are consumed from JSON. Test malformed decision codes, contract versions, empty fields, unknown purposes, and oversized identities.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

/// Serialize this collection after metric refusal.
///
/// # Errors
///
/// Returns a validation or metric-key error.
pub fn to_json(&self) -> Result<String, ApiError> {
let payload = to_json(self)?;
require_byte_limit(&payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT)?;
refuse_metrics_on_export_retrieval_payload(&payload)?;
Ok(payload)
}
}

/// Whether a path is the export collection resource.
#[must_use]
pub fn is_export_collection_path(path: &str) -> bool {
path == NARUON_EXPORT_PATH
}

/// Parse the optional `tepp-page-limit` header.
///
/// # Errors
///
/// Returns [`ApiError::InvalidWirePayload`] for a non-integer and
/// [`ApiError::LimitExceeded`] when above [`EXPORT_COLLECTION_MAX_LIMIT`].
pub fn parse_export_collection_page_limit(raw: Option<&str>) -> Result<usize, ApiError> {
let Some(raw) = raw else {
return Ok(EXPORT_COLLECTION_DEFAULT_LIMIT);
};
require_nonempty(raw)?;
let limit: usize = raw.parse().map_err(|_| ApiError::InvalidWirePayload)?;
if limit == 0 {
return Err(ApiError::InvalidWirePayload);
}
if limit > EXPORT_COLLECTION_MAX_LIMIT {
return Err(ApiError::LimitExceeded);
}
Ok(limit)
}

/// Parse the optional exclusive `tepp-page-cursor` header.
///
/// # Errors
///
/// Returns a fail-closed error for empty, slash, NUL, or oversized cursors.
pub fn parse_export_collection_page_cursor(raw: Option<&str>) -> Result<Option<String>, ApiError> {
let Some(raw) = raw else {
return Ok(None);
};
require_nonempty(raw)?;
if raw.contains('/') || raw.contains('\0') {
return Err(ApiError::InvalidWirePayload);
}
if raw.len() > EXPORT_COLLECTION_CURSOR_MAX_LEN {
return Err(ApiError::LimitExceeded);
}
Ok(Some(raw.to_owned()))
}

/// Page stored collection rows with an exclusive `export_id` cursor.
#[must_use]
pub fn page_export_collection_items(
mut items: Vec<ExportRetrieval>,
cursor: Option<&str>,
limit: usize,
) -> (Vec<ExportRetrieval>, Option<String>) {
items.sort_by(|left, right| left.export_id.cmp(&right.export_id));
let start = cursor.map_or(0, |cursor| {
items
.iter()
.position(|item| item.export_id.as_str() > cursor)
.unwrap_or(items.len())
});
Comment on lines +131 to +137

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Hash order does not affect pagination

page_export_collection_items sorts every receipt by its canonical export identity before applying the cursor. Unordered map iteration therefore cannot reorder pages.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

let end = (start + limit).min(items.len());
let next_cursor = (end < items.len()).then(|| items[end - 1].export_id.clone());
(items[start..end].to_vec(), next_cursor)
Comment on lines +138 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Invalid page limits panic callers

Calling page_export_collection_items with zero and remaining items indexes below zero. Large limits can also overflow, crashing direct library consumers.

Prompt for agents
Make page_export_collection_items safe for every public input. The exported helper currently accepts an arbitrary usize, although only the HTTP parser enforces 1..=64. Zero can underflow end - 1 and large values can overflow start + limit. Either return Result and validate the public limit or implement checked/saturating bounds while defining zero-limit cursor behavior. Add direct helper tests for zero, usize::MAX, empty input, and cursors at or beyond the end.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

/// Build a credential-free naruon collection GET exchange.
///
/// # Errors
///
/// Returns a fail-closed origin error.
pub fn naruon_export_collection_exchange(origin: &str) -> Result<NaruonHttpExchange, ApiError> {
let target_url = compose_https_target(origin, NARUON_EXPORT_PATH)?;
Ok(NaruonHttpExchange {
method: "GET",
target_url,
headers: vec![
("content-type".into(), "application/json".into()),
("tepp-consumer".into(), "naruon".into()),
("tepp-contract-version".into(), "1".into()),
],
body: String::new(),
})
}

#[cfg(test)]
mod tests {
use super::{
is_export_collection_path, naruon_export_collection_exchange,
parse_export_collection_page_cursor, parse_export_collection_page_limit,
EXPORT_COLLECTION_MAX_LIMIT,
};
use crate::naruon_http::NARUON_EXPORT_PATH;
use crate::ApiError;

#[test]
fn collection_exchange_is_metric_free_get_without_credentials() {
assert!(is_export_collection_path(NARUON_EXPORT_PATH));
assert!(!is_export_collection_path("/v1/exports/export-1"));
let exchange =
naruon_export_collection_exchange("https://tepp.example.test").expect("exchange");
assert_eq!(exchange.method, "GET");
assert!(exchange.target_url.ends_with("/v1/exports"));
assert!(exchange.body.is_empty());
assert!(!exchange
.headers
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case("authorization")
|| name.eq_ignore_ascii_case("idempotency-key")));
assert_eq!(
naruon_export_collection_exchange("http://tepp.example.test"),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(
parse_export_collection_page_limit(None).expect("default"),
32
);
assert_eq!(
parse_export_collection_page_limit(Some("99")),
Err(ApiError::LimitExceeded)
);
assert_eq!(
parse_export_collection_page_cursor(Some("a/b")),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(EXPORT_COLLECTION_MAX_LIMIT, 64);
}
}
19 changes: 19 additions & 0 deletions crates/tepp_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod corpus_split_manifest;
mod envelope;
mod error;
mod export;
mod export_collection_http;
mod export_http;
mod lineage_criterion_anchor;
mod lineage_pair_criterion;
Expand Down Expand Up @@ -104,6 +105,24 @@ pub use export_http::EXPORT_RETRIEVAL_ID_MAX_LEN;
pub use export_http::ExportRetrieval;
/// Build a naruon export-retrieval GET exchange.
pub use export_http::naruon_export_retrieval_exchange;
/// Build a credential-free naruon export collection GET exchange.
pub use export_collection_http::naruon_export_collection_exchange;
/// Whether a path is the export collection resource.
pub use export_collection_http::is_export_collection_path;
/// Page stored export collection rows with an exclusive export-id cursor.
pub use export_collection_http::page_export_collection_items;
/// Parse the optional exclusive `tepp-page-cursor` header.
pub use export_collection_http::parse_export_collection_page_cursor;
/// Parse the optional `tepp-page-limit` header.
pub use export_collection_http::parse_export_collection_page_limit;
/// Metric-free export collection page.
pub use export_collection_http::ExportCollection;
/// Maximum opaque cursor length on export collection GET.
pub use export_collection_http::EXPORT_COLLECTION_CURSOR_MAX_LEN;
/// Default page size for export collection GET.
pub use export_collection_http::EXPORT_COLLECTION_DEFAULT_LIMIT;
/// Maximum page size for export collection GET.
pub use export_collection_http::EXPORT_COLLECTION_MAX_LIMIT;
/// Refuse scientific-metric keys on export-retrieval JSON.
pub use export_http::refuse_metrics_on_export_retrieval_payload;

Expand Down
Loading
Loading