commit 331e5983dae96ee3d243f80dbfeb864745858987
parent cbb181fd1adc20917e3a28184377e041653806d0
Author: triesap <tyson@radroots.org>
Date: Tue, 25 Aug 2026 03:15:11 +0000
nip46: persist pending approval responses
- sign and retain the exact pending_connection reply
- commit response bytes and delivery authority atomically
- add additive schema-v12 pending response guards
- bind contracts, API evidence, and replay qualification
Diffstat:
24 files changed, 1018 insertions(+), 87 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -194,6 +194,12 @@
root, arbitrary member name, Nix/NixOS/OCI input or output, signing key,
parent-owned human document, private harness, protected material, or
publication/deployment authority.
+- Step 221 integration requires a signed `pending_connection` response for an
+ explicitly approval-gated NIP-46 connect request. Keep that exact response
+ and its initial delivery job atomic and immutable without recording a false
+ terminal operation completion; relay delivery and exact replay use only the
+ retained signed bytes, and terminal response authority must not conflict
+ with the pending response.
- Step 161 owns the closed executable qualification matrix in
`contracts/services_hardening/process_qualification.v1.json`. Keep its
process deadlines, parallelism, soak count, crash fixture, output bound,
diff --git a/Cargo.toml b/Cargo.toml
@@ -20,7 +20,7 @@ service = "myc"
host_feature_profile = "service-host"
nix_material = "deferred"
config_contract_version = 1
-state_contract_version = 11
+state_contract_version = 12
admin_contract_version = 1
status_contract_version = 1
provider_contract_version = 1
diff --git a/README b/README
@@ -190,10 +190,10 @@ without changing the canonical common artifact inventory.
Create-new initialization reserves the shared schema-v1 metadata and migration
ledger, retains exclusive writer authority, applies the exact Myc schema-v2
-through schema-v11 migrations, binds the normalized configuration, expected
+through schema-v12 migrations, binds the normalized configuration, expected
identity roles, and policy versions through a sealed typed repository, and
explicitly closes the host before reporting success. Existing writable open can
-resume any exact v1 through v10 prefix or admit the current v11 catalog;
+resume any exact v1 through v11 prefix or admit the current v12 catalog;
read-only inspection requires the current catalog and exact latest Myc binding.
Schema v10 adds an append-only configuration-binding history capped at exactly
@@ -224,6 +224,13 @@ The admin response transport admits at least 8,382 UTF-8 bytes so the maximum
retained model plus the maximum safe correlation identity always fits its
canonical success envelope.
+Schema v12 adds immutable response authority for a connect request awaiting
+explicit approval. Myc signs and atomically retains the exact
+`pending_connection` response with its initial delivery job before relay
+publication, without recording a false terminal operation completion. Exact
+replay and delivery use only the retained signed bytes, and a later terminal
+response cannot conflict with the pending authority.
+
The Step 159 provider and delivery boundary is sealed inside the crate. Both
governed provider kinds execute only fully bound operations, and every result
is independently verified before it becomes authority. Protected blocking
diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt
@@ -2239,6 +2239,9 @@ pub const myc::MYC_STATE_SCHEMA_VERSION_10_SHA256: [u8; 32]
pub const myc::MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256: [u8; 32]
pub const myc::MYC_STATE_SCHEMA_VERSION_11_OBJECT_COUNT: u32
pub const myc::MYC_STATE_SCHEMA_VERSION_11_SHA256: [u8; 32]
+pub const myc::MYC_STATE_SCHEMA_VERSION_12_MIGRATION_SHA256: [u8; 32]
+pub const myc::MYC_STATE_SCHEMA_VERSION_12_OBJECT_COUNT: u32
+pub const myc::MYC_STATE_SCHEMA_VERSION_12_SHA256: [u8; 32]
pub const myc::MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32
pub const myc::MYC_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32]
pub const myc::MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256: [u8; 32]
diff --git a/contracts/services_hardening/native_release.v2.json b/contracts/services_hardening/native_release.v2.json
@@ -55,7 +55,7 @@
},
"contract_versions": {
"config": 1,
- "state": 11,
+ "state": 12,
"admin": 1,
"status": 1,
"provider": 1
diff --git a/contracts/services_hardening/nip46_pending_response.v1.json b/contracts/services_hardening/nip46_pending_response.v1.json
@@ -0,0 +1,62 @@
+{
+ "schema": "radroots.myc.nip46-pending-response.v1",
+ "contract_version": 1,
+ "step": 221,
+ "state_schema_version": 12,
+ "authority": {
+ "request": "admitted_connect_request",
+ "decision": "immutable_explicit_approval_pending_decision",
+ "connection": "same_pending_connection_and_policy_generation",
+ "response": "independently_signature_verified_canonical_kind_24133_pending_connection_event",
+ "response_signer": "exact_bound_user_provider_operation",
+ "recipient": "exact_original_nip46_client_public_key",
+ "targets_and_retry_policy": "normalized_configuration_bound_in_state_metadata",
+ "time": "caller_injected_positive_unix_milliseconds",
+ "transaction": "existing_service_sqlite_transaction_runner"
+ },
+ "atomic_commit": [
+ "immutable_exact_signed_pending_response_bytes_sha256_and_event_id",
+ "immutable_relay_target_set",
+ "pending_delivery_job",
+ "zero_attempt_target_state"
+ ],
+ "terminal_effects": {
+ "operation_completion": false,
+ "session_activation": false,
+ "connection_approval": false
+ },
+ "replay": {
+ "source": "committed_pending_response_bytes_only",
+ "same_bound_request": "exact_replay",
+ "mismatched_request_or_decision": "fail_closed",
+ "pending_response_without_job": "fail_closed_without_repair",
+ "terminal_response_conflict": "fail_closed"
+ },
+ "delivery": {
+ "job_state": "pending",
+ "target_state": "pending",
+ "attempt_count": 0,
+ "target_count_maximum": 32,
+ "retry_bytes": "exact_committed_pending_response_bytes",
+ "relay_io": "after_local_commit_only"
+ },
+ "qualification": [
+ "response_edge_failure_rolls_back_response_and_delivery",
+ "exact_replay_returns_original_bytes",
+ "no_terminal_operation_commit_is_created",
+ "delivery_reads_the_same_pending_authority",
+ "public_errors_and_debug_are_source_free_and_redacted",
+ "live_nip46_client_observes_pending_then_continues_after_admin_approval"
+ ],
+ "forbidden": [
+ "false_terminal_operation_completion",
+ "session_activation_before_approval",
+ "provider_execution_inside_transaction",
+ "relay_io_inside_transaction",
+ "response_reconstruction_on_retry",
+ "alternate_sqlite_authority",
+ "ambient_clock",
+ "ambient_entropy",
+ "task_spawn"
+ ]
+}
diff --git a/radroots.service.source-lock.v2.toml b/radroots.service.source-lock.v2.toml
@@ -18,7 +18,7 @@ flake_lock_sha256 = "90a03f6f0794f3f6556b1f2bf6700812d48ce8dc1cee7c4f8bc62cea69b
[contract_versions]
config = 1
-state = 11
+state = 12
admin = 1
status = 1
provider = 1
diff --git a/src/lib.rs b/src/lib.rs
@@ -204,8 +204,10 @@ pub use state_catalog::{
MYC_STATE_SCHEMA_VERSION_9_SHA256, MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256,
MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_10_SHA256,
MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_11_OBJECT_COUNT,
- MYC_STATE_SCHEMA_VERSION_11_SHA256, MycStateCatalogError, MycStateCatalogErrorKind,
- myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs,
+ MYC_STATE_SCHEMA_VERSION_11_SHA256, MYC_STATE_SCHEMA_VERSION_12_MIGRATION_SHA256,
+ MYC_STATE_SCHEMA_VERSION_12_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_12_SHA256,
+ MycStateCatalogError, MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog,
+ validate_myc_state_catalogs,
};
pub use state_completion::{
MycNip46CommitAdmission, MycNip46CommitError, MycNip46CommitErrorKind, MycNip46CommitRecord,
diff --git a/src/nip46_wave_080_b.rs b/src/nip46_wave_080_b.rs
@@ -24,6 +24,7 @@ use super::nip46_wave_080_a::{
connect_request, connection_time, keys, metadata, migration_evidence, permissions,
prepared_request, runtime, unsigned_sign_event, untrusted_response,
};
+use crate::state_response::MycNip46PendingResponseCommitRequest;
pub(crate) async fn active_connection(
repository: &crate::MycStateRepository<'_>,
@@ -92,6 +93,205 @@ pub(crate) async fn active_connection(
(work, active, decision)
}
+async fn pending_connection(
+ repository: &crate::MycStateRepository<'_>,
+ config: &crate::MycConfigDocumentV1,
+) -> (crate::MycNip46Work, crate::MycConnectionDecisionRecord) {
+ let prepared = prepared_request(
+ config,
+ &keys(10),
+ "pending-response-connect",
+ connect_request(),
+ Encryption::Nip44V2,
+ OBSERVED_AT_SECONDS + 40,
+ 40,
+ RECEIVED_AT_MS + 40,
+ );
+ let admitted = repository
+ .admit_signer_request(prepared.signer_request())
+ .await
+ .expect("pending request admission");
+ let work = prepare_myc_nip46_work(
+ prepared,
+ admitted.record().clone(),
+ None,
+ config.provider_contract(),
+ connection_time(RECEIVED_AT_MS + 4_000),
+ None,
+ )
+ .expect("pending connect work");
+ let connection_request = work
+ .connection_admission_request(
+ MycConnectionPolicyGeneration::new(1).expect("policy generation"),
+ crate::MycConnectionNonce::from_injected_entropy([0x81; 32]),
+ connection_time(RECEIVED_AT_MS + 4_000),
+ None,
+ MycConnectionAdmissionPolicy::ExplicitApproval,
+ MycRateRelayId::new("primary").expect("relay"),
+ )
+ .expect("pending connection request");
+ let admission = repository
+ .admit_connection(&connection_request)
+ .await
+ .expect("pending connection admission");
+ let decision = admission.record().expect("pending decision").clone();
+ assert_eq!(
+ decision.decision(),
+ crate::MycConnectionDecision::PendingApproval
+ );
+ (work, decision)
+}
+
+fn pending_response_request(
+ config: &crate::MycConfigDocumentV1,
+ work: &crate::MycNip46Work,
+ decision: &crate::MycConnectionDecisionRecord,
+) -> (MycNip46PendingResponseCommitRequest, Vec<u8>) {
+ let unsigned = NostrUnsignedEvent::new(
+ keys(3).public_key(),
+ Timestamp::from_secs(OBSERVED_AT_SECONDS + 41),
+ Kind::Custom(24_133),
+ vec![Tag::public_key(keys(10).public_key())],
+ "encrypted-pending-response",
+ );
+ let operation = MycProviderOperation::new(
+ config
+ .provider_contract()
+ .binding(MycProviderRole::User)
+ .expect("user binding"),
+ MycProviderOperationId::from_bytes([0xa1; 32]),
+ MycProviderCorrelationId::from_bytes([0xa2; 32]),
+ MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("provider deadline"),
+ MycProviderOperationInput::sign_event(unsigned.as_json().as_bytes())
+ .expect("response signing input"),
+ )
+ .expect("pending response operation");
+ let signed = unsigned
+ .sign_with_keys(&keys(3))
+ .expect("signed pending response");
+ let bytes = serde_json::to_vec(&signed).expect("canonical pending response");
+ let response: MycLocalSignerUntrustedResponse = untrusted_response(
+ &operation,
+ hex::encode(operation.correlation_id().as_bytes()),
+ WireProviderResult::SignEvent {
+ payload_hex: ProtectedWireHex::from_bytes(&bytes),
+ },
+ );
+ let verified = response
+ .verify(
+ config
+ .provider_contract()
+ .binding(MycProviderRole::User)
+ .expect("user binding"),
+ &operation,
+ MycProviderResponseObservedAtUnixMs::new(RECEIVED_AT_MS + 41_001)
+ .expect("response time"),
+ )
+ .expect("verified pending response");
+ let request = MycNip46PendingResponseCommitRequest::new(
+ work,
+ decision,
+ &operation,
+ &verified,
+ crate::MycDeliveryTimeUnixMs::new(RECEIVED_AT_MS + 41_003).expect("commit time"),
+ )
+ .expect("pending response request");
+ (request, bytes)
+}
+
+#[tokio::test]
+async fn pending_approval_response_and_delivery_job_commit_atomically_and_replay_exactly() {
+ let directory = tempfile::tempdir().expect("temporary root");
+ let runtime = runtime(directory.path());
+ fs::create_dir_all(runtime.context().paths().state()).expect("state directory");
+ fs::set_permissions(
+ runtime.context().paths().state(),
+ fs::Permissions::from_mode(0o700),
+ )
+ .expect("state mode");
+ let metadata = metadata(&runtime);
+ let config = configuration();
+ let (applied_at, build) = migration_evidence();
+ initialize_myc_state(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("state initialization");
+ let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("writable state");
+ let repository = host.repository();
+ let (work, decision) = pending_connection(&repository, &config).await;
+ let (request, response_bytes) = pending_response_request(&config, &work, &decision);
+ let debug = format!("{request:?}");
+ assert!(!debug.contains("encrypted-pending-response"));
+
+ let rollback = repository
+ .commit_nip46_pending_response(&request.fail_after_response_for_test())
+ .await
+ .expect_err("pending response without delivery must roll back");
+ assert_eq!(
+ rollback.kind(),
+ crate::MycStateRepositoryErrorKind::Transaction
+ );
+ let committed = repository
+ .commit_nip46_pending_response(&request)
+ .await
+ .expect("pending response commit");
+ assert_eq!(committed.signed_response_bytes(), response_bytes);
+ assert_eq!(
+ committed.operation_id(),
+ work.request_record().operation_id()
+ );
+ let replay = repository
+ .commit_nip46_pending_response(&request)
+ .await
+ .expect("exact pending response replay");
+ assert_eq!(replay, committed);
+ let by_job = repository
+ .read_nip46_response(committed.delivery_job().id())
+ .await
+ .expect("pending response read")
+ .expect("retained pending response");
+ assert_eq!(by_job, committed);
+ host.close().await.expect("host close");
+
+ let options = SqliteConnectOptions::new()
+ .filename(runtime.artifacts().state_database())
+ .create_if_missing(false)
+ .disable_statement_logging();
+ let mut connection = sqlx::SqliteConnection::connect_with(&options)
+ .await
+ .expect("inspection connection");
+ assert_eq!(
+ sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM nip46_pending_responses")
+ .fetch_one(&mut connection)
+ .await
+ .expect("pending response count"),
+ 1
+ );
+ assert_eq!(
+ sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM nip46_signed_responses")
+ .fetch_one(&mut connection)
+ .await
+ .expect("terminal response count"),
+ 0
+ );
+ assert_eq!(
+ sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM nip46_operation_commits")
+ .fetch_one(&mut connection)
+ .await
+ .expect("terminal operation count"),
+ 0
+ );
+ assert_eq!(
+ sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM delivery_jobs")
+ .fetch_one(&mut connection)
+ .await
+ .expect("delivery job count"),
+ 1
+ );
+ connection.close().await.expect("inspection close");
+}
+
pub(crate) fn atomic_response_request(
config: &crate::MycConfigDocumentV1,
completion: &MycNip46CommitRequest,
diff --git a/src/runtime_nip46.rs b/src/runtime_nip46.rs
@@ -13,6 +13,8 @@ use radroots_nostr_connect::{
use radroots_service_host::{EntropySource, SystemEntropy, SystemWallClock, WallClock};
use sha2::{Digest, Sha256};
+use crate::state_response::MycNip46PendingResponseCommitRequest;
+
use crate::{
MycConfigDocumentV1, MycConnectionAdmissionPolicy, MycConnectionDecision,
MycConnectionDecisionRecord, MycConnectionNonce, MycConnectionPermissionSet,
@@ -42,7 +44,7 @@ pub(crate) enum MycNip46DispatchDisposition {
Dropped,
PendingApproval,
Completed,
- ExactCompletedReplay,
+ ExactResponseReplay,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -223,7 +225,7 @@ impl MycRuntimeNip46Coordinator {
.map_err(|_| dispatch_error(MycNip46DispatchErrorKind::State))?
.is_some()
{
- return Ok(MycNip46DispatchDisposition::ExactCompletedReplay);
+ return Ok(MycNip46DispatchDisposition::ExactResponseReplay);
}
let connection = if prepared.method() == MycSignerRequestMethod::Connect {
@@ -273,10 +275,29 @@ impl MycRuntimeNip46Coordinator {
let Some(record) = admission.record().cloned() else {
return Ok(MycNip46DispatchDisposition::Dropped);
};
- if matches!(
- record.decision(),
- MycConnectionDecision::PendingApproval | MycConnectionDecision::Challenged
- ) {
+ if record.decision() == MycConnectionDecision::PendingApproval {
+ let committed_at = connection_time_now()?;
+ let protocol_response = self.protocol_response(&work, Some(&record), None)?;
+ let signed = self
+ .signed_protocol_response(&work, protocol_response, committed_at, cancellation)
+ .await?;
+ let commit = MycNip46PendingResponseCommitRequest::new(
+ &work,
+ &record,
+ &signed.operation,
+ &signed.response,
+ crate::MycDeliveryTimeUnixMs::new(committed_at.get())
+ .map_err(|_| dispatch_error(MycNip46DispatchErrorKind::Runtime))?,
+ )
+ .map_err(|_| dispatch_error(MycNip46DispatchErrorKind::Runtime))?;
+ self.state
+ .repository()
+ .commit_nip46_pending_response(&commit)
+ .await
+ .map_err(|_| dispatch_error(MycNip46DispatchErrorKind::State))?;
+ return Ok(MycNip46DispatchDisposition::PendingApproval);
+ }
+ if record.decision() == MycConnectionDecision::Challenged {
return Ok(MycNip46DispatchDisposition::PendingApproval);
}
Some(record)
@@ -308,16 +329,8 @@ impl MycRuntimeNip46Coordinator {
.map_err(|_| dispatch_error(MycNip46DispatchErrorKind::Runtime))?;
let protocol_response =
self.protocol_response(&work, connect_decision.as_ref(), provider_response.as_ref())?;
- let envelope = protocol_response
- .into_envelope(work.request_record().request_id().as_str())
- .map_err(|_| dispatch_error(MycNip46DispatchErrorKind::Runtime))?;
- let plaintext = serde_json::to_vec(&envelope)
- .map_err(|_| dispatch_error(MycNip46DispatchErrorKind::Runtime))?;
- let encrypted = self
- .encrypt_response(&work, &plaintext, cancellation)
- .await?;
let signed = self
- .sign_response(&work, &encrypted, completed_at, cancellation)
+ .signed_protocol_response(&work, protocol_response, completed_at, cancellation)
.await?;
let commit = MycNip46ResponseCommitRequest::new(
&completion,
@@ -335,6 +348,25 @@ impl MycRuntimeNip46Coordinator {
Ok(MycNip46DispatchDisposition::Completed)
}
+ async fn signed_protocol_response(
+ &self,
+ work: &MycNip46Work,
+ response: Response,
+ committed_at: MycConnectionTimeUnixMs,
+ cancellation: &MycTaskCancellation,
+ ) -> Result<SignedRuntimeResponse, MycNip46DispatchError> {
+ let envelope = response
+ .into_envelope(work.request_record().request_id().as_str())
+ .map_err(|_| dispatch_error(MycNip46DispatchErrorKind::Runtime))?;
+ let plaintext = serde_json::to_vec(&envelope)
+ .map_err(|_| dispatch_error(MycNip46DispatchErrorKind::Runtime))?;
+ let encrypted = self
+ .encrypt_response(work, &plaintext, cancellation)
+ .await?;
+ self.sign_response(work, &encrypted, committed_at, cancellation)
+ .await
+ }
+
fn transport_binding(&self) -> Result<&crate::MycProviderBinding, MycNip46DispatchError> {
self.configuration
.provider_contract()
@@ -435,6 +467,7 @@ impl MycRuntimeNip46Coordinator {
MycSignerRequestMethod::Connect => {
match connect.map(MycConnectionDecisionRecord::decision) {
Some(MycConnectionDecision::Allowed) => Ok(Response::UserPublicKey(user)),
+ Some(MycConnectionDecision::PendingApproval) => Ok(Response::PendingConnection),
Some(MycConnectionDecision::Denied) => Ok(Response::Error {
result: None,
error: "connection_denied".to_owned(),
diff --git a/src/state_catalog.rs b/src/state_catalog.rs
@@ -12,7 +12,7 @@ use radroots_service_sqlite::{
pub const MYC_STATE_BASE_SCHEMA_VERSION: u32 = 1;
/// The newest governed Myc state schema understood by this binary.
-pub const MYC_STATE_SCHEMA_VERSION: u32 = 11;
+pub const MYC_STATE_SCHEMA_VERSION: u32 = 12;
/// The shared metadata and migration-ledger objects present at schema v1.
pub const MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6;
@@ -47,6 +47,9 @@ pub const MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT: u32 = 63;
/// The shared objects plus the bounded admin-operation journal.
pub const MYC_STATE_SCHEMA_VERSION_11_OBJECT_COUNT: u32 = 65;
+/// The shared objects plus immutable pending-approval response authority.
+pub const MYC_STATE_SCHEMA_VERSION_12_OBJECT_COUNT: u32 = 70;
+
/// SHA-256 identity of the exact schema-v1 object snapshot.
pub const MYC_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] = [
0x94, 0xdc, 0x66, 0xfb, 0xca, 0x60, 0x16, 0x79, 0x61, 0x5c, 0x05, 0x52, 0x29, 0xdc, 0x0d, 0xb6,
@@ -61,14 +64,14 @@ pub const MYC_STATE_SCHEMA_VERSION_2_SHA256: [u8; 32] = [
/// SHA-256 identity of the ordered Myc migration catalog.
pub const MYC_MIGRATION_CATALOG_SHA256: [u8; 32] = [
- 0x1a, 0xa7, 0x0a, 0x76, 0xb0, 0x47, 0x4f, 0x9b, 0xb0, 0x33, 0x00, 0xf0, 0xe8, 0x64, 0x54, 0xb2,
- 0x86, 0x3b, 0x45, 0x74, 0x51, 0xed, 0xd9, 0xa2, 0xcc, 0xed, 0x97, 0xba, 0x31, 0xcb, 0x8c, 0xc1,
+ 0xb1, 0xd6, 0x45, 0x82, 0x45, 0xe6, 0xdf, 0xc4, 0x66, 0x1a, 0xa6, 0x14, 0x6b, 0x8c, 0xe8, 0xab,
+ 0x55, 0xf7, 0xc2, 0x9b, 0xf3, 0x60, 0x99, 0xd2, 0x61, 0xc0, 0x7f, 0x5f, 0x51, 0x67, 0x56, 0x1d,
];
/// SHA-256 identity of the schema catalog bound to the migration catalog.
pub const MYC_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [
- 0x09, 0xec, 0x0c, 0x13, 0x2f, 0xbc, 0x1a, 0x8a, 0x46, 0xad, 0x34, 0x03, 0x49, 0x78, 0x93, 0x83,
- 0x4a, 0xbf, 0x73, 0x52, 0x00, 0x5e, 0xb6, 0x27, 0x2c, 0x7a, 0x45, 0x28, 0xdd, 0x5d, 0x66, 0x1a,
+ 0xb5, 0x27, 0x21, 0xd8, 0x5c, 0x1e, 0xb8, 0xcd, 0xdc, 0x28, 0x25, 0x6c, 0x21, 0x17, 0x9a, 0x10,
+ 0xd5, 0xf3, 0x2b, 0xcb, 0x33, 0xe9, 0xd2, 0xa6, 0xbb, 0x8f, 0xe4, 0x9e, 0xcb, 0xc4, 0x9a, 0x68,
];
/// SHA-256 identity of the schema-v2 migration content.
@@ -179,6 +182,18 @@ pub const MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256: [u8; 32] = [
0x68, 0xb5, 0x46, 0xa4, 0xba, 0x6a, 0xfd, 0xfe, 0xde, 0x56, 0x5f, 0xe0, 0x26, 0x57, 0x6c, 0x96,
];
+/// SHA-256 identity of the schema-v12 pending-approval response migration.
+pub const MYC_STATE_SCHEMA_VERSION_12_MIGRATION_SHA256: [u8; 32] = [
+ 0x38, 0x8e, 0xe5, 0x1d, 0xe5, 0x99, 0xf3, 0x7b, 0x7b, 0xb1, 0x95, 0x6c, 0xeb, 0xd5, 0x18, 0x46,
+ 0x1f, 0x3e, 0xb1, 0x93, 0x53, 0x5f, 0xfe, 0x6b, 0xb9, 0xea, 0xc2, 0xd8, 0x2b, 0xbe, 0x3e, 0x37,
+];
+
+/// SHA-256 identity of the schema-v12 object snapshot.
+pub const MYC_STATE_SCHEMA_VERSION_12_SHA256: [u8; 32] = [
+ 0xd8, 0x93, 0xa2, 0x3b, 0xa6, 0x8e, 0x46, 0x34, 0x89, 0xad, 0x7e, 0xf1, 0xe6, 0xa8, 0x0e, 0xa4,
+ 0xe8, 0x80, 0x89, 0x38, 0x1c, 0x73, 0xde, 0xcc, 0x32, 0x43, 0xa4, 0xce, 0x6f, 0x64, 0xda, 0x8f,
+];
+
/// SHA-256 identity of the schema-v11 object snapshot.
pub const MYC_STATE_SCHEMA_VERSION_11_SHA256: [u8; 32] = [
0x0d, 0xe7, 0xfe, 0x17, 0x6e, 0xa7, 0xda, 0x60, 0x30, 0x42, 0x4a, 0xdd, 0xc2, 0x9b, 0x9a, 0x91,
@@ -1746,6 +1761,118 @@ const CREATE_NIP46_ATOMIC_RESPONSE_MIGRATION_SQL: &str = concat!(
nip46_signed_responses_no_delete_sql!(),
);
+macro_rules! nip46_pending_responses_table_sql {
+ () => {
+ r#"CREATE TABLE nip46_pending_responses (
+ operation_id BLOB NOT NULL PRIMARY KEY CHECK (length(operation_id) = 32)
+ REFERENCES nip46_requests(operation_id),
+ connection_id BLOB NOT NULL CHECK (length(connection_id) = 32)
+ REFERENCES connections(connection_id),
+ response_kind TEXT NOT NULL CHECK (response_kind = 'pending_approval'),
+ response_provider_operation_id BLOB NOT NULL UNIQUE
+ CHECK (length(response_provider_operation_id) = 32),
+ response_event_id BLOB NOT NULL UNIQUE CHECK (length(response_event_id) = 32),
+ response_sha256 BLOB NOT NULL CHECK (length(response_sha256) = 32),
+ response_bytes BLOB NOT NULL
+ CHECK (length(response_bytes) BETWEEN 1 AND 1048576),
+ authored_at_unix_s INTEGER NOT NULL
+ CHECK (authored_at_unix_s BETWEEN 1 AND 9223372036854775807),
+ committed_at_unix_ms INTEGER NOT NULL
+ CHECK (committed_at_unix_ms BETWEEN 1 AND 9223372036854775807)
+) STRICT"#
+ };
+}
+
+macro_rules! nip46_pending_responses_guard_insert_sql {
+ () => {
+ r#"CREATE TRIGGER nip46_pending_responses_guard_insert
+BEFORE INSERT ON nip46_pending_responses
+WHEN NOT EXISTS (
+ SELECT 1
+ FROM nip46_request_decisions AS decision
+ JOIN connections AS connection
+ ON connection.connection_id = decision.connection_id
+ JOIN nip46_requests AS request
+ ON request.operation_id = decision.operation_id
+ WHERE decision.operation_id = NEW.operation_id
+ AND decision.connection_id = NEW.connection_id
+ AND decision.decision = 'pending_approval'
+ AND decision.reason_code = 'explicit_approval_required'
+ AND connection.status = 'pending'
+ AND connection.client_public_key = request.client_public_key
+ AND connection.policy_generation = decision.policy_generation
+ AND connection.requested_permissions_sha256 = decision.requested_permissions_sha256
+ AND request.method = 'connect'
+ )
+ OR EXISTS (
+ SELECT 1 FROM nip46_signed_responses
+ WHERE operation_id = NEW.operation_id
+ OR response_provider_operation_id = NEW.response_provider_operation_id
+ OR response_event_id = NEW.response_event_id
+ )
+BEGIN
+ SELECT RAISE(ABORT, 'pending NIP-46 response binding is invalid');
+END"#
+ };
+}
+
+macro_rules! nip46_signed_responses_guard_pending_insert_sql {
+ () => {
+ r#"CREATE TRIGGER nip46_signed_responses_guard_pending_insert
+BEFORE INSERT ON nip46_signed_responses
+WHEN EXISTS (
+ SELECT 1 FROM nip46_pending_responses
+ WHERE operation_id = NEW.operation_id
+ OR response_provider_operation_id = NEW.response_provider_operation_id
+ OR response_event_id = NEW.response_event_id
+)
+BEGIN
+ SELECT RAISE(ABORT, 'terminal NIP-46 response conflicts with pending authority');
+END"#
+ };
+}
+
+macro_rules! nip46_pending_responses_no_update_sql {
+ () => {
+ r#"CREATE TRIGGER nip46_pending_responses_no_update
+BEFORE UPDATE ON nip46_pending_responses
+BEGIN
+ SELECT RAISE(ABORT, 'pending NIP-46 response is immutable');
+END"#
+ };
+}
+
+macro_rules! nip46_pending_responses_no_delete_sql {
+ () => {
+ r#"CREATE TRIGGER nip46_pending_responses_no_delete
+BEFORE DELETE ON nip46_pending_responses
+BEGIN
+ SELECT RAISE(ABORT, 'pending NIP-46 response is retained');
+END"#
+ };
+}
+
+const CREATE_NIP46_PENDING_RESPONSES_TABLE_SQL: &str = nip46_pending_responses_table_sql!();
+const CREATE_NIP46_PENDING_RESPONSES_GUARD_INSERT_SQL: &str =
+ nip46_pending_responses_guard_insert_sql!();
+const CREATE_NIP46_SIGNED_RESPONSES_GUARD_PENDING_INSERT_SQL: &str =
+ nip46_signed_responses_guard_pending_insert_sql!();
+const CREATE_NIP46_PENDING_RESPONSES_NO_UPDATE_SQL: &str = nip46_pending_responses_no_update_sql!();
+const CREATE_NIP46_PENDING_RESPONSES_NO_DELETE_SQL: &str = nip46_pending_responses_no_delete_sql!();
+
+const CREATE_NIP46_PENDING_RESPONSE_MIGRATION_SQL: &str = concat!(
+ nip46_pending_responses_table_sql!(),
+ ";\n",
+ nip46_pending_responses_guard_insert_sql!(),
+ ";\n",
+ nip46_signed_responses_guard_pending_insert_sql!(),
+ ";\n",
+ nip46_pending_responses_no_update_sql!(),
+ ";\n",
+ nip46_pending_responses_no_delete_sql!(),
+ ";",
+);
+
macro_rules! myc_config_bindings_table_sql {
() => {
r#"CREATE TABLE myc_config_bindings (
@@ -1907,6 +2034,27 @@ const MYC_ADMIN_OPERATIONS_GUARD_UPDATE_SHA256: [u8; 32] = [
0x22, 0x52, 0x4f, 0x51, 0x2c, 0xbc, 0xe2, 0x8a, 0x7a, 0x8f, 0xe0, 0xd5, 0x2c, 0xfd, 0x45, 0xf6,
];
+const NIP46_PENDING_RESPONSES_TABLE_SHA256: [u8; 32] = [
+ 0x27, 0x99, 0x17, 0x8b, 0x41, 0xb4, 0x4b, 0xb2, 0x22, 0x2c, 0x54, 0x99, 0xbc, 0xc2, 0x67, 0x37,
+ 0x47, 0x84, 0xe8, 0xe1, 0xd5, 0xde, 0xa2, 0xe6, 0x93, 0x6e, 0xfa, 0x9f, 0xb0, 0xc8, 0x80, 0x10,
+];
+const NIP46_PENDING_RESPONSES_GUARD_INSERT_SHA256: [u8; 32] = [
+ 0x7a, 0x8a, 0x70, 0x91, 0x63, 0x33, 0xf4, 0xb8, 0x39, 0x5e, 0xa9, 0x39, 0x6b, 0xc1, 0xd6, 0x3e,
+ 0x4b, 0x11, 0xe9, 0x96, 0x70, 0x4d, 0x6f, 0x3b, 0xa0, 0x30, 0xac, 0xbb, 0x19, 0x5a, 0xd6, 0x8c,
+];
+const NIP46_SIGNED_RESPONSES_GUARD_PENDING_INSERT_SHA256: [u8; 32] = [
+ 0xd7, 0x30, 0xca, 0xbe, 0xe4, 0x05, 0x1b, 0xfb, 0x5a, 0x7b, 0x34, 0xe5, 0x9c, 0x1f, 0x35, 0x7b,
+ 0x25, 0x4f, 0xea, 0x50, 0x53, 0x6d, 0xed, 0x67, 0x7e, 0x9e, 0x52, 0x67, 0x4a, 0x6b, 0x15, 0xbe,
+];
+const NIP46_PENDING_RESPONSES_NO_UPDATE_SHA256: [u8; 32] = [
+ 0x7a, 0xfa, 0xc0, 0xb6, 0x19, 0x61, 0x2d, 0xf8, 0xfe, 0xab, 0xe0, 0x27, 0xa8, 0x18, 0x82, 0x7b,
+ 0xa6, 0x5c, 0x84, 0xed, 0x11, 0x1f, 0x2d, 0x7e, 0xf3, 0xee, 0xdd, 0x3b, 0xaa, 0x3c, 0x7d, 0x4e,
+];
+const NIP46_PENDING_RESPONSES_NO_DELETE_SHA256: [u8; 32] = [
+ 0xa2, 0x95, 0x91, 0x8a, 0x52, 0x2e, 0xfb, 0xf3, 0xbf, 0x94, 0x73, 0xeb, 0x58, 0x60, 0x67, 0xad,
+ 0x4d, 0x28, 0x64, 0x2f, 0xbb, 0x02, 0xca, 0xf3, 0xdf, 0x2f, 0x2a, 0x02, 0xfd, 0xe1, 0x5d, 0x1b,
+];
+
const MYC_CONFIG_BINDINGS_TABLE_SHA256: [u8; 32] = [
0xf7, 0x7a, 0x0b, 0xc4, 0x4a, 0xb0, 0xf9, 0x18, 0x09, 0xed, 0x6a, 0xb1, 0xaa, 0x0c, 0x9e, 0xd8,
0x80, 0x4c, 0xac, 0x8f, 0x12, 0x15, 0xf1, 0x15, 0xd5, 0x7e, 0x1b, 0x42, 0xe4, 0x20, 0xf2, 0x60,
@@ -2286,6 +2434,13 @@ pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError>
MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256),
)
.map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?;
+ let pending_responses = MigrationDescriptor::sql(
+ 12,
+ "create_nip46_pending_response_authority",
+ CREATE_NIP46_PENDING_RESPONSE_MIGRATION_SQL,
+ MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_12_MIGRATION_SHA256),
+ )
+ .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?;
let catalog = MigrationCatalog::new([
metadata,
requests,
@@ -2297,10 +2452,11 @@ pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError>
response,
configuration,
admin_operations,
+ pending_responses,
])
.map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?;
if catalog.current_version() != MYC_STATE_SCHEMA_VERSION
- || catalog.descriptors().len() != 10
+ || catalog.descriptors().len() != 11
|| catalog.digest().as_bytes() != &MYC_MIGRATION_CATALOG_SHA256
{
return Err(MycStateCatalogError::new(
@@ -2379,6 +2535,12 @@ pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> {
SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_11_SHA256),
)
.map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?;
+ let version_twelve = SchemaVersionCatalog::new(
+ 12,
+ myc_state_pending_response_objects()?,
+ SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_12_SHA256),
+ )
+ .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?;
let catalog = SchemaCatalog::new(
&migrations,
[
@@ -2393,6 +2555,7 @@ pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> {
version_nine,
version_ten,
version_eleven,
+ version_twelve,
],
)
.map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?;
@@ -3018,6 +3181,52 @@ fn myc_state_admin_operation_objects() -> Result<Vec<SchemaObject>, MycStateCata
Ok(objects)
}
+fn myc_state_pending_response_objects() -> Result<Vec<SchemaObject>, MycStateCatalogError> {
+ let mut objects = myc_state_admin_operation_objects()?;
+ let object = |kind, name, table, sql, digest| {
+ SchemaObject::new(kind, name, table, sql, SchemaDigest::from_bytes(digest))
+ .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))
+ };
+ objects.extend([
+ object(
+ SchemaObjectKind::Table,
+ "nip46_pending_responses",
+ "nip46_pending_responses",
+ CREATE_NIP46_PENDING_RESPONSES_TABLE_SQL,
+ NIP46_PENDING_RESPONSES_TABLE_SHA256,
+ )?,
+ object(
+ SchemaObjectKind::Trigger,
+ "nip46_pending_responses_guard_insert",
+ "nip46_pending_responses",
+ CREATE_NIP46_PENDING_RESPONSES_GUARD_INSERT_SQL,
+ NIP46_PENDING_RESPONSES_GUARD_INSERT_SHA256,
+ )?,
+ object(
+ SchemaObjectKind::Trigger,
+ "nip46_pending_responses_no_delete",
+ "nip46_pending_responses",
+ CREATE_NIP46_PENDING_RESPONSES_NO_DELETE_SQL,
+ NIP46_PENDING_RESPONSES_NO_DELETE_SHA256,
+ )?,
+ object(
+ SchemaObjectKind::Trigger,
+ "nip46_pending_responses_no_update",
+ "nip46_pending_responses",
+ CREATE_NIP46_PENDING_RESPONSES_NO_UPDATE_SQL,
+ NIP46_PENDING_RESPONSES_NO_UPDATE_SHA256,
+ )?,
+ object(
+ SchemaObjectKind::Trigger,
+ "nip46_signed_responses_guard_pending_insert",
+ "nip46_signed_responses",
+ CREATE_NIP46_SIGNED_RESPONSES_GUARD_PENDING_INSERT_SQL,
+ NIP46_SIGNED_RESPONSES_GUARD_PENDING_INSERT_SHA256,
+ )?,
+ ]);
+ Ok(objects)
+}
+
/// Independently validates exact catalog versions, counts, and digests.
pub fn validate_myc_state_catalogs(
migrations: &MigrationCatalog,
@@ -3026,7 +3235,7 @@ pub fn validate_myc_state_catalogs(
let versions = schema.versions();
let descriptors = migrations.descriptors();
let valid = migrations.current_version() == MYC_STATE_SCHEMA_VERSION
- && descriptors.len() == 10
+ && descriptors.len() == 11
&& descriptors[0].target_version() == 2
&& descriptors[0].name().as_str() == "create_myc_state_metadata"
&& descriptors[0].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256
@@ -3057,9 +3266,12 @@ pub fn validate_myc_state_catalogs(
&& descriptors[9].target_version() == 11
&& descriptors[9].name().as_str() == "create_admin_operation_journal"
&& descriptors[9].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256
+ && descriptors[10].target_version() == 12
+ && descriptors[10].name().as_str() == "create_nip46_pending_response_authority"
+ && descriptors[10].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_12_MIGRATION_SHA256
&& migrations.digest().as_bytes() == &MYC_MIGRATION_CATALOG_SHA256
&& schema.migration_catalog_digest() == migrations.digest()
- && versions.len() == 11
+ && versions.len() == 12
&& versions[0].version() == MYC_STATE_BASE_SCHEMA_VERSION
&& versions[0].object_count() == MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT
&& versions[0].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_1_SHA256
@@ -3093,6 +3305,9 @@ pub fn validate_myc_state_catalogs(
&& versions[10].version() == 11
&& versions[10].object_count() == MYC_STATE_SCHEMA_VERSION_11_OBJECT_COUNT
&& versions[10].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_11_SHA256
+ && versions[11].version() == 12
+ && versions[11].object_count() == MYC_STATE_SCHEMA_VERSION_12_OBJECT_COUNT
+ && versions[11].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_12_SHA256
&& schema.digest().as_bytes() == &MYC_STATE_SCHEMA_CATALOG_SHA256;
if valid {
Ok(())
diff --git a/src/state_host.rs b/src/state_host.rs
@@ -488,23 +488,24 @@ fn require_migration_build(
fn exact_initialization_outcome(outcome: MigrationApplicationOutcome) -> bool {
outcome.initial_version() == MYC_STATE_BASE_SCHEMA_VERSION
&& outcome.final_version() == MYC_STATE_SCHEMA_VERSION
- && outcome.applied_count() == 10
+ && outcome.applied_count() == 11
}
fn exact_existing_outcome(outcome: MigrationApplicationOutcome) -> bool {
outcome.final_version() == MYC_STATE_SCHEMA_VERSION
&& matches!(
(outcome.initial_version(), outcome.applied_count()),
- (MYC_STATE_BASE_SCHEMA_VERSION, 10)
- | (2, 9)
- | (3, 8)
- | (4, 7)
- | (5, 6)
- | (6, 5)
- | (7, 4)
- | (8, 3)
- | (9, 2)
- | (10, 1)
+ (MYC_STATE_BASE_SCHEMA_VERSION, 11)
+ | (2, 10)
+ | (3, 9)
+ | (4, 8)
+ | (5, 7)
+ | (6, 6)
+ | (7, 5)
+ | (8, 4)
+ | (9, 3)
+ | (10, 2)
+ | (11, 1)
| (MYC_STATE_SCHEMA_VERSION, 0)
)
}
diff --git a/src/state_response.rs b/src/state_response.rs
@@ -23,8 +23,10 @@ use crate::state_repository::{
RepositoryOperationError, require_expected_metadata,
};
use crate::{
- MYC_PROVIDER_OUTPUT_MAX_BYTES, MycProviderCapability, MycProviderOperation, MycProviderRole,
- MycSignerOperationId, MycVerifiedProviderResponse,
+ MYC_PROVIDER_OUTPUT_MAX_BYTES, MycConnectionDecision, MycConnectionDecisionRecord,
+ MycConnectionId, MycConnectionPolicyGeneration, MycConnectionStatus, MycNip46ClientPublicKey,
+ MycNip46Work, MycNip46WorkKind, MycProviderCapability, MycProviderOperation, MycProviderRole,
+ MycSignerOperationId, MycSignerRequestMethod, MycVerifiedProviderResponse,
};
const NIP46_RPC_KIND: u16 = 24_133;
@@ -34,7 +36,27 @@ const INSERT_RESPONSE_SQL: &str = r#"INSERT INTO nip46_signed_responses (
response_sha256, response_bytes, authored_at_unix_s, committed_at_unix_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)"#;
-const READ_RESPONSE_BY_OPERATION_SQL: &str = r#"SELECT
+const INSERT_PENDING_RESPONSE_SQL: &str = r#"INSERT INTO nip46_pending_responses (
+ operation_id, connection_id, response_kind, response_provider_operation_id,
+ response_event_id, response_sha256, response_bytes, authored_at_unix_s,
+ committed_at_unix_ms
+) VALUES (?, ?, 'pending_approval', ?, ?, ?, ?, ?, ?)"#;
+
+const READ_RESPONSE_BY_OPERATION_SQL: &str = r#"WITH response_authority AS (
+ SELECT 'terminal' AS authority_kind, operation_id,
+ response_provider_operation_id, response_event_id, response_sha256,
+ response_bytes, authored_at_unix_s, committed_at_unix_ms
+ FROM nip46_signed_responses
+ UNION ALL
+ SELECT response_kind AS authority_kind, operation_id,
+ response_provider_operation_id, response_event_id, response_sha256,
+ response_bytes, authored_at_unix_s, committed_at_unix_ms
+ FROM nip46_pending_responses
+)
+SELECT
+ CASE WHEN typeof(r.authority_kind) = 'text'
+ AND length(CAST(r.authority_kind AS BLOB)) <= 16
+ THEN r.authority_kind ELSE NULL END AS authority_kind,
CASE WHEN typeof(r.operation_id) = 'blob' AND length(r.operation_id) = 32
THEN r.operation_id ELSE NULL END AS operation_id,
CASE WHEN typeof(r.response_provider_operation_id) = 'blob'
@@ -53,14 +75,28 @@ const READ_RESPONSE_BY_OPERATION_SQL: &str = r#"SELECT
THEN q.client_public_key ELSE NULL END AS client_public_key,
CASE WHEN typeof(j.job_id) = 'blob' AND length(j.job_id) = 32
THEN j.job_id ELSE NULL END AS job_id
-FROM nip46_signed_responses r
+FROM response_authority r
JOIN nip46_requests q ON q.operation_id = r.operation_id
JOIN delivery_jobs j ON j.source_kind = 'signer_response'
AND j.source_id = r.operation_id
WHERE r.operation_id = ?
LIMIT 2"#;
-const READ_RESPONSE_BY_JOB_SQL: &str = r#"SELECT
+const READ_RESPONSE_BY_JOB_SQL: &str = r#"WITH response_authority AS (
+ SELECT 'terminal' AS authority_kind, operation_id,
+ response_provider_operation_id, response_event_id, response_sha256,
+ response_bytes, authored_at_unix_s, committed_at_unix_ms
+ FROM nip46_signed_responses
+ UNION ALL
+ SELECT response_kind AS authority_kind, operation_id,
+ response_provider_operation_id, response_event_id, response_sha256,
+ response_bytes, authored_at_unix_s, committed_at_unix_ms
+ FROM nip46_pending_responses
+)
+SELECT
+ CASE WHEN typeof(r.authority_kind) = 'text'
+ AND length(CAST(r.authority_kind AS BLOB)) <= 16
+ THEN r.authority_kind ELSE NULL END AS authority_kind,
CASE WHEN typeof(r.operation_id) = 'blob' AND length(r.operation_id) = 32
THEN r.operation_id ELSE NULL END AS operation_id,
CASE WHEN typeof(r.response_provider_operation_id) = 'blob'
@@ -80,11 +116,38 @@ const READ_RESPONSE_BY_JOB_SQL: &str = r#"SELECT
CASE WHEN typeof(j.job_id) = 'blob' AND length(j.job_id) = 32
THEN j.job_id ELSE NULL END AS job_id
FROM delivery_jobs j
-JOIN nip46_signed_responses r ON r.operation_id = j.source_id
+JOIN response_authority r ON r.operation_id = j.source_id
JOIN nip46_requests q ON q.operation_id = r.operation_id
WHERE j.job_id = ? AND j.source_kind = 'signer_response'
LIMIT 2"#;
+const READ_PENDING_BINDING_SQL: &str = r#"SELECT
+ CASE WHEN typeof(decision.connection_id) = 'blob'
+ AND length(decision.connection_id) = 32
+ THEN decision.connection_id ELSE NULL END AS connection_id,
+ decision.policy_generation, decision.decided_at_unix_ms,
+ CASE WHEN typeof(decision.decision) = 'text'
+ AND length(CAST(decision.decision AS BLOB)) <= 32
+ THEN decision.decision ELSE NULL END AS decision,
+ CASE WHEN typeof(decision.reason_code) = 'text'
+ AND length(CAST(decision.reason_code AS BLOB)) <= 32
+ THEN decision.reason_code ELSE NULL END AS reason_code,
+ CASE WHEN typeof(connection.status) = 'text'
+ AND length(CAST(connection.status AS BLOB)) <= 16
+ THEN connection.status ELSE NULL END AS connection_status,
+ CASE WHEN typeof(connection.client_public_key) = 'text'
+ AND length(CAST(connection.client_public_key AS BLOB)) = 64
+ THEN connection.client_public_key ELSE NULL END AS client_public_key
+FROM nip46_request_decisions AS decision
+JOIN connections AS connection ON connection.connection_id = decision.connection_id
+JOIN nip46_requests AS request ON request.operation_id = decision.operation_id
+WHERE decision.operation_id = ?
+ AND connection.client_public_key = request.client_public_key
+ AND connection.policy_generation = decision.policy_generation
+ AND connection.requested_permissions_sha256 = decision.requested_permissions_sha256
+ AND request.method = 'connect'
+LIMIT 2"#;
+
/// Stable construction failure classes for an atomic NIP-46 response commit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MycNip46ResponseCommitErrorKind {
@@ -257,9 +320,125 @@ impl fmt::Debug for MycNip46ResponseCommitRequest {
}
}
+#[derive(Clone)]
+pub(crate) struct MycNip46PendingResponseCommitRequest {
+ operation_id: MycSignerOperationId,
+ connection_id: MycConnectionId,
+ policy_generation: MycConnectionPolicyGeneration,
+ client_public_key: MycNip46ClientPublicKey,
+ decided_at_unix_ms: u64,
+ response_provider_operation_id: [u8; 32],
+ response_event_id: [u8; 32],
+ response_digest: MycDeliveryArtifactDigest,
+ response_bytes: Box<[u8]>,
+ authored_at_unix_s: u64,
+ committed_at: MycDeliveryTimeUnixMs,
+ #[cfg(test)]
+ fail_after_response: bool,
+}
+
+impl MycNip46PendingResponseCommitRequest {
+ pub(crate) fn new(
+ work: &MycNip46Work,
+ decision: &MycConnectionDecisionRecord,
+ response_operation: &MycProviderOperation,
+ response: &MycVerifiedProviderResponse,
+ committed_at: MycDeliveryTimeUnixMs,
+ ) -> Result<Self, MycNip46ResponseCommitError> {
+ let connection = decision.connection().ok_or_else(|| {
+ MycNip46ResponseCommitRequest::error(MycNip46ResponseCommitErrorKind::InvalidBinding)
+ })?;
+ if work.kind() != MycNip46WorkKind::Connect
+ || work.method() != MycSignerRequestMethod::Connect
+ || decision.operation_id() != work.request_record().operation_id()
+ || decision.decision() != MycConnectionDecision::PendingApproval
+ || decision.policy_generation() != connection.policy_generation()
+ || connection.status() != MycConnectionStatus::Pending
+ || connection.client_public_key() != work.request_record().client_public_key()
+ || response_operation.role() != MycProviderRole::User
+ || response_operation.input().capability() != MycProviderCapability::SignEvent
+ || response.operation_id() != response_operation.operation_id()
+ || response.correlation_id() != response_operation.correlation_id()
+ || response.instance() != response_operation.instance()
+ || response.role() != response_operation.role()
+ || response.capability() != response_operation.input().capability()
+ || !response.matches_operation(response_operation)
+ || response_operation.operation_id().as_bytes()
+ == work.request_record().operation_id().as_bytes()
+ {
+ return Err(MycNip46ResponseCommitRequest::error(
+ MycNip46ResponseCommitErrorKind::InvalidBinding,
+ ));
+ }
+ let bytes = response.signed_event_bytes().ok_or_else(|| {
+ MycNip46ResponseCommitRequest::error(MycNip46ResponseCommitErrorKind::InvalidResponse)
+ })?;
+ let event = validate_response_event(
+ bytes,
+ work.request_record().client_public_key().as_hex(),
+ Some(response_operation.expected_identity().as_hex()),
+ )?;
+ let authored_at_unix_s = event.created_at.as_secs();
+ if committed_at.get() < decision.decided_at().get()
+ || authored_at_unix_s
+ .checked_mul(1_000)
+ .is_none_or(|authored_ms| authored_ms > committed_at.get())
+ {
+ return Err(MycNip46ResponseCommitRequest::error(
+ MycNip46ResponseCommitErrorKind::InvalidTime,
+ ));
+ }
+ Ok(Self {
+ operation_id: work.request_record().operation_id(),
+ connection_id: connection.id(),
+ policy_generation: connection.policy_generation(),
+ client_public_key: connection.client_public_key().clone(),
+ decided_at_unix_ms: decision.decided_at().get(),
+ response_provider_operation_id: *response_operation.operation_id().as_bytes(),
+ response_event_id: *event.id.as_bytes(),
+ response_digest: MycDeliveryArtifactDigest::from_bytes(Sha256::digest(bytes).into()),
+ response_bytes: Box::from(bytes),
+ authored_at_unix_s,
+ committed_at,
+ #[cfg(test)]
+ fail_after_response: false,
+ })
+ }
+
+ #[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
+ pub(crate) fn fail_after_response_for_test(&self) -> Self {
+ let mut request = self.clone();
+ request.fail_after_response = true;
+ request
+ }
+}
+
+impl fmt::Debug for MycNip46PendingResponseCommitRequest {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("MycNip46PendingResponseCommitRequest([redacted])")
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum ResponseAuthorityKind {
+ Terminal,
+ PendingApproval,
+}
+
+impl ResponseAuthorityKind {
+ fn parse(value: &str) -> Option<Self> {
+ match value {
+ "terminal" => Some(Self::Terminal),
+ "pending_approval" => Some(Self::PendingApproval),
+ _ => None,
+ }
+ }
+}
+
/// Immutable exact signed response and its config-bound initial delivery state.
#[derive(Clone, PartialEq, Eq)]
pub struct MycNip46ResponseRecord {
+ authority_kind: ResponseAuthorityKind,
operation_id: MycSignerOperationId,
response_provider_operation_id: [u8; 32],
response_event_id: [u8; 32],
@@ -445,6 +624,54 @@ impl MycStateRepository<'_> {
.map_err(map_transaction_error)
}
+ pub(crate) async fn commit_nip46_pending_response(
+ &self,
+ request: &MycNip46PendingResponseCommitRequest,
+ ) -> Result<MycNip46ResponseRecord, MycStateRepositoryError> {
+ let expected = PersistedMetadata::from(self.expected());
+ let policy = self.expected().delivery_policies().clone();
+ let request = request.clone();
+ self.host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ require_expected_metadata(transaction, &expected)
+ .await
+ .map_err(AtomicOperationError::from)?;
+ require_pending_binding(transaction, &request).await?;
+ if let Some(response) =
+ read_response_by_operation(transaction, request.operation_id).await?
+ {
+ exact_pending_response(&response, &request)?;
+ return Ok(response);
+ }
+ insert_pending_response(transaction, &request).await?;
+ #[cfg(test)]
+ if request.fail_after_response {
+ return Err(AtomicOperationError::Storage);
+ }
+ let delivery = create_job(
+ transaction,
+ MycDeliverySource::signer_response(request.operation_id),
+ request.response_digest,
+ request.committed_at,
+ &policy,
+ )
+ .await
+ .map_err(AtomicOperationError::from)?;
+ if !matches!(delivery, MycDeliveryJobAdmission::Created(_)) {
+ return Err(AtomicOperationError::Binding);
+ }
+ let response = read_response_by_operation(transaction, request.operation_id)
+ .await?
+ .ok_or(AtomicOperationError::Binding)?;
+ exact_pending_response(&response, &request)?;
+ Ok(response)
+ })
+ })
+ .await
+ .map_err(map_transaction_error)
+ }
+
/// Reads the exact committed response bytes for a retained delivery job.
pub async fn read_nip46_response(
&self,
@@ -520,6 +747,57 @@ impl From<DeliveryOperationError> for AtomicOperationError {
}
}
+async fn require_pending_binding(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ request: &MycNip46PendingResponseCommitRequest,
+) -> Result<(), AtomicOperationError> {
+ let rows = sqlx::query(READ_PENDING_BINDING_SQL)
+ .bind(request.operation_id.as_bytes().as_slice())
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| AtomicOperationError::Storage)?;
+ if rows.len() != 1 {
+ return Err(AtomicOperationError::Binding);
+ }
+ let row = &rows[0];
+ let connection_id = MycConnectionId::from_bytes(blob32(row, "connection_id")?);
+ let policy_generation = positive_i64(row, "policy_generation")?;
+ let decided_at_unix_ms = positive_i64(row, "decided_at_unix_ms")?;
+ let decision = bounded_text(row, "decision", 32)?;
+ let reason_code = bounded_text(row, "reason_code", 32)?;
+ let connection_status = bounded_text(row, "connection_status", 16)?;
+ let client_public_key = bounded_text(row, "client_public_key", 64)?;
+ let valid = connection_id == request.connection_id
+ && policy_generation == request.policy_generation.get()
+ && decided_at_unix_ms == request.decided_at_unix_ms
+ && decision == "pending_approval"
+ && reason_code == "explicit_approval_required"
+ && connection_status == "pending"
+ && client_public_key == request.client_public_key.as_hex();
+ valid.then_some(()).ok_or(AtomicOperationError::Binding)
+}
+
+async fn insert_pending_response(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ request: &MycNip46PendingResponseCommitRequest,
+) -> Result<(), AtomicOperationError> {
+ let result = sqlx::query(INSERT_PENDING_RESPONSE_SQL)
+ .bind(request.operation_id.as_bytes().as_slice())
+ .bind(request.connection_id.as_bytes().as_slice())
+ .bind(request.response_provider_operation_id.as_slice())
+ .bind(request.response_event_id.as_slice())
+ .bind(request.response_digest.as_bytes().as_slice())
+ .bind(request.response_bytes.as_ref())
+ .bind(to_i64(request.authored_at_unix_s)?)
+ .bind(to_i64(request.committed_at.get())?)
+ .execute(&mut *transaction)
+ .await
+ .map_err(|_| AtomicOperationError::Storage)?;
+ (result.rows_affected() == 1)
+ .then_some(())
+ .ok_or(AtomicOperationError::Storage)
+}
+
async fn insert_response(
transaction: &mut ServiceSqliteTransaction<'_>,
request: &MycNip46ResponseCommitRequest,
@@ -596,6 +874,8 @@ async fn read_response(
let Some(row) = rows.first() else {
return Ok(None);
};
+ let authority_kind = ResponseAuthorityKind::parse(bounded_text(row, "authority_kind", 16)?)
+ .ok_or(AtomicOperationError::Binding)?;
let operation_id = MycSignerOperationId::from_persisted(blob32(row, "operation_id")?);
let response_provider_operation_id = blob32(row, "response_provider_operation_id")?;
let response_event_id = blob32(row, "response_event_id")?;
@@ -633,6 +913,7 @@ async fn read_response(
return Err(AtomicOperationError::Binding);
}
Ok(Some(MycNip46ResponseRecord {
+ authority_kind,
operation_id,
response_provider_operation_id,
response_event_id,
@@ -648,7 +929,24 @@ fn exact_response(
response: &MycNip46ResponseRecord,
request: &MycNip46ResponseCommitRequest,
) -> Result<(), AtomicOperationError> {
- (response.operation_id == request.completion.signer_request().operation_id()
+ (response.authority_kind == ResponseAuthorityKind::Terminal
+ && response.operation_id == request.completion.signer_request().operation_id()
+ && response.response_provider_operation_id == request.response_provider_operation_id
+ && response.response_event_id == request.response_event_id
+ && response.response_digest == request.response_digest
+ && response.response_bytes.as_ref() == request.response_bytes.as_ref()
+ && response.authored_at_unix_s == request.authored_at_unix_s
+ && response.committed_at == request.committed_at)
+ .then_some(())
+ .ok_or(AtomicOperationError::Binding)
+}
+
+fn exact_pending_response(
+ response: &MycNip46ResponseRecord,
+ request: &MycNip46PendingResponseCommitRequest,
+) -> Result<(), AtomicOperationError> {
+ (response.authority_kind == ResponseAuthorityKind::PendingApproval
+ && response.operation_id == request.operation_id
&& response.response_provider_operation_id == request.response_provider_operation_id
&& response.response_event_id == request.response_event_id
&& response.response_digest == request.response_digest
@@ -704,6 +1002,17 @@ fn blob32(row: &sqlx::sqlite::SqliteRow, column: &str) -> Result<[u8; 32], Atomi
.map_err(|_| AtomicOperationError::Binding)
}
+fn bounded_text<'row>(
+ row: &'row sqlx::sqlite::SqliteRow,
+ column: &str,
+ maximum_bytes: usize,
+) -> Result<&'row str, AtomicOperationError> {
+ row.try_get::<Option<&str>, _>(column)
+ .map_err(|_| AtomicOperationError::Binding)?
+ .filter(|value| !value.is_empty() && value.len() <= maximum_bytes)
+ .ok_or(AtomicOperationError::Binding)
+}
+
fn positive_i64(row: &sqlx::sqlite::SqliteRow, column: &str) -> Result<u64, AtomicOperationError> {
row.try_get::<i64, _>(column)
.map_err(|_| AtomicOperationError::Binding)
diff --git a/src/status_v1.rs b/src/status_v1.rs
@@ -186,8 +186,9 @@ impl MycStatusBuildInfoV1 {
target: Option<&str>,
feature_profile: Option<&str>,
) -> Result<Self, MycStatusError> {
- let contract_versions = HostContractVersions::new(1, 9, 1, 1, 1)
- .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidBuildInfo))?;
+ let contract_versions =
+ HostContractVersions::new(1, crate::MYC_STATE_SCHEMA_VERSION, 1, 1, 1)
+ .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidBuildInfo))?;
HostBuildInfo::from_compile_time(
match mode {
MycStatusBuildMode::Development => HostBuildMode::Development,
diff --git a/tests/build_policy.rs b/tests/build_policy.rs
@@ -113,6 +113,6 @@ fn source_lock_binds_the_current_cargo_lock() {
"source_archive_sha256 = \"b425371c134be96cce46b37f7035d6212f1efe8cff50bef366631ba5632991b0\""
));
assert!(SOURCE_LOCK.ends_with(
- "[contract_versions]\nconfig = 1\nstate = 11\nadmin = 1\nstatus = 1\nprovider = 1\n"
+ "[contract_versions]\nconfig = 1\nstate = 12\nadmin = 1\nstatus = 1\nprovider = 1\n"
));
}
diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs
@@ -15,6 +15,7 @@ const NIP46_WORK: &str = include_str!("../src/nip46_work.rs");
const NIP46_WAVE_080_A: &str = include_str!("../src/nip46_wave_080_a.rs");
const NIP46_COMPLETION: &str = include_str!("../src/state_completion.rs");
const NIP46_RESPONSE: &str = include_str!("../src/state_response.rs");
+const STATE_CATALOG: &str = include_str!("../src/state_catalog.rs");
const DELIVERY_RECOVERY: &str = include_str!("../src/state_recovery.rs");
const DELIVERY_WORKER: &str = include_str!("../src/delivery_worker.rs");
const PROVIDER_EXECUTOR: &str = include_str!("../src/provider_executor.rs");
@@ -59,6 +60,8 @@ const NIP46_COMPLETION_CONTRACT: &str =
include_str!("../contracts/services_hardening/nip46_completion.v1.json");
const NIP46_RESPONSE_CONTRACT: &str =
include_str!("../contracts/services_hardening/nip46_response_commit.v1.json");
+const NIP46_PENDING_RESPONSE_CONTRACT: &str =
+ include_str!("../contracts/services_hardening/nip46_pending_response.v1.json");
const DELIVERY_RECOVERY_EXPORT_CONTRACT: &str =
include_str!("../contracts/services_hardening/delivery_recovery_export.v1.json");
const PROCESS_QUALIFICATION_CONTRACT: &str =
@@ -190,6 +193,9 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() {
"caps a\nreplayed response model at 8,192 bytes",
"admits at least 8,382 UTF-8 bytes",
"The journal stores no request body, path,\ncorrelation ID, credential, bundle path, or secret",
+ "Schema v12 adds immutable response authority for a connect request awaiting\nexplicit approval",
+ "without recording a false terminal operation completion",
+ "Exact\nreplay and delivery use only the retained signed bytes",
"The Step 159 provider and delivery boundary is sealed inside the crate",
"persists Submitted immediately before execution",
"The selected absolute config path is opened no-follow through its retained\nparent descriptor",
@@ -256,7 +262,7 @@ fn step159_runtime_graph_is_fixed_joined_and_binary_signal_owned() {
}
assert_eq!(RUNTIME_GRAPH.matches(".spawn(").count(), 5);
assert!(RUNTIME_NIP46.contains("commit_nip46_response(&commit)"));
- assert!(RUNTIME_NIP46.contains("ExactCompletedReplay"));
+ assert!(RUNTIME_NIP46.contains("ExactResponseReplay"));
assert!(RUNTIME_NIP46.contains("admission_evidence: MycRuntimeNip46AdmissionEvidence"));
assert!(RUNTIME_SIGNAL.contains("pub trait MycProcessSignalSource: Send"));
for forbidden in [
@@ -379,6 +385,9 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
"pub enum myc::MycNip46ResponseCommitErrorKind",
"pub async fn myc::MycStateRepository<'_>::commit_nip46_response",
"pub async fn myc::MycStateRepository<'_>::read_nip46_response",
+ "pub const myc::MYC_STATE_SCHEMA_VERSION_12_MIGRATION_SHA256: [u8; 32]",
+ "pub const myc::MYC_STATE_SCHEMA_VERSION_12_OBJECT_COUNT: u32",
+ "pub const myc::MYC_STATE_SCHEMA_VERSION_12_SHA256: [u8; 32]",
"pub async fn myc::MycStateRepository<'_>::recover_delivery_state",
"pub async fn myc::MycStateRepository<'_>::render_offline_nip05",
"pub struct myc::MycDeliveryRecoveryEntropy",
@@ -852,6 +861,45 @@ fn step148_response_commit_is_one_atomic_exact_byte_authority() {
}
#[test]
+fn step221_pending_response_is_atomic_exact_and_nonterminal() {
+ let contract: serde_json::Value = serde_json::from_str(NIP46_PENDING_RESPONSE_CONTRACT)
+ .expect("Step 221 pending-response contract");
+ assert_eq!(contract["schema"], "radroots.myc.nip46-pending-response.v1");
+ assert_eq!(contract["contract_version"], 1);
+ assert_eq!(contract["step"], 221);
+ assert_eq!(contract["state_schema_version"], 12);
+ assert_eq!(contract["terminal_effects"]["operation_completion"], false);
+ assert_eq!(contract["terminal_effects"]["session_activation"], false);
+ for required in [
+ "immutable_explicit_approval_pending_decision",
+ "exact_committed_pending_response_bytes",
+ "response_edge_failure_rolls_back_response_and_delivery",
+ "no_terminal_operation_commit_is_created",
+ "live_nip46_client_observes_pending_then_continues_after_admin_approval",
+ "false_terminal_operation_completion",
+ ] {
+ assert!(
+ NIP46_PENDING_RESPONSE_CONTRACT.contains(required),
+ "Step 221 contract is missing `{required}`"
+ );
+ }
+ for required in [
+ "commit_nip46_pending_response(&commit)",
+ "Response::PendingConnection",
+ "MycNip46DispatchDisposition::PendingApproval",
+ ] {
+ assert!(RUNTIME_NIP46.contains(required), "missing `{required}`");
+ }
+ for required in [
+ "CREATE TABLE nip46_pending_responses",
+ "nip46_signed_responses_guard_pending_insert",
+ "fail_after_response_for_test",
+ ] {
+ assert!(NIP46_RESPONSE.contains(required) || STATE_CATALOG.contains(required));
+ }
+}
+
+#[test]
fn step147_completion_is_atomic_redacted_and_defers_delivery_authority() {
let contract: serde_json::Value =
serde_json::from_str(NIP46_COMPLETION_CONTRACT).expect("Step 147 contract");
diff --git a/tests/services_hardening_config_lifecycle.rs b/tests/services_hardening_config_lifecycle.rs
@@ -525,7 +525,7 @@ async fn v9_upgrade_seeds_one_current_binding_without_rewriting_birth_evidence()
}
#[tokio::test]
-async fn v10_binding_remains_valid_historical_evidence_after_v11_migration() {
+async fn v10_binding_remains_valid_historical_evidence_after_v12_migration() {
let directory = tempfile::tempdir().expect("root");
let runtime = runtime(directory.path());
prepare(&runtime);
@@ -558,7 +558,7 @@ async fn v10_binding_remains_valid_historical_evidence_after_v11_migration() {
.fetch_one(&mut connection)
.await
.expect("shared schema version"),
- 11
+ 12
);
assert_eq!(
sqlx::query_scalar::<_, i64>(
diff --git a/tests/services_hardening_native_release.rs b/tests/services_hardening_native_release.rs
@@ -74,7 +74,7 @@ fn native_release_contract_and_manifest_metadata_are_exact() {
},
"contract_versions": {
"config": 1,
- "state": 11,
+ "state": 12,
"admin": 1,
"status": 1,
"provider": 1
@@ -159,7 +159,7 @@ fn native_release_contract_and_manifest_metadata_are_exact() {
host_feature_profile = "service-host"
nix_material = "deferred"
config_contract_version = 1
- state_contract_version = 11
+ state_contract_version = 12
admin_contract_version = 1
status_contract_version = 1
provider_contract_version = 1
diff --git a/tests/services_hardening_operations.rs b/tests/services_hardening_operations.rs
@@ -5,12 +5,13 @@ use std::net::{Ipv4Addr, SocketAddrV4, TcpListener};
use myc::{
InstanceId, MYC_LIVEZ_PATH, MYC_METRICS_PATH, MYC_OPERATIONS_CONTRACT_VERSION, MYC_READYZ_PATH,
- MycConfigProfile, MycConnectionCountsV1, MycIdentityHealthV1, MycIntegrityStateV1,
- MycOperationsCancellationToken, MycOperationsErrorKind, MycOperationsServer, MycOutboxStatusV1,
- MycPersistenceHealthV1, MycPersistenceStatusV1, MycProviderStatusV1, MycRelayTransportStatusV1,
- MycServicePhase, MycStatusBuildInfoV1, MycStatusBuildMode, MycStatusCommonV1,
- MycStatusConfigurationIdentityV1, MycStatusConfigurationSource, MycStatusObservationV1,
- MycStatusReasonCodes, MycTransportHealthV1, myc_status_cache, parse_myc_config_v1,
+ MYC_STATE_SCHEMA_VERSION, MycConfigProfile, MycConnectionCountsV1, MycIdentityHealthV1,
+ MycIntegrityStateV1, MycOperationsCancellationToken, MycOperationsErrorKind,
+ MycOperationsServer, MycOutboxStatusV1, MycPersistenceHealthV1, MycPersistenceStatusV1,
+ MycProviderStatusV1, MycRelayTransportStatusV1, MycServicePhase, MycStatusBuildInfoV1,
+ MycStatusBuildMode, MycStatusCommonV1, MycStatusConfigurationIdentityV1,
+ MycStatusConfigurationSource, MycStatusObservationV1, MycStatusReasonCodes,
+ MycTransportHealthV1, myc_status_cache, parse_myc_config_v1,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
@@ -46,7 +47,7 @@ fn observation(phase: MycServicePhase, ready: bool) -> MycStatusObservationV1 {
.expect("configuration");
let persistence = MycPersistenceStatusV1::new(
MycPersistenceHealthV1::Ready,
- 9,
+ MYC_STATE_SCHEMA_VERSION,
42,
MycIntegrityStateV1::Verified,
MycStatusReasonCodes::empty(),
diff --git a/tests/services_hardening_process.rs b/tests/services_hardening_process.rs
@@ -139,7 +139,7 @@ fn binary_executes_config_state_backup_restore_and_doctor_boundaries() {
let status_value: serde_json::Value =
serde_json::from_slice(&status.stdout).expect("state status JSON");
assert_eq!(status_value["generation"], 1);
- assert_eq!(status_value["schema_version"], 11);
+ assert_eq!(status_value["schema_version"], 12);
assert_eq!(status_value["integrity"], "verified");
let service_status = fixture.run(&["status"]);
diff --git a/tests/services_hardening_signer_request_state.rs b/tests/services_hardening_signer_request_state.rs
@@ -458,7 +458,7 @@ async fn concurrent_identical_admission_creates_one_request_and_bounded_replay_e
}
#[tokio::test]
-async fn exact_schema_v3_state_advances_to_v11_before_request_admission() {
+async fn exact_schema_v3_state_advances_to_v12_before_request_admission() {
let directory = tempfile::tempdir().expect("temporary root");
let runtime = runtime(directory.path());
prepare_state_directory(&runtime);
@@ -513,6 +513,11 @@ async fn exact_schema_v3_state_advances_to_v11_before_request_admission() {
"DROP TRIGGER connection_permissions_no_update",
"DROP TRIGGER connections_no_delete",
"DROP TRIGGER connections_guard_update",
+ "DROP TRIGGER nip46_pending_responses_no_delete",
+ "DROP TRIGGER nip46_pending_responses_no_update",
+ "DROP TRIGGER nip46_signed_responses_guard_pending_insert",
+ "DROP TRIGGER nip46_pending_responses_guard_insert",
+ "DROP TABLE nip46_pending_responses",
"DROP TRIGGER myc_admin_operations_guard_update",
"DROP TABLE myc_admin_operations",
"DROP TRIGGER myc_config_bindings_no_delete",
@@ -541,7 +546,7 @@ async fn exact_schema_v3_state_advances_to_v11_before_request_admission() {
"DROP TABLE connections",
"UPDATE radroots_service_metadata SET state_schema_version = 3 WHERE singleton = 1",
"UPDATE myc_state_metadata SET state_contract_version = 3 WHERE singleton = 1",
- "DELETE FROM schema_migrations WHERE version IN (4, 5, 6, 7, 8, 9, 10, 11)",
+ "DELETE FROM schema_migrations WHERE version IN (4, 5, 6, 7, 8, 9, 10, 11, 12)",
] {
sqlx::query(sql)
.execute(&mut connection)
diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs
@@ -20,8 +20,10 @@ use myc::{
MYC_STATE_SCHEMA_VERSION_9_SHA256, MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256,
MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_10_SHA256,
MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_11_OBJECT_COUNT,
- MYC_STATE_SCHEMA_VERSION_11_SHA256, MycStateCatalogErrorKind, myc_migration_catalog,
- myc_schema_catalog, validate_myc_state_catalogs,
+ MYC_STATE_SCHEMA_VERSION_11_SHA256, MYC_STATE_SCHEMA_VERSION_12_MIGRATION_SHA256,
+ MYC_STATE_SCHEMA_VERSION_12_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_12_SHA256,
+ MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog,
+ validate_myc_state_catalogs,
};
use radroots_service_sqlite::{
MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaDigest,
@@ -33,13 +35,13 @@ const LIB_SOURCE: &str = include_str!("../src/lib.rs");
const MANIFEST: &str = include_str!("../Cargo.toml");
#[test]
-fn schema_v1_through_v11_and_all_migrations_have_exact_literal_identities() {
+fn schema_v1_through_v12_and_all_migrations_have_exact_literal_identities() {
let migrations = myc_migration_catalog().expect("Myc migration catalog");
let schema = myc_schema_catalog().expect("Myc schema catalog");
assert_eq!(MYC_STATE_BASE_SCHEMA_VERSION, 1);
- assert_eq!(MYC_STATE_SCHEMA_VERSION, 11);
- assert_eq!(migrations.descriptors().len(), 10);
+ assert_eq!(MYC_STATE_SCHEMA_VERSION, 12);
+ assert_eq!(migrations.descriptors().len(), 11);
let metadata = &migrations.descriptors()[0];
assert_eq!(metadata.target_version(), 2);
assert_eq!(metadata.name().as_str(), "create_myc_state_metadata");
@@ -125,13 +127,23 @@ fn schema_v1_through_v11_and_all_migrations_have_exact_literal_identities() {
admin_operations.checksum().as_bytes(),
&MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256
);
- assert_eq!(migrations.current_version(), 11);
+ let pending_responses = &migrations.descriptors()[10];
+ assert_eq!(pending_responses.target_version(), 12);
+ assert_eq!(
+ pending_responses.name().as_str(),
+ "create_nip46_pending_response_authority"
+ );
+ assert_eq!(
+ pending_responses.checksum().as_bytes(),
+ &MYC_STATE_SCHEMA_VERSION_12_MIGRATION_SHA256
+ );
+ assert_eq!(migrations.current_version(), 12);
assert_eq!(
migrations.digest().as_bytes(),
&MYC_MIGRATION_CATALOG_SHA256
);
- assert_eq!(schema.versions().len(), 11);
+ assert_eq!(schema.versions().len(), 12);
assert_eq!(schema.versions()[0].version(), 1);
assert_eq!(
schema.versions()[0].object_count(),
@@ -241,6 +253,16 @@ fn schema_v1_through_v11_and_all_migrations_have_exact_literal_identities() {
schema.versions()[10].digest().as_bytes(),
&MYC_STATE_SCHEMA_VERSION_11_SHA256
);
+ assert_eq!(schema.versions()[11].version(), 12);
+ assert_eq!(
+ schema.versions()[11].object_count(),
+ MYC_STATE_SCHEMA_VERSION_12_OBJECT_COUNT
+ );
+ assert_eq!(schema.versions()[11].object_count(), 70);
+ assert_eq!(
+ schema.versions()[11].digest().as_bytes(),
+ &MYC_STATE_SCHEMA_VERSION_12_SHA256
+ );
assert_eq!(schema.digest().as_bytes(), &MYC_STATE_SCHEMA_CATALOG_SHA256);
assert_eq!(schema.migration_catalog_digest(), migrations.digest());
validate_myc_state_catalogs(&migrations, &schema).expect("exact catalogs");
@@ -251,7 +273,7 @@ fn schema_v1_through_v11_and_all_migrations_have_exact_literal_identities() {
);
assert_eq!(
hex::encode(MYC_MIGRATION_CATALOG_SHA256),
- "1aa70a76b0474f9bb03300f0e86454b2863b457451edd9a2cced97ba31cb8cc1"
+ "b1d6458245e6dfc4661aa6146b8ce8ab55f7c29bf36099d261c07f5f5167561d"
);
assert_eq!(
hex::encode(MYC_STATE_SCHEMA_VERSION_1_SHA256),
@@ -263,7 +285,7 @@ fn schema_v1_through_v11_and_all_migrations_have_exact_literal_identities() {
);
assert_eq!(
hex::encode(MYC_STATE_SCHEMA_CATALOG_SHA256),
- "09ec0c132fbc1a8a46ad3403497893834abf7352005eb6272c7a4528dd5d661a"
+ "b52721d85c1eb8cddc28256c21179a10d5f32bcb33e9d2a6bb8fe49ecbc49a68"
);
assert_eq!(
hex::encode(MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256),
@@ -337,6 +359,14 @@ fn schema_v1_through_v11_and_all_migrations_have_exact_literal_identities() {
hex::encode(MYC_STATE_SCHEMA_VERSION_11_SHA256),
"0de7fe176ea7da6030424addc29b9a91363e88b04dc78ddab480fd0f0af94e5a"
);
+ assert_eq!(
+ hex::encode(MYC_STATE_SCHEMA_VERSION_12_MIGRATION_SHA256),
+ "388ee51de599f37b7bb1956cebd518461f3eb193535ffe6bb9eac2d82bbe3e37"
+ );
+ assert_eq!(
+ hex::encode(MYC_STATE_SCHEMA_VERSION_12_SHA256),
+ "d893a23ba68e463489ad7ef1e6a80ea4e88089381c73decc3243a4ce6f64da8f"
+ );
}
#[test]
@@ -401,10 +431,13 @@ fn independent_validator_rejects_migration_or_schema_drift() {
let v10 = SchemaVersionCatalog::new(10, [object.clone()], v10_digest).expect("schema v10");
let v11_digest =
SchemaVersionCatalog::computed_digest(11, [object.clone()]).expect("schema-v11 digest");
- let v11 = SchemaVersionCatalog::new(11, [object], v11_digest).expect("schema v11");
+ let v11 = SchemaVersionCatalog::new(11, [object.clone()], v11_digest).expect("schema v11");
+ let v12_digest =
+ SchemaVersionCatalog::computed_digest(12, [object.clone()]).expect("schema-v12 digest");
+ let v12 = SchemaVersionCatalog::new(12, [object], v12_digest).expect("schema v12");
let schema = SchemaCatalog::new(
&expected_migrations,
- [v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11],
+ [v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12],
)
.expect("drift schema catalog");
assert_eq!(
diff --git a/tests/services_hardening_state_repository.rs b/tests/services_hardening_state_repository.rs
@@ -120,7 +120,7 @@ async fn initialization_migrates_and_binds_exact_metadata_before_inspection() {
.fetch_all(&mut connection)
.await
.expect("migration rows");
- assert_eq!(migrations.len(), 10);
+ assert_eq!(migrations.len(), 11);
assert_eq!(migrations[0].get::<i64, _>(0), 2);
assert_eq!(
migrations[0].get::<String, _>(1),
@@ -171,6 +171,11 @@ async fn initialization_migrates_and_binds_exact_metadata_before_inspection() {
migrations[9].get::<String, _>(1),
"create_admin_operation_journal"
);
+ assert_eq!(migrations[10].get::<i64, _>(0), 12);
+ assert_eq!(
+ migrations[10].get::<String, _>(1),
+ "create_nip46_pending_response_authority"
+ );
let binding = sqlx::query(
"SELECT normalized_config_sha256, transport_public_key, user_public_key, \
discovery_public_key, config_contract_version, state_contract_version, \
diff --git a/tests/services_hardening_status.rs b/tests/services_hardening_status.rs
@@ -3,13 +3,13 @@
use std::error::Error;
use myc::{
- InstanceId, MYC_DETAILED_STATUS_MAX_UTF8_BYTES, MYC_STATUS_CACHE_CONTRACT_VERSION,
- MycConnectionCountsV1, MycIdentityHealthV1, MycIntegrityStateV1, MycOutboxStatusV1,
- MycPersistenceHealthV1, MycPersistenceStatusV1, MycProviderStatusV1, MycRelayTransportStatusV1,
- MycServicePhase, MycStatusBuildInfoV1, MycStatusBuildMode, MycStatusCommonV1,
- MycStatusConfigurationIdentityV1, MycStatusConfigurationSource, MycStatusErrorKind,
- MycStatusObservationV1, MycStatusReasonCode, MycStatusReasonCodes, MycStatusUnixSeconds,
- MycTransportHealthV1, myc_status_cache,
+ InstanceId, MYC_DETAILED_STATUS_MAX_UTF8_BYTES, MYC_STATE_SCHEMA_VERSION,
+ MYC_STATUS_CACHE_CONTRACT_VERSION, MycConnectionCountsV1, MycIdentityHealthV1,
+ MycIntegrityStateV1, MycOutboxStatusV1, MycPersistenceHealthV1, MycPersistenceStatusV1,
+ MycProviderStatusV1, MycRelayTransportStatusV1, MycServicePhase, MycStatusBuildInfoV1,
+ MycStatusBuildMode, MycStatusCommonV1, MycStatusConfigurationIdentityV1,
+ MycStatusConfigurationSource, MycStatusErrorKind, MycStatusObservationV1, MycStatusReasonCode,
+ MycStatusReasonCodes, MycStatusUnixSeconds, MycTransportHealthV1, myc_status_cache,
};
const CONTRACT: &str = include_str!("../contracts/services_hardening/status_cache.v1.json");
@@ -60,7 +60,7 @@ fn observation(
.expect("configuration");
let persistence = MycPersistenceStatusV1::new(
MycPersistenceHealthV1::Ready,
- 9,
+ MYC_STATE_SCHEMA_VERSION,
42,
MycIntegrityStateV1::Verified,
MycStatusReasonCodes::empty(),
@@ -161,7 +161,7 @@ fn machine_contract_and_canonical_detailed_status_are_exact() {
let wire = std::str::from_utf8(snapshot.detailed_status_json()).expect("status UTF-8");
assert_eq!(
wire,
- r#"{"contract_version":1,"service":"myc","instance":"primary","phase":"ready","ready":true,"uptime_millis":120000,"reason_codes":[],"build_info":{"version":"0.1.0","revision":"0123456789abcdef0123456789abcdef01234567","toolchain":"1.97.1","contract_versions":{"config":1,"state":9,"admin":1,"status":1,"provider":1}},"configuration":{"schema":"radroots.myc.config","schema_version":1,"digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source":"explicit_config"},"persistence":{"health":"ready","schema_version":9,"generation":42,"integrity":"verified","reason_codes":[]},"provider":{"health":"ready","transport":{"configured":true,"available":true,"reason_codes":[]},"user":{"configured":true,"available":true,"reason_codes":[]},"discovery":{"configured":false,"available":false,"reason_codes":[]},"reason_codes":[]},"transport":{"health":"ready","required_relays_ready":true,"connected_relay_count":2,"reason_codes":[]},"myc":{"transport":{"configured":true,"available":true,"reason_codes":[]},"user":{"configured":true,"available":true,"reason_codes":[]},"discovery":{"configured":false,"available":false,"reason_codes":[]},"connection_counts":{"pending":5,"active":3,"denied":1,"expired":2},"outbox":{"pending":4,"unknown":1,"oldest_pending_at_utc":1723456789}}}"#
+ r#"{"contract_version":1,"service":"myc","instance":"primary","phase":"ready","ready":true,"uptime_millis":120000,"reason_codes":[],"build_info":{"version":"0.1.0","revision":"0123456789abcdef0123456789abcdef01234567","toolchain":"1.97.1","contract_versions":{"config":1,"state":12,"admin":1,"status":1,"provider":1}},"configuration":{"schema":"radroots.myc.config","schema_version":1,"digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source":"explicit_config"},"persistence":{"health":"ready","schema_version":12,"generation":42,"integrity":"verified","reason_codes":[]},"provider":{"health":"ready","transport":{"configured":true,"available":true,"reason_codes":[]},"user":{"configured":true,"available":true,"reason_codes":[]},"discovery":{"configured":false,"available":false,"reason_codes":[]},"reason_codes":[]},"transport":{"health":"ready","required_relays_ready":true,"connected_relay_count":2,"reason_codes":[]},"myc":{"transport":{"configured":true,"available":true,"reason_codes":[]},"user":{"configured":true,"available":true,"reason_codes":[]},"discovery":{"configured":false,"available":false,"reason_codes":[]},"connection_counts":{"pending":5,"active":3,"denied":1,"expired":2},"outbox":{"pending":4,"unknown":1,"oldest_pending_at_utc":1723456789}}}"#
);
assert!(wire.len() < MYC_DETAILED_STATUS_MAX_UTF8_BYTES);
for forbidden in [