-
Notifications
You must be signed in to change notification settings - Fork 0
feat(api): enumerate authorized exports via loopback collection GET #443
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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; | ||
|
|
@@ -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) | ||
|
|
@@ -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), | ||
| )?; | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| let collection = ExportCollection::new(page, next_cursor)?; | ||
|
Comment on lines
+362
to
+380
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| Ok(json_response(200, "OK", collection.to_json()?)) | ||
|
Comment on lines
+379
to
+381
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Prompt for agentsWas 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; | ||
|
|
@@ -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 { | ||
|
|
@@ -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 | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Invalid receipts enter valid collections
Prompt for agentsWas 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Invalid page limits panic callers Calling Prompt for agentsWas 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); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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_exportsignores arbitrary and misspelled pagination headers. ADR 0075 requires unknown collection keys to fail closed, so these requests need rejection.Was this helpful? React with 👍 or 👎 to provide feedback.