myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 87d92b3424b65da9729ded2a0f9a1b5a73b22adf
parent 9ede1be7a1ef6e59c0e3ab49aad7a568c41fae91
Author: triesap <tyson@radroots.org>
Date:   Sat, 22 Aug 2026 04:26:47 +0000

admin: bind governed Myc routes

Diffstat:
MAGENTS.md | 11+++++++++--
MREADME | 30++++++++++++++++++++++++++++++
Mcontracts/api_baselines/myc.txt | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/services_hardening/operator_contract.v1.json | 2+-
Asrc/admin_v1.rs | 1807+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 7+++++++
Mtests/package_boundary.rs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/services_hardening_contracts.rs | 2+-
8 files changed, 2005 insertions(+), 5 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -31,8 +31,8 @@ `radroots.service.source-lock.v1.toml`, the relevant implementation and tests, and `flake.nix` or migrations when they are in scope. - `.radroots-consumer-root` is the standalone source-lock identity and must - remain exactly `myc`. The reserved pre-implementation control-plane - contract is `contracts/services_hardening/operator_contract.v1.json`. + remain exactly `myc`. The implemented control-plane contract is + `contracts/services_hardening/operator_contract.v1.json`. Service implementation must use its exact routes, operation IDs, model fields, doctor checks, and shared host/exit references; prototype CLI or HTTP behavior is not authority to reinterpret that contract. @@ -100,6 +100,13 @@ code may commit a completion only through the atomic exact-response method; a retained completion without its immutable response and initial delivery state is inconsistent evidence and must never be repaired implicitly. +- Step 150 implements the exact 21-route and 35-model Myc Unix-admin adapter. + Keep the shared Lib router private; reject model drift, unknown/duplicate/null + fields, noncanonical response bytes, invalid path/query values, and unsafe + errors at the adapter boundary. Domain handlers must bind authenticated + cursors to route/filter/snapshot identity and must retain exact operation-ID + replay/conflict evidence before returning success. Do not infer relay + delivery from a successful local mutation and do not add TCP admin routes. - Treat checked-in source, tests, and prototype behavior as implementation evidence, not permission to preserve behavior that the active requirement removes. diff --git a/README b/README @@ -114,6 +114,36 @@ authority. Explicit desired/current offline NIP-05 export re-reads verified state, works through read-only inspection, and returns canonical compact `names` plus NIP-46 discovery JSON without hosting it. +The implemented v1 Myc administration adapter registers the exact 21-route +inventory and validates every request and response against all 35 model +references in `contracts/services_hardening/operator_contract.v1.json`. It +reuses the hardened Lib HTTP/1.1-over-Unix server for original-wire duplicate +and null rejection, percent-decoded bounded path parameters, peer admission, +deadlines, concurrency, body limits, and effective correlation IDs. The Myc +boundary additionally rejects unknown or missing model fields, invalid query +types, noncanonical response JSON, unsafe URLs and identifiers, and response +model drift. Raw shared-host routers and JSON values never cross the public +API. + +The domain handler receives compact canonical model bytes plus the durable +operation ID. A successful mutation means its contract-defined local effect +and operation audit are committed; it does not claim relay submission or +delivery. Identical operation-ID reuse must return the original committed +result, different-byte reuse returns `operation_id_conflict`, and pagination +cursors remain authenticated to the same route, filters, and snapshot. This +checkpoint supplies the typed protocol adapter, not the later daemon runtime, +cached status/metrics producer, CLI dispatch, or TCP operations surface. + +The transport capability and admitted request remain non-forgeable outside +the crate: + +```compile_fail +use myc::{MycAdminRequestDocument, MycAdminRouter}; + +let _request = MycAdminRequestDocument {}; +let _router = MycAdminRouter {}; +``` + The public Myc repository exposes no raw pool, connection, transaction-control handle, path, or SQL. Binding and request-admission mutations execute only inside the shared `ServiceSqliteTransaction` runner. Provider and relay work diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt @@ -9,6 +9,53 @@ pub use myc::RadrootsServiceInstanceArtifacts pub use myc::RuntimeContext pub use myc::RuntimeContextSource pub use myc::ServiceId +pub enum myc::MycAdminDocumentErrorKind +pub myc::MycAdminDocumentErrorKind::DuplicateField +pub myc::MycAdminDocumentErrorKind::InvalidModel +pub myc::MycAdminDocumentErrorKind::Malformed +pub myc::MycAdminDocumentErrorKind::NonCanonical +pub myc::MycAdminDocumentErrorKind::NullForbidden +pub myc::MycAdminDocumentErrorKind::TooLarge +pub enum myc::MycAdminHandlerErrorKind +pub myc::MycAdminHandlerErrorKind::Conflict +pub myc::MycAdminHandlerErrorKind::Internal +pub myc::MycAdminHandlerErrorKind::InvalidCursor +pub myc::MycAdminHandlerErrorKind::NotFound +pub myc::MycAdminHandlerErrorKind::OperationIdConflict +pub myc::MycAdminHandlerErrorKind::Unavailable +pub enum myc::MycAdminMethod +pub myc::MycAdminMethod::Get +pub myc::MycAdminMethod::Post +pub enum myc::MycAdminRoute +pub myc::MycAdminRoute::AuditEvents +pub myc::MycAdminRoute::AuditSummary +pub myc::MycAdminRoute::ChallengeAuthorize +pub myc::MycAdminRoute::ChallengeRequire +pub myc::MycAdminRoute::ConnectionApprove +pub myc::MycAdminRoute::ConnectionReject +pub myc::MycAdminRoute::ConnectionRevoke +pub myc::MycAdminRoute::ConnectionsList +pub myc::MycAdminRoute::DiscoveryDesired +pub myc::MycAdminRoute::DiscoveryPublish +pub myc::MycAdminRoute::DiscoveryRefresh +pub myc::MycAdminRoute::DiscoveryRender +pub myc::MycAdminRoute::EffectiveConfig +pub myc::MycAdminRoute::IdentityPublic +pub myc::MycAdminRoute::IdentityRekey +pub myc::MycAdminRoute::IdentityReplace +pub myc::MycAdminRoute::IdentityStatus +pub myc::MycAdminRoute::MetricsSnapshot +pub myc::MycAdminRoute::StateBackup +pub myc::MycAdminRoute::StateStatus +pub myc::MycAdminRoute::Status +impl myc::MycAdminRoute +pub const myc::MycAdminRoute::ALL: [Self; 21] +pub const fn myc::MycAdminRoute::is_mutation(self) -> bool +pub const fn myc::MycAdminRoute::method(self) -> myc::MycAdminMethod +pub const fn myc::MycAdminRoute::operation_id(self) -> &'static str +pub const fn myc::MycAdminRoute::path(self) -> &'static str +pub const fn myc::MycAdminRoute::request_model(self) -> &'static str +pub const fn myc::MycAdminRoute::response_model(self) -> &'static str pub enum myc::MycAuditKind pub myc::MycAuditKind::ChallengeAuthorization pub myc::MycAuditKind::ChallengeCreation @@ -551,6 +598,42 @@ pub myc::MycStateRepositoryErrorKind::CommitOutcomeUnknown pub myc::MycStateRepositoryErrorKind::Transaction impl myc::MycStateRepositoryErrorKind pub const fn myc::MycStateRepositoryErrorKind::code(self) -> &'static str +pub struct myc::MycAdminDocumentError +impl myc::MycAdminDocumentError +pub const fn myc::MycAdminDocumentError::kind(self) -> myc::MycAdminDocumentErrorKind +impl core::error::Error for myc::MycAdminDocumentError +impl core::fmt::Display for myc::MycAdminDocumentError +pub fn myc::MycAdminDocumentError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycAdminHandlerError +impl myc::MycAdminHandlerError +pub const fn myc::MycAdminHandlerError::kind(self) -> myc::MycAdminHandlerErrorKind +pub const fn myc::MycAdminHandlerError::new(myc::MycAdminHandlerErrorKind) -> Self +impl core::error::Error for myc::MycAdminHandlerError +impl core::fmt::Display for myc::MycAdminHandlerError +pub fn myc::MycAdminHandlerError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycAdminRequestDocument +impl myc::MycAdminRequestDocument +pub fn myc::MycAdminRequestDocument::correlation_id(&self) -> &str +pub fn myc::MycAdminRequestDocument::model_bytes(&self) -> &[u8] +pub fn myc::MycAdminRequestDocument::operation_id(&self) -> core::option::Option<&str> +pub fn myc::MycAdminRequestDocument::parameter(&self, &str) -> core::option::Option<&str> +pub const fn myc::MycAdminRequestDocument::route(&self) -> myc::MycAdminRoute +impl core::fmt::Debug for myc::MycAdminRequestDocument +pub fn myc::MycAdminRequestDocument::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycAdminResponseDocument +impl myc::MycAdminResponseDocument +pub fn myc::MycAdminResponseDocument::canonical_bytes(&self) -> &[u8] +pub fn myc::MycAdminResponseDocument::from_canonical_bytes(myc::MycAdminRoute, &[u8]) -> core::result::Result<Self, myc::MycAdminDocumentError> +pub const fn myc::MycAdminResponseDocument::route(&self) -> myc::MycAdminRoute +impl core::fmt::Debug for myc::MycAdminResponseDocument +pub fn myc::MycAdminResponseDocument::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycAdminRouter +impl core::fmt::Debug for myc::MycAdminRouter +pub fn myc::MycAdminRouter::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycAdminRouterError +impl core::error::Error for myc::MycAdminRouterError +impl core::fmt::Display for myc::MycAdminRouterError +pub fn myc::MycAdminRouterError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycAuditCorrelationId(_) impl myc::MycAuditCorrelationId pub const fn myc::MycAuditCorrelationId::new([u8; 32]) -> Self @@ -1601,9 +1684,12 @@ pub const myc::MYC_STATE_SCHEMA_VERSION_9_OBJECT_COUNT: u32 pub const myc::MYC_STATE_SCHEMA_VERSION_9_SHA256: [u8; 32] pub const myc::MYC_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize pub const myc::MYC_WRAPPING_CREDENTIAL_CONTRACT_VERSION: u32 +pub trait myc::MycAdminHandler: core::marker::Send + core::marker::Sync + 'static +pub fn myc::MycAdminHandler::handle<'a>(&'a self, myc::MycAdminRequestDocument) -> myc::MycAdminFuture<'a> pub fn myc::admit_myc_nip46_event(myc::MycNip46AdmissionLimits, &[u8]) -> core::result::Result<myc::MycBoundedNip46Event, myc::MycNip46AdmissionError> pub fn myc::admit_myc_nip46_request(myc::MycNip46AdmissionLimits, &[u8]) -> core::result::Result<myc::MycBoundedNip46Request, myc::MycNip46AdmissionError> pub fn myc::bind_myc_nip46_replay(myc::MycVerifiedNip46Event, myc::MycVerifiedNip46Request) -> core::result::Result<myc::MycReplayBoundNip46Request, myc::MycSignerRequestError> +pub fn myc::build_myc_admin_router<H>(alloc::sync::Arc<H>) -> core::result::Result<myc::MycAdminRouter, myc::MycAdminRouterError> where H: myc::MycAdminHandler pub async fn myc::finalize_myc_state_restore(myc::MycStagedStateRestore) -> core::result::Result<(), myc::MycStateMaintenanceError> pub async fn myc::initialize_myc_state(&myc::MycRuntimeContext, &myc::MycStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<(), myc::MycStateHostError> pub fn myc::myc_migration_catalog() -> core::result::Result<radroots_service_sqlite::migration::MigrationCatalog, myc::MycStateCatalogError> @@ -1625,3 +1711,4 @@ pub fn myc::validate_myc_state_catalogs(&radroots_service_sqlite::migration::Mig pub fn myc::verify_myc_nip46_event(myc::MycBoundedNip46Event, &myc::MycProviderBinding, myc::MycNip46ObservedAtUnixSeconds, myc::MycNip46AuthoredTimePolicy) -> core::result::Result<myc::MycVerifiedNip46Event, myc::MycNip46VerificationError> pub fn myc::verify_myc_nip46_request(myc::MycBoundedNip46Request) -> core::result::Result<myc::MycVerifiedNip46Request, myc::MycNip46VerificationError> pub fn myc::verify_myc_state_backup(&[u8], radroots_service_sqlite::backup::manifest::BackupManifestSha256, &std::path::Path, &myc::MycStateMetadata, core::num::nonzero::NonZeroU64) -> core::result::Result<myc::MycVerifiedStateBackup, myc::MycStateMaintenanceError> +pub type myc::MycAdminFuture<'a> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = core::result::Result<myc::MycAdminResponseDocument, myc::MycAdminHandlerError>> + core::marker::Send + 'a)>> diff --git a/contracts/services_hardening/operator_contract.v1.json b/contracts/services_hardening/operator_contract.v1.json @@ -1,7 +1,7 @@ { "schema": "radroots.myc.operator-contract.v1", "contract_version": 1, - "decision_state": "reserved_preimplementation", + "decision_state": "implemented", "service": "myc", "shared_host_contract": { "repository": "https://github.com/radrootslabs/lib", diff --git a/src/admin_v1.rs b/src/admin_v1.rs @@ -0,0 +1,1807 @@ +//! Exact Myc v1 Unix-admin route and model boundary. + +use core::{fmt, future::Future, pin::Pin}; +use std::{ + collections::BTreeSet, + error::Error, + path::Path, + sync::{Arc, OnceLock}, +}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use radroots_service_host::{ + AdminCorrelationId, AdminError, AdminErrorCode, AdminErrorMessage, AdminHttpMethod, + AdminMutationRequest, AdminOperationId, AdminRequest, AdminRouteFailure, + AdminRouteFailureStatus, AdminRouteOutcome, AdminRouter, +}; +use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Value}; + +// Original model admission is never permitted to exceed the complete response +// body cap enforced again by the shared host after envelope encoding. +const MYC_ADMIN_RESPONSE_BODY_MAX_UTF8_BYTES: usize = 1_048_576; + +const OPERATOR_CONTRACT: &str = + include_str!("../contracts/services_hardening/operator_contract.v1.json"); + +/// Closed Myc v1 admin method vocabulary. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MycAdminMethod { + Get, + Post, +} + +/// Closed Myc v1 Unix-admin route inventory. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MycAdminRoute { + Status, + EffectiveConfig, + IdentityStatus, + IdentityRekey, + IdentityReplace, + IdentityPublic, + StateStatus, + StateBackup, + MetricsSnapshot, + ConnectionsList, + ConnectionApprove, + ConnectionReject, + ConnectionRevoke, + ChallengeRequire, + ChallengeAuthorize, + AuditEvents, + AuditSummary, + DiscoveryDesired, + DiscoveryRender, + DiscoveryRefresh, + DiscoveryPublish, +} + +impl MycAdminRoute { + pub const ALL: [Self; 21] = [ + Self::Status, + Self::EffectiveConfig, + Self::IdentityStatus, + Self::IdentityRekey, + Self::IdentityReplace, + Self::IdentityPublic, + Self::StateStatus, + Self::StateBackup, + Self::MetricsSnapshot, + Self::ConnectionsList, + Self::ConnectionApprove, + Self::ConnectionReject, + Self::ConnectionRevoke, + Self::ChallengeRequire, + Self::ChallengeAuthorize, + Self::AuditEvents, + Self::AuditSummary, + Self::DiscoveryDesired, + Self::DiscoveryRender, + Self::DiscoveryRefresh, + Self::DiscoveryPublish, + ]; + + #[must_use] + pub const fn method(self) -> MycAdminMethod { + match self { + Self::Status + | Self::EffectiveConfig + | Self::IdentityStatus + | Self::IdentityPublic + | Self::StateStatus + | Self::MetricsSnapshot + | Self::ConnectionsList + | Self::AuditEvents + | Self::AuditSummary + | Self::DiscoveryDesired => MycAdminMethod::Get, + Self::IdentityRekey + | Self::IdentityReplace + | Self::StateBackup + | Self::ConnectionApprove + | Self::ConnectionReject + | Self::ConnectionRevoke + | Self::ChallengeRequire + | Self::ChallengeAuthorize + | Self::DiscoveryRender + | Self::DiscoveryRefresh + | Self::DiscoveryPublish => MycAdminMethod::Post, + } + } + + #[must_use] + pub const fn path(self) -> &'static str { + match self { + Self::Status => "/v1/status", + Self::EffectiveConfig => "/v1/config/effective", + Self::IdentityStatus => "/v1/identity/status", + Self::IdentityRekey => "/v1/identity/rekey", + Self::IdentityReplace => "/v1/identity/replace", + Self::IdentityPublic => "/v1/identity/public", + Self::StateStatus => "/v1/state/status", + Self::StateBackup => "/v1/state/backup", + Self::MetricsSnapshot => "/v1/metrics/snapshot", + Self::ConnectionsList => "/v1/connections", + Self::ConnectionApprove => "/v1/connections/{connection_id}/approve", + Self::ConnectionReject => "/v1/connections/{connection_id}/reject", + Self::ConnectionRevoke => "/v1/connections/{connection_id}/revoke", + Self::ChallengeRequire => "/v1/authorization/challenges/require", + Self::ChallengeAuthorize => "/v1/authorization/challenges/{challenge_id}/authorize", + Self::AuditEvents => "/v1/audit/events", + Self::AuditSummary => "/v1/audit/summary", + Self::DiscoveryDesired => "/v1/discovery/desired", + Self::DiscoveryRender => "/v1/discovery/render", + Self::DiscoveryRefresh => "/v1/discovery/refresh", + Self::DiscoveryPublish => "/v1/discovery/publish", + } + } + + #[must_use] + pub const fn operation_id(self) -> &'static str { + match self { + Self::Status => "radroots.myc.status.get.v1", + Self::EffectiveConfig => "radroots.myc.config.effective.get.v1", + Self::IdentityStatus => "radroots.myc.identity.status.get.v1", + Self::IdentityRekey => "radroots.myc.identity.rekey.v1", + Self::IdentityReplace => "radroots.myc.identity.replace.v1", + Self::IdentityPublic => "radroots.myc.identity.public.get.v1", + Self::StateStatus => "radroots.myc.state.status.get.v1", + Self::StateBackup => "radroots.myc.state.backup.create.v1", + Self::MetricsSnapshot => "radroots.myc.metrics.snapshot.get.v1", + Self::ConnectionsList => "radroots.myc.connections.list.v1", + Self::ConnectionApprove => "radroots.myc.connection.approve.v1", + Self::ConnectionReject => "radroots.myc.connection.reject.v1", + Self::ConnectionRevoke => "radroots.myc.connection.revoke.v1", + Self::ChallengeRequire => "radroots.myc.authorization.challenge.require.v1", + Self::ChallengeAuthorize => "radroots.myc.authorization.challenge.authorize.v1", + Self::AuditEvents => "radroots.myc.audit.events.list.v1", + Self::AuditSummary => "radroots.myc.audit.summary.get.v1", + Self::DiscoveryDesired => "radroots.myc.discovery.desired.get.v1", + Self::DiscoveryRender => "radroots.myc.discovery.render.v1", + Self::DiscoveryRefresh => "radroots.myc.discovery.refresh.v1", + Self::DiscoveryPublish => "radroots.myc.discovery.publish.v1", + } + } + + #[must_use] + pub const fn request_model(self) -> &'static str { + match self { + Self::Status + | Self::EffectiveConfig + | Self::StateStatus + | Self::MetricsSnapshot + | Self::DiscoveryDesired => "empty", + Self::IdentityStatus => "identity_status_query_v1", + Self::IdentityRekey => "identity_rekey_request_v1", + Self::IdentityReplace => "identity_replace_request_v1", + Self::IdentityPublic => "identity_public_query_v1", + Self::StateBackup => "state_backup_request_v1", + Self::ConnectionsList => "connections_query_v1", + Self::ConnectionApprove => "connection_approve_request_v1", + Self::ConnectionReject => "connection_reject_request_v1", + Self::ConnectionRevoke => "connection_revoke_request_v1", + Self::ChallengeRequire => "challenge_require_request_v1", + Self::ChallengeAuthorize => "challenge_authorize_request_v1", + Self::AuditEvents => "audit_events_query_v1", + Self::AuditSummary => "audit_summary_query_v1", + Self::DiscoveryRender => "discovery_render_request_v1", + Self::DiscoveryRefresh => "discovery_refresh_request_v1", + Self::DiscoveryPublish => "discovery_publish_request_v1", + } + } + + #[must_use] + pub const fn response_model(self) -> &'static str { + match self { + Self::Status => "service_status_v1", + Self::EffectiveConfig => "effective_config_v1", + Self::IdentityStatus => "identity_status_v1", + Self::IdentityRekey | Self::IdentityReplace => "identity_mutation_receipt_v1", + Self::IdentityPublic => "identity_public_v1", + Self::StateStatus => "state_status_v1", + Self::StateBackup => "state_backup_receipt_v1", + Self::MetricsSnapshot => "metrics_snapshot_v1", + Self::ConnectionsList => "connections_page_v1", + Self::ConnectionApprove | Self::ConnectionReject | Self::ConnectionRevoke => { + "connection_mutation_receipt_v1" + } + Self::ChallengeRequire => "challenge_v1", + Self::ChallengeAuthorize => "challenge_authorization_receipt_v1", + Self::AuditEvents => "audit_events_page_v1", + Self::AuditSummary => "audit_summary_v1", + Self::DiscoveryDesired => "discovery_desired_v1", + Self::DiscoveryRender => "discovery_render_receipt_v1", + Self::DiscoveryRefresh => "discovery_refresh_receipt_v1", + Self::DiscoveryPublish => "discovery_publish_receipt_v1", + } + } + + #[must_use] + pub const fn is_mutation(self) -> bool { + matches!(self.method(), MycAdminMethod::Post) + } + + const fn host_method(self) -> AdminHttpMethod { + match self.method() { + MycAdminMethod::Get => AdminHttpMethod::Get, + MycAdminMethod::Post => AdminHttpMethod::Post, + } + } + + const fn parameter_name(self) -> Option<&'static str> { + match self { + Self::ConnectionApprove | Self::ConnectionReject | Self::ConnectionRevoke => { + Some("connection_id") + } + Self::ChallengeAuthorize => Some("challenge_id"), + _ => None, + } + } +} + +/// One route-bound, already validated Myc admin request. +pub struct MycAdminRequestDocument { + route: MycAdminRoute, + operation_id: Option<AdminOperationId>, + correlation_id: AdminCorrelationId, + parameter: Option<(&'static str, Box<str>)>, + model_bytes: Box<[u8]>, +} + +impl MycAdminRequestDocument { + #[must_use] + pub const fn route(&self) -> MycAdminRoute { + self.route + } + + /// Returns the caller's durable idempotency identity for a mutation. + #[must_use] + pub fn operation_id(&self) -> Option<&str> { + self.operation_id.as_ref().map(AdminOperationId::as_str) + } + + #[must_use] + pub fn correlation_id(&self) -> &str { + self.correlation_id.as_str() + } + + /// Returns a validated percent-decoded path parameter when this route has one. + #[must_use] + pub fn parameter(&self, name: &str) -> Option<&str> { + self.parameter + .as_ref() + .filter(|(parameter_name, _)| *parameter_name == name) + .map(|(_, value)| value.as_ref()) + } + + /// Returns compact canonical JSON for the route's exact request model. + #[must_use] + pub fn model_bytes(&self) -> &[u8] { + &self.model_bytes + } +} + +impl fmt::Debug for MycAdminRequestDocument { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycAdminRequestDocument") + .field("route", &self.route) + .field("mutation", &self.operation_id.is_some()) + .field("has_parameter", &self.parameter.is_some()) + .field("model", &"[redacted]") + .finish() + } +} + +/// One exact validated response model for a fixed route. +pub struct MycAdminResponseDocument { + route: MycAdminRoute, + canonical_bytes: Box<[u8]>, + value: Value, +} + +impl MycAdminResponseDocument { + /// Admits only compact canonical JSON matching the route's response model. + pub fn from_canonical_bytes( + route: MycAdminRoute, + bytes: &[u8], + ) -> Result<Self, MycAdminDocumentError> { + if bytes.is_empty() { + return Err(MycAdminDocumentError::new( + MycAdminDocumentErrorKind::Malformed, + )); + } + if bytes.len() > MYC_ADMIN_RESPONSE_BODY_MAX_UTF8_BYTES { + return Err(MycAdminDocumentError::new( + MycAdminDocumentErrorKind::TooLarge, + )); + } + let value = strict_json(bytes)?; + validate_model(route.response_model(), &value)?; + let canonical = serde_json::to_vec(&value) + .map_err(|_| MycAdminDocumentError::new(MycAdminDocumentErrorKind::Malformed))?; + if canonical.as_slice() != bytes { + return Err(MycAdminDocumentError::new( + MycAdminDocumentErrorKind::NonCanonical, + )); + } + Ok(Self { + route, + canonical_bytes: canonical.into_boxed_slice(), + value, + }) + } + + #[must_use] + pub const fn route(&self) -> MycAdminRoute { + self.route + } + + #[must_use] + pub fn canonical_bytes(&self) -> &[u8] { + &self.canonical_bytes + } +} + +impl fmt::Debug for MycAdminResponseDocument { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycAdminResponseDocument") + .field("route", &self.route) + .field("model", &"[redacted]") + .finish() + } +} + +/// Stable classification for a rejected Myc admin document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycAdminDocumentErrorKind { + TooLarge, + Malformed, + DuplicateField, + NullForbidden, + NonCanonical, + InvalidModel, +} + +/// Source-free and content-free Myc admin document error. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MycAdminDocumentError { + kind: MycAdminDocumentErrorKind, +} + +impl MycAdminDocumentError { + const fn new(kind: MycAdminDocumentErrorKind) -> Self { + Self { kind } + } + + #[must_use] + pub const fn kind(self) -> MycAdminDocumentErrorKind { + self.kind + } +} + +impl fmt::Display for MycAdminDocumentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("Myc admin document is invalid") + } +} + +impl Error for MycAdminDocumentError {} + +/// Stable route-handler failure mapped to a bounded safe admin response. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycAdminHandlerErrorKind { + InvalidCursor, + OperationIdConflict, + NotFound, + Conflict, + Unavailable, + Internal, +} + +/// Source-free route-handler error. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MycAdminHandlerError { + kind: MycAdminHandlerErrorKind, +} + +impl MycAdminHandlerError { + #[must_use] + pub const fn new(kind: MycAdminHandlerErrorKind) -> Self { + Self { kind } + } + + #[must_use] + pub const fn kind(self) -> MycAdminHandlerErrorKind { + self.kind + } +} + +impl fmt::Display for MycAdminHandlerError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("Myc admin operation failed") + } +} + +impl Error for MycAdminHandlerError {} + +/// Boxed route future used by the sealed Myc admin adapter. +pub type MycAdminFuture<'a> = Pin< + Box<dyn Future<Output = Result<MycAdminResponseDocument, MycAdminHandlerError>> + Send + 'a>, +>; + +/// Domain port behind the exact Myc admin transport. +/// +/// Implementations own authoritative local commit, durable operation-ID +/// replay/conflict handling, and any provider or outbox orchestration. Returning +/// success means that the operation's contract-defined local effect is already +/// committed; relay submission or delivery is not implied. Pagination cursors +/// must be authenticated and bound to the same route, filters, and snapshot; +/// mismatched or invalid cursors return [`MycAdminHandlerErrorKind::InvalidCursor`]. +/// Reusing an operation ID with identical canonical request bytes returns the +/// original committed response; reuse with different bytes returns +/// [`MycAdminHandlerErrorKind::OperationIdConflict`]. +pub trait MycAdminHandler: Send + Sync + 'static { + fn handle<'a>(&'a self, request: MycAdminRequestDocument) -> MycAdminFuture<'a>; +} + +/// Source-free router-construction failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MycAdminRouterError; + +impl fmt::Display for MycAdminRouterError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("Myc admin router could not be constructed") + } +} + +impl Error for MycAdminRouterError {} + +/// Opaque, fully registered Myc v1 router capability. +/// +/// The underlying shared-host router remains an implementation detail. The +/// later runtime-composition checkpoint consumes this capability without +/// exposing raw listener or transport authority. +pub struct MycAdminRouter { + inner: AdminRouter, +} + +impl fmt::Debug for MycAdminRouter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { inner } = self; + let _ = inner; + formatter.write_str("MycAdminRouter") + } +} + +/// Registers the complete closed Myc v1 route inventory on the hardened Lib router. +pub fn build_myc_admin_router<H>(handler: Arc<H>) -> Result<MycAdminRouter, MycAdminRouterError> +where + H: MycAdminHandler, +{ + if !operator_route_inventory_is_exact() { + return Err(MycAdminRouterError); + } + let mut router = AdminRouter::new(); + for route in MycAdminRoute::ALL { + let handler = Arc::clone(&handler); + router + .route(route.host_method(), route.path(), move |request| { + let handler = Arc::clone(&handler); + async move { dispatch_route(route, handler, request).await } + }) + .map_err(|_| MycAdminRouterError)?; + } + Ok(MycAdminRouter { inner: router }) +} + +async fn dispatch_route<H>( + route: MycAdminRoute, + handler: Arc<H>, + request: AdminRequest, +) -> AdminRouteOutcome +where + H: MycAdminHandler, +{ + let document = match request_document(route, &request) { + Ok(document) => document, + Err(_) => return failure(MycAdminHandlerErrorKind::Conflict, true), + }; + match handler.handle(document).await { + Ok(response) if response.route == route => match request.success(&response.value) { + Ok(outcome) => outcome, + Err(_) => failure(MycAdminHandlerErrorKind::Internal, false), + }, + Ok(_) => failure(MycAdminHandlerErrorKind::Internal, false), + Err(error) => failure(error.kind, false), + } +} + +fn failure(kind: MycAdminHandlerErrorKind, invalid_request: bool) -> AdminRouteOutcome { + let (status, code, message) = if invalid_request { + ( + AdminRouteFailureStatus::BadRequest, + "invalid_request", + "admin request does not match the route model", + ) + } else { + match kind { + MycAdminHandlerErrorKind::InvalidCursor => ( + AdminRouteFailureStatus::BadRequest, + "invalid_cursor", + "admin pagination cursor is invalid", + ), + MycAdminHandlerErrorKind::OperationIdConflict => ( + AdminRouteFailureStatus::Conflict, + "operation_id_conflict", + "admin operation identity conflicts with retained state", + ), + MycAdminHandlerErrorKind::NotFound => ( + AdminRouteFailureStatus::NotFound, + "not_found", + "admin resource was not found", + ), + MycAdminHandlerErrorKind::Conflict => ( + AdminRouteFailureStatus::Conflict, + "operation_conflict", + "admin operation conflicts with current state", + ), + MycAdminHandlerErrorKind::Unavailable => ( + AdminRouteFailureStatus::Unavailable, + "service_unavailable", + "admin operation is temporarily unavailable", + ), + MycAdminHandlerErrorKind::Internal => ( + AdminRouteFailureStatus::Internal, + "internal_error", + "admin operation failed internally", + ), + } + }; + let code = AdminErrorCode::new(code).expect("fixed admin error code"); + let message = AdminErrorMessage::new(message).expect("fixed admin error message"); + AdminRouteOutcome::failure(AdminRouteFailure::new( + status, + AdminError::new(code, message), + )) +} + +fn request_document( + route: MycAdminRoute, + request: &AdminRequest, +) -> Result<MycAdminRequestDocument, MycAdminDocumentError> { + let (operation_id, value) = match route.method() { + MycAdminMethod::Get => (None, query_model(route, request.query())?), + MycAdminMethod::Post => { + let envelope = request + .decode_json::<AdminMutationRequest<Value>>() + .map_err(|_| MycAdminDocumentError::new(MycAdminDocumentErrorKind::Malformed))?; + ( + Some(envelope.operation_id().clone()), + envelope.into_request(), + ) + } + }; + validate_model(route.request_model(), &value)?; + let model_bytes = serde_json::to_vec(&value) + .map_err(|_| MycAdminDocumentError::new(MycAdminDocumentErrorKind::Malformed))?; + let parameter = route + .parameter_name() + .map(|name| { + let value = request + .parameter(name) + .ok_or_else(|| MycAdminDocumentError::new(MycAdminDocumentErrorKind::InvalidModel))? + .to_owned() + .into_boxed_str(); + validate_type("bounded_id", &Value::String(value.to_string()), 0)?; + Ok((name, value)) + }) + .transpose()?; + Ok(MycAdminRequestDocument { + route, + operation_id, + correlation_id: request.correlation_id().clone(), + parameter, + model_bytes: model_bytes.into_boxed_slice(), + }) +} + +fn query_model(route: MycAdminRoute, query: Option<&str>) -> Result<Value, MycAdminDocumentError> { + let Some(query) = query else { + return Ok(Value::Object(Map::new())); + }; + if query.is_empty() { + return Err(MycAdminDocumentError::new( + MycAdminDocumentErrorKind::InvalidModel, + )); + } + let fields = model_fields(route.request_model())?; + let mut output = Map::new(); + for item in query.split('&') { + let (raw_key, raw_value) = item + .split_once('=') + .ok_or_else(|| MycAdminDocumentError::new(MycAdminDocumentErrorKind::InvalidModel))?; + if raw_key.is_empty() + || !valid_percent_encoding(raw_key) + || !valid_percent_encoding(raw_value) + { + return Err(MycAdminDocumentError::new( + MycAdminDocumentErrorKind::InvalidModel, + )); + } + let key = percent_decode(raw_key)?; + let value = percent_decode(raw_value)?; + if output.contains_key(&key) { + return Err(MycAdminDocumentError::new( + MycAdminDocumentErrorKind::DuplicateField, + )); + } + let descriptor = fields + .get(&key) + .ok_or_else(|| MycAdminDocumentError::new(MycAdminDocumentErrorKind::InvalidModel))?; + let type_name = descriptor + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| MycAdminDocumentError::new(MycAdminDocumentErrorKind::InvalidModel))?; + output.insert(key, query_scalar(type_name, value)?); + } + Ok(Value::Object(output)) +} + +fn query_scalar(type_name: &str, value: String) -> Result<Value, MycAdminDocumentError> { + let descriptor = type_descriptor(type_name)?; + match descriptor.get("kind").and_then(Value::as_str) { + Some("integer") => { + let canonical = value == "0" + || (value.as_bytes().first().is_some_and(u8::is_ascii_digit) + && !value.starts_with('0') + && value.bytes().all(|byte| byte.is_ascii_digit())); + if !canonical { + return Err(MycAdminDocumentError::new( + MycAdminDocumentErrorKind::InvalidModel, + )); + } + value + .parse::<u64>() + .map(Value::from) + .map_err(|_| MycAdminDocumentError::new(MycAdminDocumentErrorKind::InvalidModel)) + } + Some("boolean") => match value.as_str() { + "true" => Ok(Value::Bool(true)), + "false" => Ok(Value::Bool(false)), + _ => Err(MycAdminDocumentError::new( + MycAdminDocumentErrorKind::InvalidModel, + )), + }, + _ => Ok(Value::String(value)), + } +} + +fn percent_decode(value: &str) -> Result<String, MycAdminDocumentError> { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + let high = hex_nibble(bytes[index + 1]).ok_or_else(invalid_model_error)?; + let low = hex_nibble(bytes[index + 2]).ok_or_else(invalid_model_error)?; + decoded.push((high << 4) | low); + index += 3; + } + b'+' => { + decoded.push(b' '); + index += 1; + } + byte => { + decoded.push(byte); + index += 1; + } + } + } + String::from_utf8(decoded).map_err(|_| invalid_model_error()) +} + +const fn hex_nibble(byte: u8) -> Option<u8> { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn valid_percent_encoding(value: &str) -> bool { + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + if index + 2 >= bytes.len() + || !bytes[index + 1].is_ascii_hexdigit() + || !bytes[index + 2].is_ascii_hexdigit() + { + return false; + } + index += 3; + } else { + index += 1; + } + } + true +} + +fn operator_contract() -> &'static Value { + static CONTRACT: OnceLock<Value> = OnceLock::new(); + CONTRACT.get_or_init(|| { + serde_json::from_str(OPERATOR_CONTRACT).expect("checked-in Myc operator contract") + }) +} + +fn operator_route_inventory_is_exact() -> bool { + let Some(admin) = operator_contract().get("admin") else { + return false; + }; + let Some(routes) = admin.get("routes").and_then(Value::as_array) else { + return false; + }; + let Some(models) = admin.get("models").and_then(Value::as_object) else { + return false; + }; + routes.len() == MycAdminRoute::ALL.len() + && models.len() == 35 + && admin + .pointer("/model_wire_contract/response_body_max_utf8_bytes") + .and_then(Value::as_u64) + == Some(MYC_ADMIN_RESPONSE_BODY_MAX_UTF8_BYTES as u64) + && routes.iter().zip(MycAdminRoute::ALL).all(|(wire, route)| { + wire.get("method").and_then(Value::as_str) + == Some(match route.method() { + MycAdminMethod::Get => "GET", + MycAdminMethod::Post => "POST", + }) + && wire.get("path").and_then(Value::as_str) == Some(route.path()) + && wire.get("operation_id").and_then(Value::as_str) == Some(route.operation_id()) + && wire.get("request_model").and_then(Value::as_str) == Some(route.request_model()) + && wire.get("response_model").and_then(Value::as_str) + == Some(route.response_model()) + && wire.get("mutation").and_then(Value::as_bool) == Some(route.is_mutation()) + }) +} + +fn model_fields(model_name: &str) -> Result<&'static Map<String, Value>, MycAdminDocumentError> { + operator_contract() + .pointer(&format!("/admin/models/{model_name}/fields")) + .and_then(Value::as_object) + .ok_or_else(|| MycAdminDocumentError::new(MycAdminDocumentErrorKind::InvalidModel)) +} + +fn type_descriptor(type_name: &str) -> Result<&'static Value, MycAdminDocumentError> { + operator_contract() + .pointer(&format!("/admin/types/{type_name}")) + .ok_or_else(|| MycAdminDocumentError::new(MycAdminDocumentErrorKind::InvalidModel)) +} + +fn validate_model(model_name: &str, value: &Value) -> Result<(), MycAdminDocumentError> { + let object = value + .as_object() + .ok_or_else(|| MycAdminDocumentError::new(MycAdminDocumentErrorKind::InvalidModel))?; + let fields = model_fields(model_name)?; + for key in object.keys() { + if !fields.contains_key(key) { + return Err(MycAdminDocumentError::new( + MycAdminDocumentErrorKind::InvalidModel, + )); + } + } + for (name, field) in fields { + let required = field.get("presence").and_then(Value::as_str) == Some("required"); + let type_name = field + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| MycAdminDocumentError::new(MycAdminDocumentErrorKind::InvalidModel))?; + match object.get(name) { + Some(value) => validate_type(type_name, value, 0)?, + None if required => { + return Err(MycAdminDocumentError::new( + MycAdminDocumentErrorKind::InvalidModel, + )); + } + None => {} + } + } + Ok(()) +} + +fn validate_type( + type_name: &str, + value: &Value, + depth: usize, +) -> Result<(), MycAdminDocumentError> { + if depth > 24 || value.is_null() { + return Err(MycAdminDocumentError::new(if value.is_null() { + MycAdminDocumentErrorKind::NullForbidden + } else { + MycAdminDocumentErrorKind::InvalidModel + })); + } + let descriptor = type_descriptor(type_name)?; + match descriptor.get("kind").and_then(Value::as_str) { + Some("literal") => { + if descriptor.get("value") != Some(value) { + return invalid_model(); + } + } + Some("boolean") => { + if !value.is_boolean() { + return invalid_model(); + } + } + Some("integer") => validate_integer(descriptor, value)?, + Some("string") => validate_string(descriptor, value)?, + Some("enum") => { + if !descriptor + .get("values") + .and_then(Value::as_array) + .is_some_and(|values| values.contains(value)) + { + return invalid_model(); + } + } + Some("string_union") => validate_string_union(descriptor, value)?, + Some("array") => validate_array(descriptor, value, depth + 1)?, + Some("canonical_delimited_set") => { + validate_delimited_set(descriptor, value, depth + 1)?; + } + Some("map") => validate_map(descriptor, value, depth + 1)?, + Some("closed_object") => validate_closed_object(descriptor, value, depth + 1)?, + Some("tagged_union") => validate_tagged_union(descriptor, value, depth + 1)?, + Some("alias") => validate_type( + descriptor + .get("target") + .and_then(Value::as_str) + .ok_or_else(invalid_model_error)?, + value, + depth + 1, + )?, + Some("optional") => validate_type( + descriptor + .get("value") + .and_then(Value::as_str) + .ok_or_else(invalid_model_error)?, + value, + depth + 1, + )?, + Some("canonical_json_object") => { + if !value.is_object() + || serde_json::to_vec(value).ok().is_none_or(|bytes| { + bytes.len() + > descriptor + .get("maximum_utf8_bytes") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0) + }) + { + return invalid_model(); + } + } + _ => return invalid_model(), + } + Ok(()) +} + +fn validate_integer(descriptor: &Value, value: &Value) -> Result<(), MycAdminDocumentError> { + let number = value.as_u64().ok_or_else(invalid_model_error)?; + let minimum = descriptor + .get("minimum") + .and_then(Value::as_u64) + .unwrap_or(0); + let maximum = descriptor + .get("maximum") + .and_then(Value::as_u64) + .unwrap_or(u64::MAX); + if !(minimum..=maximum).contains(&number) { + return invalid_model(); + } + Ok(()) +} + +fn validate_string(descriptor: &Value, value: &Value) -> Result<(), MycAdminDocumentError> { + let string = value.as_str().ok_or_else(invalid_model_error)?; + let exact = descriptor + .get("utf8_bytes") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()); + let minimum = descriptor + .get("minimum_utf8_bytes") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + let maximum = descriptor + .get("maximum_utf8_bytes") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(usize::MAX); + if exact.is_some_and(|exact| string.len() != exact) + || !(minimum..=maximum).contains(&string.len()) + || string.chars().any(char::is_control) + { + return invalid_model(); + } + if let Some(pattern) = descriptor.get("pattern").and_then(Value::as_str) { + let valid = match pattern { + "^[A-Za-z0-9][A-Za-z0-9._:-]*$" => bounded_id(string), + "^[a-z][a-z0-9_]*$" => safe_code(string), + "^[0-9a-f]{64}$" => lower_hex(string, 64), + "^[0-9a-f]{40}$" => lower_hex(string, 40), + "^/" => Path::new(string).is_absolute(), + _ => false, + }; + if !valid { + return invalid_model(); + } + } + if descriptor.get("encoding").and_then(Value::as_str) == Some("canonical_base64url_no_padding") + { + let decoded = URL_SAFE_NO_PAD + .decode(string) + .map_err(|_| invalid_model_error())?; + if URL_SAFE_NO_PAD.encode(decoded) != string { + return invalid_model(); + } + } + if let Some(schemes) = descriptor.get("allowed_schemes").and_then(Value::as_array) { + let parsed = url::Url::parse(string).map_err(|_| invalid_model_error())?; + if !schemes + .iter() + .any(|scheme| scheme.as_str() == Some(parsed.scheme())) + { + return invalid_model(); + } + } + Ok(()) +} + +fn validate_string_union(descriptor: &Value, value: &Value) -> Result<(), MycAdminDocumentError> { + let string = value.as_str().ok_or_else(invalid_model_error)?; + if descriptor + .get("simple_values") + .and_then(Value::as_array) + .is_some_and(|values| values.iter().any(|value| value.as_str() == Some(string))) + { + return Ok(()); + } + let kind = string + .strip_prefix("sign_event:kind:") + .ok_or_else(invalid_model_error)?; + if kind.is_empty() + || (kind.len() > 1 && kind.starts_with('0')) + || !kind.bytes().all(|byte| byte.is_ascii_digit()) + || kind.parse::<u32>().is_err() + || string.len() + > descriptor + .get("maximum_utf8_bytes") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0) + { + return invalid_model(); + } + Ok(()) +} + +fn validate_array( + descriptor: &Value, + value: &Value, + depth: usize, +) -> Result<(), MycAdminDocumentError> { + let values = value.as_array().ok_or_else(invalid_model_error)?; + let maximum = descriptor + .get("maximum_items") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + if values.len() > maximum { + return invalid_model(); + } + let item_type = descriptor + .get("items") + .and_then(Value::as_str) + .ok_or_else(invalid_model_error)?; + for item in values { + validate_type(item_type, item, depth)?; + } + if descriptor.get("unique").and_then(Value::as_bool) == Some(true) { + let mut unique = BTreeSet::new(); + for item in values { + let encoded = serde_json::to_vec(item).map_err(|_| invalid_model_error())?; + if !unique.insert(encoded) { + return invalid_model(); + } + } + } + if descriptor.get("canonical_sort").is_some() { + let rendered = values + .iter() + .map(|value| value.as_str().ok_or_else(invalid_model_error)) + .collect::<Result<Vec<_>, _>>()?; + if !rendered.windows(2).all(|pair| pair[0] < pair[1]) { + return invalid_model(); + } + } + Ok(()) +} + +fn validate_delimited_set( + descriptor: &Value, + value: &Value, + depth: usize, +) -> Result<(), MycAdminDocumentError> { + let rendered = value.as_str().ok_or_else(invalid_model_error)?; + let maximum_bytes = descriptor + .get("maximum_utf8_bytes") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + if rendered.len() > maximum_bytes { + return invalid_model(); + } + if rendered.is_empty() { + return if descriptor.get("empty_allowed").and_then(Value::as_bool) == Some(true) { + Ok(()) + } else { + invalid_model() + }; + } + let delimiter = descriptor + .get("delimiter") + .and_then(Value::as_str) + .filter(|delimiter| delimiter.len() == 1) + .ok_or_else(invalid_model_error)?; + let items = rendered.split(delimiter).collect::<Vec<_>>(); + let maximum_items = descriptor + .get("maximum_items") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + if items.is_empty() + || items.len() > maximum_items + || items.iter().any(|item| item.is_empty()) + || !items.windows(2).all(|pair| pair[0] < pair[1]) + { + return invalid_model(); + } + let item_type = descriptor + .get("items") + .and_then(Value::as_str) + .ok_or_else(invalid_model_error)?; + for item in items { + validate_type(item_type, &Value::String(item.to_owned()), depth)?; + } + Ok(()) +} + +fn validate_map( + descriptor: &Value, + value: &Value, + depth: usize, +) -> Result<(), MycAdminDocumentError> { + let values = value.as_object().ok_or_else(invalid_model_error)?; + let maximum_entries = descriptor + .get("maximum_entries") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + if values.len() > maximum_entries { + return invalid_model(); + } + let key_type = descriptor + .get("key") + .and_then(Value::as_str) + .ok_or_else(invalid_model_error)?; + let value_type = descriptor + .get("value") + .and_then(Value::as_str) + .ok_or_else(invalid_model_error)?; + for (key, value) in values { + validate_type(key_type, &Value::String(key.clone()), depth)?; + validate_type(value_type, value, depth)?; + } + Ok(()) +} + +fn validate_closed_object( + descriptor: &Value, + value: &Value, + depth: usize, +) -> Result<(), MycAdminDocumentError> { + let values = value.as_object().ok_or_else(invalid_model_error)?; + let fields = descriptor + .get("fields") + .and_then(Value::as_object) + .ok_or_else(invalid_model_error)?; + if values.keys().any(|key| !fields.contains_key(key)) { + return invalid_model(); + } + for (field, type_name) in fields { + let type_name = type_name.as_str().ok_or_else(invalid_model_error)?; + match values.get(field) { + Some(value) => validate_type(type_name, value, depth)?, + None if type_descriptor(type_name)? + .get("kind") + .and_then(Value::as_str) + == Some("optional") => {} + None => return invalid_model(), + } + } + Ok(()) +} + +fn validate_tagged_union( + descriptor: &Value, + value: &Value, + depth: usize, +) -> Result<(), MycAdminDocumentError> { + let object = value.as_object().ok_or_else(invalid_model_error)?; + let discriminator = descriptor + .get("discriminator") + .and_then(Value::as_str) + .ok_or_else(invalid_model_error)?; + let selected = object.get(discriminator).ok_or_else(invalid_model_error)?; + let variants = descriptor + .get("variants") + .and_then(Value::as_array) + .ok_or_else(invalid_model_error)?; + let mut matched = None; + for variant in variants { + let variant = variant.as_str().ok_or_else(invalid_model_error)?; + let fields = type_descriptor(variant)? + .get("fields") + .and_then(Value::as_object) + .ok_or_else(invalid_model_error)?; + let discriminator_type = fields + .get(discriminator) + .and_then(Value::as_str) + .ok_or_else(invalid_model_error)?; + if type_descriptor(discriminator_type)?.get("value") == Some(selected) + && matched.replace(variant).is_some() + { + return invalid_model(); + } + } + validate_type(matched.ok_or_else(invalid_model_error)?, value, depth) +} + +fn bounded_id(value: &str) -> bool { + (1..=128).contains(&value.len()) + && value + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) +} + +fn safe_code(value: &str) -> bool { + (1..=64).contains(&value.len()) + && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +fn lower_hex(value: &str, exact: usize) -> bool { + value.len() == exact + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn invalid_model<T>() -> Result<T, MycAdminDocumentError> { + Err(invalid_model_error()) +} + +const fn invalid_model_error() -> MycAdminDocumentError { + MycAdminDocumentError::new(MycAdminDocumentErrorKind::InvalidModel) +} + +const DUPLICATE_MARKER: &str = "myc_admin_duplicate_field"; +const NULL_MARKER: &str = "myc_admin_null_forbidden"; + +fn strict_json(bytes: &[u8]) -> Result<Value, MycAdminDocumentError> { + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let value = StrictValueSeed + .deserialize(&mut deserializer) + .map_err(|error| { + let message = error.to_string(); + if message.contains(DUPLICATE_MARKER) { + MycAdminDocumentError::new(MycAdminDocumentErrorKind::DuplicateField) + } else if message.contains(NULL_MARKER) { + MycAdminDocumentError::new(MycAdminDocumentErrorKind::NullForbidden) + } else { + MycAdminDocumentError::new(MycAdminDocumentErrorKind::Malformed) + } + })?; + deserializer + .end() + .map_err(|_| MycAdminDocumentError::new(MycAdminDocumentErrorKind::Malformed))?; + Ok(value) +} + +struct StrictValueSeed; + +impl<'de> DeserializeSeed<'de> for StrictValueSeed { + type Value = Value; + + fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(StrictValueVisitor) + } +} + +struct StrictValueVisitor; + +impl<'de> Visitor<'de> for StrictValueVisitor { + type Value = Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a non-null JSON value") + } + + fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> { + Ok(Value::Bool(value)) + } + + fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> { + Ok(Value::from(value)) + } + + fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> { + Ok(Value::from(value)) + } + + fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E> + where + E: de::Error, + { + serde_json::Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("invalid JSON number")) + } + + fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> { + Ok(Value::String(value.to_owned())) + } + + fn visit_string<E>(self, value: String) -> Result<Self::Value, E> { + Ok(Value::String(value)) + } + + fn visit_none<E>(self) -> Result<Self::Value, E> + where + E: de::Error, + { + Err(E::custom(NULL_MARKER)) + } + + fn visit_unit<E>(self) -> Result<Self::Value, E> + where + E: de::Error, + { + Err(E::custom(NULL_MARKER)) + } + + fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element_seed(StrictValueSeed)? { + values.push(value); + } + Ok(Value::Array(values)) + } + + fn visit_map<A>(self, mut object: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'de>, + { + let mut values = Map::new(); + while let Some(key) = object.next_key::<String>()? { + if values.contains_key(&key) { + return Err(de::Error::custom(DUPLICATE_MARKER)); + } + let value = object.next_value_seed(StrictValueSeed)?; + values.insert(key, value); + } + Ok(Value::Object(values)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_model(model_name: &str) -> Value { + let fields = model_fields(model_name).expect("governed model"); + let mut object = Map::new(); + for (name, field) in fields { + if field.get("presence").and_then(Value::as_str) == Some("required") { + let type_name = field + .get("type") + .and_then(Value::as_str) + .expect("field type"); + object.insert(name.clone(), sample_type(type_name).expect("required type")); + } + } + Value::Object(object) + } + + fn sample_type(type_name: &str) -> Option<Value> { + let descriptor = type_descriptor(type_name).expect("governed type"); + match descriptor + .get("kind") + .and_then(Value::as_str) + .expect("type kind") + { + "literal" => Some(descriptor.get("value").expect("literal value").clone()), + "boolean" => Some(Value::Bool(false)), + "integer" => Some(Value::from( + descriptor + .get("minimum") + .and_then(Value::as_u64) + .unwrap_or(0), + )), + "string" => { + let value = if descriptor.get("encoding").is_some() { + "AA".to_owned() + } else if descriptor.get("allowed_schemes").is_some() { + "https://example.test/".to_owned() + } else if descriptor.get("pattern").and_then(Value::as_str) == Some("^/") { + "/tmp/myc".to_owned() + } else if let Some(length) = descriptor.get("utf8_bytes").and_then(Value::as_u64) { + "0".repeat(usize::try_from(length).expect("bounded sample length")) + } else { + "x".to_owned() + }; + Some(Value::String(value)) + } + "enum" => descriptor + .get("values") + .and_then(Value::as_array) + .and_then(|values| values.first()) + .cloned(), + "string_union" => descriptor + .get("simple_values") + .and_then(Value::as_array) + .and_then(|values| values.first()) + .cloned(), + "array" => Some(Value::Array(Vec::new())), + "canonical_delimited_set" => Some(Value::String(String::new())), + "map" | "canonical_json_object" => Some(Value::Object(Map::new())), + "closed_object" => { + let mut object = Map::new(); + for (field, field_type) in descriptor + .get("fields") + .and_then(Value::as_object) + .expect("closed fields") + { + if let Some(value) = sample_type(field_type.as_str().expect("closed type")) { + object.insert(field.clone(), value); + } + } + Some(Value::Object(object)) + } + "tagged_union" => descriptor + .get("variants") + .and_then(Value::as_array) + .and_then(|variants| variants.first()) + .and_then(Value::as_str) + .and_then(sample_type), + "alias" => descriptor + .get("target") + .and_then(Value::as_str) + .and_then(sample_type), + "optional" => None, + kind => panic!("unsupported governed kind {kind}"), + } + } + + fn response_document(route: MycAdminRoute) -> MycAdminResponseDocument { + let value = sample_model(route.response_model()); + validate_model(route.response_model(), &value).expect("generated response model"); + let bytes = serde_json::to_vec(&value).expect("canonical response bytes"); + MycAdminResponseDocument::from_canonical_bytes(route, &bytes) + .expect("admitted response document") + } + + #[test] + fn complete_route_and_model_inventory_matches_the_machine_contract() { + assert!(operator_route_inventory_is_exact()); + assert_eq!(MycAdminRoute::ALL.len(), 21); + let referenced = MycAdminRoute::ALL + .into_iter() + .flat_map(|route| [route.request_model(), route.response_model()]) + .collect::<BTreeSet<_>>(); + let governed = operator_contract()["admin"]["models"] + .as_object() + .expect("model inventory") + .keys() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + assert_eq!(referenced, governed); + assert_eq!(governed.len(), 35); + for model in governed { + let value = sample_model(model); + validate_model(model, &value).expect("minimum exact model"); + } + } + + #[test] + fn strict_response_admission_rejects_duplicates_null_and_noncanonical_bytes() { + let route = MycAdminRoute::IdentityPublic; + let valid = response_document(route); + assert_eq!(valid.route(), route); + assert!(!valid.canonical_bytes().is_empty()); + + let duplicate = br#"{"generation":0,"generation":1,"public_key":"0000000000000000000000000000000000000000000000000000000000000000","role":"transport"}"#; + assert_eq!( + MycAdminResponseDocument::from_canonical_bytes(route, duplicate) + .expect_err("duplicate key") + .kind(), + MycAdminDocumentErrorKind::DuplicateField + ); + let nested_null = br#"{"generation":0,"public_key":null,"role":"transport"}"#; + assert_eq!( + MycAdminResponseDocument::from_canonical_bytes(route, nested_null) + .expect_err("nested null") + .kind(), + MycAdminDocumentErrorKind::NullForbidden + ); + let noncanonical = format!(" {}", String::from_utf8_lossy(valid.canonical_bytes())); + assert_eq!( + MycAdminResponseDocument::from_canonical_bytes(route, noncanonical.as_bytes()) + .expect_err("whitespace") + .kind(), + MycAdminDocumentErrorKind::NonCanonical + ); + let invalid = br#"{"generation":0,"public_key":"0000000000000000000000000000000000000000000000000000000000000000","role":"administrator"}"#; + assert_eq!( + MycAdminResponseDocument::from_canonical_bytes(route, invalid) + .expect_err("invalid model") + .kind(), + MycAdminDocumentErrorKind::InvalidModel + ); + assert_eq!( + MycAdminResponseDocument::from_canonical_bytes(route, b"") + .expect_err("empty response") + .kind(), + MycAdminDocumentErrorKind::Malformed + ); + let oversized = vec![b' '; MYC_ADMIN_RESPONSE_BODY_MAX_UTF8_BYTES + 1]; + assert_eq!( + MycAdminResponseDocument::from_canonical_bytes(route, &oversized) + .expect_err("oversized response") + .kind(), + MycAdminDocumentErrorKind::TooLarge + ); + assert_eq!( + format!("{valid:?}"), + "MycAdminResponseDocument { route: IdentityPublic, model: \"[redacted]\" }" + ); + } + + #[test] + fn query_and_nested_type_admission_is_exact_and_bounded() { + let connections = query_model( + MycAdminRoute::ConnectionsList, + Some("cursor=AA&limit=200&state=approved"), + ) + .expect("canonical query"); + validate_model("connections_query_v1", &connections).expect("query model"); + for invalid in [ + "limit=01", + "limit=201", + "limit=1&limit=2", + "unknown=x", + "cursor=%", + "state=administrator", + ] { + let result = query_model(MycAdminRoute::ConnectionsList, Some(invalid)) + .and_then(|value| validate_model("connections_query_v1", &value)); + assert!(result.is_err(), "query `{invalid}` unexpectedly passed"); + } + for valid in [ + "", + "nip04_decrypt", + "sign_event:kind:0", + "nip04_decrypt,sign_event:kind:1", + ] { + validate_type("permission_set", &Value::String(valid.to_owned()), 0) + .expect("permission set"); + } + for invalid in [ + "sign_event:kind:00", + "sign_event:kind:4294967296", + "sign_event:kind:1,nip04_decrypt", + "nip04_decrypt,nip04_decrypt", + ] { + assert!( + validate_type("permission_set", &Value::String(invalid.to_owned()), 0).is_err() + ); + } + assert!( + validate_type( + "safe_url", + &Value::String("http://example.test/".to_owned()), + 0 + ) + .is_err() + ); + assert!(validate_type("bounded_id", &Value::String("../escape".to_owned()), 0).is_err()); + } + + #[test] + fn public_diagnostics_are_source_free_and_content_free() { + let document = MycAdminDocumentError::new(MycAdminDocumentErrorKind::InvalidModel); + let handler = MycAdminHandlerError::new(MycAdminHandlerErrorKind::Internal); + for rendered in [ + format!("{document}"), + format!("{document:?}"), + format!("{handler}"), + format!("{handler:?}"), + ] { + assert!(!rendered.contains("/tmp/protected")); + assert!(!rendered.contains("credential")); + } + assert!(Error::source(&document).is_none()); + assert!(Error::source(&handler).is_none()); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + mod native { + use core::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; + + use radroots_service_host::{ + AdminClient, AdminClientTarget, AdminServer, AdminTransportLimits, CancellationToken, + EntropyError, EntropySource, UnixAdminSocketBinding, UnixAdminSocketWriterAuthority, + }; + + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[derive(Clone, Copy)] + struct FixedEntropy; + + impl EntropySource for FixedEntropy { + fn fill_bytes(&self, destination: &mut [u8]) -> Result<(), EntropyError> { + destination.fill(0x51); + Ok(()) + } + } + + type FixtureCall = (MycAdminRoute, Option<String>, Box<[u8]>); + + struct FixtureHandler { + calls: Mutex<Vec<FixtureCall>>, + invalid_cursor: AtomicUsize, + decoded_parameter: AtomicUsize, + } + + impl FixtureHandler { + fn new() -> Self { + Self { + calls: Mutex::new(Vec::new()), + invalid_cursor: AtomicUsize::new(0), + decoded_parameter: AtomicUsize::new(0), + } + } + } + + impl MycAdminHandler for FixtureHandler { + fn handle<'a>(&'a self, request: MycAdminRequestDocument) -> MycAdminFuture<'a> { + Box::pin(async move { + if request.route() == MycAdminRoute::ConnectionsList + && request + .model_bytes() + .windows(4) + .any(|window| window == b"\"eA\"") + { + self.invalid_cursor.fetch_add(1, Ordering::SeqCst); + return Err(MycAdminHandlerError::new( + MycAdminHandlerErrorKind::InvalidCursor, + )); + } + if request.operation_id() == Some("conflict") { + return Err(MycAdminHandlerError::new( + MycAdminHandlerErrorKind::OperationIdConflict, + )); + } + if request.parameter("connection_id") == Some("connection-encoded") { + self.decoded_parameter.fetch_add(1, Ordering::SeqCst); + return Ok(response_document(request.route())); + } + self.calls.lock().expect("calls").push(( + request.route(), + request.operation_id().map(str::to_owned), + request.model_bytes().into(), + )); + Ok(response_document(request.route())) + }) + } + } + + fn runtime_directory() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("myc-admin-") + .tempdir_in("/tmp") + .expect("short runtime directory") + } + + fn target_for(route: MycAdminRoute, request: &Value) -> AdminClientTarget { + let path = route + .path() + .replace("{connection_id}", "connection-1") + .replace("{challenge_id}", "challenge-1"); + if !matches!(route.method(), MycAdminMethod::Get) + || request.as_object().is_some_and(Map::is_empty) + { + return AdminClientTarget::new(path).expect("route target"); + } + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for (name, value) in request.as_object().expect("query object") { + let rendered = value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()); + serializer.append_pair(name, &rendered); + } + AdminClientTarget::new(format!("{path}?{}", serializer.finish())).expect("query target") + } + + async fn raw_post(socket: &Path, body: &str) -> String { + let mut stream = tokio::net::UnixStream::connect(socket) + .await + .expect("raw connection"); + let request = format!( + "POST /v1/discovery/render HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(request.as_bytes()) + .await + .expect("raw request"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("raw response"); + String::from_utf8(response).expect("HTTP response") + } + + #[tokio::test] + async fn all_twenty_one_routes_round_trip_over_the_hardened_unix_boundary() { + let directory = runtime_directory(); + let socket = directory.path().join("admin.sock"); + let authority = UnixAdminSocketWriterAuthority::acquire(directory.path()) + .expect("writer authority"); + let binding = UnixAdminSocketBinding::bind(authority, &socket) + .await + .expect("socket binding"); + let handler = Arc::new(FixtureHandler::new()); + let MycAdminRouter { inner } = + build_myc_admin_router(Arc::clone(&handler)).expect("exact router"); + let server = AdminServer::new(inner, AdminTransportLimits::DEFAULT, FixedEntropy) + .expect("admin server"); + let cancellation = CancellationToken::new(); + let server_cancellation = cancellation.clone(); + let task = tokio::spawn(async move { + server + .serve(binding, server_cancellation) + .await + .expect("serve Myc admin"); + }); + let client = + AdminClient::new(&socket, AdminTransportLimits::DEFAULT).expect("admin client"); + + for (index, route) in MycAdminRoute::ALL.into_iter().enumerate() { + let request = sample_model(route.request_model()); + let target = target_for(route, &request); + let response = match route.method() { + MycAdminMethod::Get => client.get::<Value>(&target).await.expect("GET route"), + MycAdminMethod::Post => { + let operation_id = AdminOperationId::new(format!("operation-{index}")) + .expect("operation ID"); + client + .mutate::<_, Value>(&target, operation_id, None, &request) + .await + .expect("POST route") + } + }; + validate_model(route.response_model(), response.result()) + .expect("route response model"); + } + + let cursor_target = + AdminClientTarget::new("/v1/connections?cursor=eA&limit=1").expect("cursor target"); + let cursor_error = client + .get::<Value>(&cursor_target) + .await + .expect_err("handler rejects unbound cursor"); + assert_eq!( + cursor_error + .failure() + .expect("failure envelope") + .error() + .code() + .as_str(), + "invalid_cursor" + ); + assert_eq!(handler.invalid_cursor.load(Ordering::SeqCst), 1); + + let conflict_target = + AdminClientTarget::new("/v1/discovery/render").expect("mutation target"); + let conflict_error = client + .mutate::<_, Value>( + &conflict_target, + AdminOperationId::new("conflict").expect("operation ID"), + None, + sample_model("discovery_render_request_v1"), + ) + .await + .expect_err("operation conflict"); + assert_eq!( + conflict_error + .failure() + .expect("failure envelope") + .error() + .code() + .as_str(), + "operation_id_conflict" + ); + + for body in [ + r#"{"contract_version":1,"operation_id":"duplicate","request":{"expected_generation":0,"expected_generation":1}}"#, + r#"{"contract_version":1,"operation_id":"null","request":{"expected_generation":null}}"#, + ] { + let response = raw_post(&socket, body).await; + assert!(response.starts_with("HTTP/1.1 400 "), "{response}"); + } + + let encoded_target = + AdminClientTarget::new("/v1/connections/connection%2Dencoded/approve") + .expect("encoded parameter target"); + client + .mutate::<_, Value>( + &encoded_target, + AdminOperationId::new("encoded-parameter").expect("operation ID"), + None, + sample_model("connection_approve_request_v1"), + ) + .await + .expect("percent-decoded path parameter"); + assert_eq!(handler.decoded_parameter.load(Ordering::SeqCst), 1); + + { + let calls = handler.calls.lock().expect("calls"); + assert_eq!(calls.len(), 21); + for (index, (route, operation_id, request)) in calls.iter().enumerate() { + assert_eq!(*route, MycAdminRoute::ALL[index]); + assert_eq!(operation_id.is_some(), route.is_mutation()); + validate_model( + route.request_model(), + &serde_json::from_slice(request).expect("retained request model"), + ) + .expect("retained exact model"); + } + } + cancellation.cancel(); + task.await.expect("server task"); + } + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] #![doc = include_str!("../README")] +mod admin_v1; mod cli_v1; mod config_v1; mod nip46_admission; @@ -33,6 +34,12 @@ mod state_repository; mod state_request; mod state_response; +pub use admin_v1::{ + MycAdminDocumentError, MycAdminDocumentErrorKind, MycAdminFuture, MycAdminHandler, + MycAdminHandlerError, MycAdminHandlerErrorKind, MycAdminMethod, MycAdminRequestDocument, + MycAdminResponseDocument, MycAdminRoute, MycAdminRouter, MycAdminRouterError, + build_myc_admin_router, +}; pub use cli_v1::{ MycBootstrapProfileV1, MycCliInvocationV1, MycCliV1Error, MycCliV1ErrorKind, MycCommandV1, MycConfigCommandV1, MycIdentityCommandV1, MycStateCommandV1, parse_myc_cli_v1_from, diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -5,6 +5,7 @@ use std::collections::BTreeSet; const ROOT: &str = include_str!("../src/lib.rs"); const README: &str = include_str!("../README"); const PUBLIC_API: &str = include_str!("../contracts/api_baselines/myc.txt"); +const ADMIN_V1: &str = include_str!("../src/admin_v1.rs"); const NIP46_VERIFICATION: &str = include_str!("../src/nip46_verification.rs"); const NIP46_AUTHORIZATION: &str = include_str!("../src/nip46_authorization.rs"); const NIP46_REPLAY: &str = include_str!("../src/nip46_replay.rs"); @@ -31,6 +32,7 @@ const NIP46_RESPONSE_CONTRACT: &str = const DELIVERY_RECOVERY_EXPORT_CONTRACT: &str = include_str!("../contracts/services_hardening/delivery_recovery_export.v1.json"); const SOURCES: &[&str] = &[ + include_str!("../src/admin_v1.rs"), include_str!("../src/cli_v1.rs"), include_str!("../src/config_v1.rs"), include_str!("../src/nip46_admission.rs"), @@ -68,6 +70,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { .filter_map(|line| line.strip_suffix(';')) .collect::<BTreeSet<_>>(), BTreeSet::from([ + "admin_v1", "cli_v1", "config_v1", "nip46_admission", @@ -116,6 +119,13 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { #[test] fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { for required in [ + "pub struct myc::MycAdminRequestDocument", + "pub struct myc::MycAdminResponseDocument", + "pub enum myc::MycAdminMethod", + "pub enum myc::MycAdminRoute", + "pub trait myc::MycAdminHandler", + "pub struct myc::MycAdminRouter", + "pub fn myc::build_myc_admin_router", "pub struct myc::MycRuntimeContext", "pub struct myc::MycRuntimeFoundation", "pub struct myc::MycRuntimeReadiness", @@ -178,6 +188,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { } for module in [ + "admin_v1", "cli_v1", "config_v1", "nip46_admission", @@ -442,6 +453,57 @@ fn step146_first_wave_gate_is_machine_bound_and_test_only() { } #[test] +fn step150_admin_adapter_is_closed_typed_and_transport_bounded() { + for required in [ + "pub const ALL: [Self; 21]", + "models.len() == 35", + "operator_route_inventory_is_exact", + "AdminMutationRequest<Value>", + "strict_json(bytes)", + "MycAdminDocumentErrorKind::DuplicateField", + "MycAdminDocumentErrorKind::NullForbidden", + "MycAdminHandlerErrorKind::InvalidCursor", + "MycAdminHandlerErrorKind::OperationIdConflict", + "original committed response", + "same route, filters, and snapshot", + "relay submission or delivery is not implied", + "all_twenty_one_routes_round_trip_over_the_hardened_unix_boundary", + ] { + assert!( + ADMIN_V1.contains(required), + "Step 150 adapter is missing `{required}`" + ); + } + for forbidden in [ + "TcpListener", + "axum::", + "warp::", + "actix", + "SqlitePool", + "SqliteConnection", + "tokio::spawn(async move { handler", + "SystemTime", + "rand::", + "getrandom", + "process::exit", + ] { + assert!( + !ADMIN_V1.contains(forbidden), + "Step 150 adapter gained forbidden authority `{forbidden}`" + ); + } + for required in [ + "exact 21-route", + "all 35 model", + "Raw shared-host routers and JSON values never cross the public", + "operation_id_conflict", + "not the later daemon runtime", + ] { + assert!(README.contains(required), "README is missing `{required}`"); + } +} + +#[test] fn step144_authorization_is_configuration_bound_and_reuses_durable_state() { for forbidden in [ "sqlx::", @@ -528,7 +590,7 @@ fn public_errors_remain_crate_owned_redacted_and_source_free() { .lines() .filter(|line| line.starts_with("pub struct myc::") && line.ends_with("Error")) .count(); - assert_eq!(public_error_count, 24); + assert_eq!(public_error_count, 27); assert!(!PUBLIC_API.contains("pub struct myc::MycRuntimeFoundation {")); assert!(!PUBLIC_API.contains("pub struct myc::MycStateHost {")); } diff --git a/tests/services_hardening_contracts.rs b/tests/services_hardening_contracts.rs @@ -49,7 +49,7 @@ fn source_lock_identity_and_shared_host_reference_are_exact() { let value = contract(); assert_eq!(value["schema"], "radroots.myc.operator-contract.v1"); assert_eq!(value["contract_version"], 1); - assert_eq!(value["decision_state"], "reserved_preimplementation"); + assert_eq!(value["decision_state"], "implemented"); assert_eq!(value["service"], "myc"); assert_eq!( value["shared_host_contract"],