feat(egress): owner-identity artifact capabilities + boundary stamp (C3)

Extend the durable owner-identity capability registry with ArtifactPolicy
(NIP-AP, P30/P31/P32-C1): a generation-stamped owner-key-derived value that
leaves its producing lease and is applied later. Unlike Session/Bearer,
an artifact owns no side-effecting teardown, so its only revocation is the
exercise-time / application-site generation compare — the transition bump
IS the invalidation (shape (ii), acked by Paul). revoke_durable_capabilities_before
performs no per-entry artifact work; the barrier reaches artifacts via the bump.

The seven commands/identity producers admit a bounded lease before the owner-key
sign/encrypt/decrypt and return the value wrapped as StampedArtifact<T>
({value, artifact:{id,generation}}), stamping the issuing lease's generation
(the F1 lesson). The wire shape is locked on both sides; a serialization test
pins it so C6/C7 inherits a stable contract.

The 7 TS adapters unwrap .value at the boundary with a named C6/C7 deferral —
outward threading to application sites + the generation-compare lands in C6/C7
coupled to the code that reads the stamp (supersedes condition 2 of the
stamp-at-boundary ruling, revised on the measured 87-file transitive radius).
StampedArtifact type and the four identity adapters split into small modules to
respect the desktop file-size ratchet.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-18 02:28:04 -04:00
parent fb488a31f6
commit 5b14931122
11 changed files with 545 additions and 106 deletions

View File

@@ -110,8 +110,13 @@ pub async fn sign_event(
created_at: Option<u64>,
tags: Vec<Vec<String>>,
state: State<'_, AppState>,
) -> Result<String, String> {
) -> Result<crate::owner_identity_egress::StampedArtifact<String>, String> {
let keys = state.signing_keys()?;
// Admit BEFORE signing (the F1 lesson): the sign that derives this artifact
// is a leased owner-identity operation, and the artifact is stamped with
// the lease's generation so a signature produced across a transition fails
// closed at its frontend application site (C6/C7).
let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?;
tauri::async_runtime::spawn_blocking(move || {
let nostr_tags = tags
@@ -128,7 +133,10 @@ pub async fn sign_event(
.sign_with_keys(&keys)
.map_err(|error| format!("sign failed: {error}"))?;
Ok(event.as_json())
// Stamp the issuing lease's generation, then wrap the value; the
// frontend threads {value, artifact} opaque — validated in C6/C7.
let artifact = crate::owner_identity_egress::register_owner_artifact(&lease);
Ok(artifact.stamp_value(event.as_json()))
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
@@ -138,8 +146,11 @@ pub async fn sign_event(
pub async fn decrypt_observer_event(
event_json: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
) -> Result<crate::owner_identity_egress::StampedArtifact<serde_json::Value>, String> {
let keys = state.signing_keys()?;
// Admit BEFORE decrypting: the owner-key decryption is a leased operation
// and the decrypted payload is a stamped artifact — see `sign_event`.
let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?;
tauri::async_runtime::spawn_blocking(move || {
let event =
@@ -153,20 +164,26 @@ pub async fn decrypt_observer_event(
return Err("observer event has invalid signature".into());
}
buzz_core_pkg::observer::decrypt_observer_payload(&keys, &event)
.map_err(|error| format!("decrypt observer event failed: {error}"))
let payload = buzz_core_pkg::observer::decrypt_observer_payload(&keys, &event)
.map_err(|error| format!("decrypt observer event failed: {error}"))?;
// Stamp the decrypted payload; frontend validates in C6/C7.
let artifact = crate::owner_identity_egress::register_owner_artifact(&lease);
Ok(artifact.stamp_value(payload))
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[tauri::command]
pub fn build_observer_control_event(
pub async fn build_observer_control_event(
agent_pubkey: String,
payload: serde_json::Value,
state: State<'_, AppState>,
) -> Result<String, String> {
) -> Result<crate::owner_identity_egress::StampedArtifact<String>, String> {
let keys = state.signing_keys()?;
// Admit BEFORE encrypt+sign: the owner-signed observer control frame is a
// leased artifact stamped with the lease's generation — see `sign_event`.
let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?;
let agent_pubkey = PublicKey::from_hex(agent_pubkey.trim())
.map_err(|error| format!("invalid agent pubkey: {error}"))?;
let agent_pubkey_hex = agent_pubkey.to_hex();
@@ -183,7 +200,9 @@ pub fn build_observer_control_event(
let event = builder
.sign_with_keys(&keys)
.map_err(|error| format!("sign observer control failed: {error}"))?;
Ok(event.as_json())
// Stamp the issuing lease's generation; frontend validates in C6/C7.
let artifact = crate::owner_identity_egress::register_owner_artifact(&lease);
Ok(artifact.stamp_value(event.as_json()))
}
#[tauri::command]
@@ -825,7 +844,7 @@ pub async fn sign_nostr_identity_binding(
origin: String,
expires_at: String,
state: State<'_, AppState>,
) -> Result<String, String> {
) -> Result<crate::owner_identity_egress::StampedArtifact<String>, String> {
nostr_bind::validate_signing_request(
&challenge_id,
&nonce,
@@ -835,6 +854,9 @@ pub async fn sign_nostr_identity_binding(
)?;
let keys = state.signing_keys()?;
// Admit BEFORE signing: the identity-binding response is a leased artifact
// stamped with the lease's generation — see `sign_event`.
let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?;
tauri::async_runtime::spawn_blocking(move || {
let event = build_nostr_identity_binding_event(
@@ -846,7 +868,8 @@ pub async fn sign_nostr_identity_binding(
&expires_at,
)?;
Ok(event.as_json())
let artifact = crate::owner_identity_egress::register_owner_artifact(&lease);
Ok(artifact.stamp_value(event.as_json()))
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
@@ -857,15 +880,16 @@ pub async fn create_auth_event(
challenge: String,
relay_url: String,
state: State<'_, AppState>,
) -> Result<String, String> {
) -> Result<crate::owner_identity_egress::StampedArtifact<String>, String> {
let keys = state.signing_keys()?;
// The relay-WS reconnection handshake signs a NIP-42 auth event under a
// per-send bounded egress lease: reconnection cannot re-authenticate
// mid-transition because the drain refuses new admission (spec — the
// frontend session's authority is gated the same as every other owner
// sign). Frontend session REGISTRATION (capability + native-WS teardown
// handle) defers to C6/C7 with the frontend identity store; this lands the
// spec-mandated admission gate now.
// sign). The signed auth event is an owner-signed relay-event ARTIFACT
// (stamped and threaded to the frontend, validated in C6/C7); frontend
// session REGISTRATION (native-WS teardown handle) defers to C6/C7 with the
// frontend identity store.
let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?;
tauri::async_runtime::spawn_blocking(move || {
@@ -880,9 +904,10 @@ pub async fn create_auth_event(
.tags(tags)
.sign_with_keys(&keys)
.map_err(|error| format!("sign failed: {error}"))?;
// Sign complete: the lease has served its sign→return window.
drop(lease);
Ok(event.as_json())
// Stamp the issuing lease's generation, consuming the lease as the
// sign→return window closes; frontend validates in C6/C7.
let artifact = crate::owner_identity_egress::register_owner_artifact(&lease);
Ok(artifact.stamp_value(event.as_json()))
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
@@ -892,17 +917,22 @@ pub async fn create_auth_event(
pub async fn nip44_encrypt_to_self(
plaintext: String,
state: State<'_, AppState>,
) -> Result<String, String> {
) -> Result<crate::owner_identity_egress::StampedArtifact<String>, String> {
let keys = state.signing_keys()?;
// Admit BEFORE encrypting: the encrypt-to-self ciphertext is a leased
// artifact stamped with the lease's generation — see `sign_event`.
let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?;
tauri::async_runtime::spawn_blocking(move || {
nip44::encrypt(
let ciphertext = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
&plaintext,
nip44::Version::V2,
)
.map_err(|e| format!("nip44 encrypt failed: {e}"))
.map_err(|e| format!("nip44 encrypt failed: {e}"))?;
let artifact = crate::owner_identity_egress::register_owner_artifact(&lease);
Ok(artifact.stamp_value(ciphertext))
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
@@ -912,12 +942,17 @@ pub async fn nip44_encrypt_to_self(
pub async fn nip44_decrypt_from_self(
ciphertext: String,
state: State<'_, AppState>,
) -> Result<String, String> {
) -> Result<crate::owner_identity_egress::StampedArtifact<String>, String> {
let keys = state.signing_keys()?;
// Admit BEFORE decrypting: the decrypted plaintext is a leased artifact
// stamped with the lease's generation — see `sign_event`.
let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?;
tauri::async_runtime::spawn_blocking(move || {
nip44::decrypt(keys.secret_key(), &keys.public_key(), &ciphertext)
.map_err(|e| format!("nip44 decrypt failed: {e}"))
let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &ciphertext)
.map_err(|e| format!("nip44 decrypt failed: {e}"))?;
let artifact = crate::owner_identity_egress::register_owner_artifact(&lease);
Ok(artifact.stamp_value(plaintext))
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?

View File

@@ -11,6 +11,7 @@ use std::collections::HashMap;
use std::sync::atomic::Ordering;
use std::sync::{LazyLock, Mutex};
use serde::Serialize;
use tokio_util::sync::CancellationToken;
use super::{lock_inner, IdentityPersistenceState, OwnerIdentityEgressLease, REGISTRY};
@@ -86,17 +87,45 @@ pub struct SessionPolicy;
#[derive(Debug)]
pub struct BearerPolicy;
/// A generation-stamped owner-key-derived VALUE (owner-signed relay event JSON,
/// identity-binding response, encrypt-to-self ciphertext, or the plaintext of
/// an owner-key decryption) that leaves its producing lease and is exercised —
/// exported, published, signed around, or applied to identity-indexed state —
/// LATER. Unlike a session or bearer, an artifact owns NO side-effecting
/// teardown: a stamped `String`/JSON value has nothing to cancel or remove. Its
/// only revocation mechanism is the exercise-time generation compare, which the
/// generation bump at [`begin_egress_drain`] triggers by construction — so the
/// registry keeps NO artifact entry for the barrier to invalidate (shape (ii),
/// acked by Paul 2026-08-18). The registered revocation handle is therefore
/// [`RevocationHandle::Artifact`], a marker carrying no teardown authority; the
/// capability's `id` is correlation/diagnostic only, never a revocation target.
///
/// Rust-consumed / collapsed-transaction artifacts (project git-workflow
/// sign+submit, archive-ingest decrypts, snapshot-import, engram-listing
/// decrypt) exercise synchronously inside their producing lease and validate
/// via [`OwnerIdentityCapability::admit_exercise`] before their local
/// application — identical to a bounded lease. Boundary-crossing artifacts (the
/// frontend-consumed producers) serialize their stamp across the Tauri boundary
/// as [`ArtifactStamp`]; the frontend threads it opaque until C6/C7 adds the
/// generation-compare at each application site.
#[derive(Debug)]
pub struct ArtifactPolicy;
impl CapabilityPolicy for SessionPolicy {
const KIND: &'static str = "session";
}
impl CapabilityPolicy for BearerPolicy {
const KIND: &'static str = "bearer";
}
impl CapabilityPolicy for ArtifactPolicy {
const KIND: &'static str = "artifact";
}
mod private {
pub trait Sealed {}
impl Sealed for super::SessionPolicy {}
impl Sealed for super::BearerPolicy {}
impl Sealed for super::ArtifactPolicy {}
}
/// The revocation authority for one registered durable capability, invoked by
@@ -109,6 +138,16 @@ enum RevocationHandle {
/// invalidation, so no side-effecting handle is needed. The variant exists
/// so the registry records the capability's kind for the per-kind barrier.
Bearer,
/// Artifact invalidation is BY GENERATION BUMP, not by entry: a stamped
/// value owns no side-effecting teardown, and its only revocation is the
/// exercise-time / application-site generation compare failing once the
/// bump advances the current generation (shape (ii)). The registry keeps NO
/// artifact entry for the barrier to invalidate — this marker exists only
/// so `register_owner_artifact` shares the one registration path; the entry
/// is removed immediately on the handle's Drop (serialization time for a
/// boundary-crossing artifact, scope end for a collapsed one), so the id is
/// correlation/diagnostic only, never a revocation target.
Artifact,
}
/// Registry of live durable capabilities, keyed by capability id, under its
@@ -234,6 +273,85 @@ pub fn register_owner_bearer(
register_durable(lease.generation(), RevocationHandle::Bearer)
}
/// The wire form of an artifact's generation stamp, serialized across the Tauri
/// boundary alongside the owner-key-derived value as `{ value, artifact }`.
///
/// The frontend threads this pair OPAQUE (never unwrapping and discarding it at
/// the adapter — the witness must survive to the application site); C6/C7 adds
/// the generation-compare at each application boundary using `generation`. The
/// `id` is correlation/diagnostic only (log correlation, completeness count),
/// never a revocation target — see [`ArtifactPolicy`] for the shape (ii)
/// rationale.
#[derive(Debug, Clone, Serialize)]
pub struct ArtifactStamp {
/// Correlation id — monotonic, diagnostic only, never a revocation target.
pub id: u64,
/// The identity-persistence generation this artifact was issued under; the
/// application-site compare (C6/C7) refuses once the current generation
/// advances past it.
pub generation: u64,
}
/// The serialized `{ value, artifact }` pair a boundary-crossing producer
/// returns across the Tauri boundary. The frontend threads this OPAQUE (the
/// witness must survive to the application site — never unwrap-and-discard at
/// the adapter); C6/C7 adds the generation-compare against `artifact.generation`
/// immediately before each irreversible application effect.
#[derive(Debug, Serialize)]
pub struct StampedArtifact<T: Serialize> {
/// The owner-key-derived value (signed event JSON, ciphertext, decrypted
/// payload). Consumers destructure `.value` at their current use point.
pub value: T,
/// The generation stamp carried alongside the value.
pub artifact: ArtifactStamp,
}
impl OwnerIdentityCapability<ArtifactPolicy> {
/// The wire stamp for a boundary-crossing artifact, serialized alongside
/// the value as `{ value, artifact: ArtifactStamp }`. Producing the stamp
/// does not consume the capability; the caller drops the capability once
/// the pair is serialized (the entry is correlation-only — see
/// [`ArtifactPolicy`]).
pub fn stamp(&self) -> ArtifactStamp {
ArtifactStamp {
id: self.id,
generation: self.generation,
}
}
/// Wrap a boundary-crossing `value` with this capability's stamp, consuming
/// the capability. The returned [`StampedArtifact`] serializes as
/// `{ value, artifact }`; the capability's registry entry deregisters as it
/// drops at the end of this call (the entry is correlation-only under shape
/// (ii), so early deregistration loses no revocation authority).
pub fn stamp_value<T: Serialize>(self, value: T) -> StampedArtifact<T> {
StampedArtifact {
value,
artifact: self.stamp(),
}
}
}
/// Issue a durable owner-identity artifact capability over an owner-key-derived
/// value, stamped with the issuing lease's generation.
///
/// Stamps the issuing [`OwnerIdentityEgressLease`]'s generation, not a re-read
/// one — see [`register_owner_session`] for why re-reading is a barrier bypass.
/// The F1 lesson applies identically: the signing/decryption that derives the
/// value runs under the lease, and the stamp must be that lease's generation so
/// a value derived across a bump fails closed at its application site.
///
/// Call this AFTER the owner-key operation completes under the lease (admit
/// before sign/decrypt, stamp after). A Rust-consumed / collapsed artifact
/// validates via [`OwnerIdentityCapability::admit_exercise`] before its local
/// application; a boundary-crossing artifact serializes [`ArtifactStamp`] via
/// [`OwnerIdentityCapability::stamp`] and the frontend validates in C6/C7.
pub fn register_owner_artifact(
lease: &OwnerIdentityEgressLease,
) -> OwnerIdentityCapability<ArtifactPolicy> {
register_durable(lease.generation(), RevocationHandle::Artifact)
}
/// Register a durable capability stamped with `generation` and carrying
/// `handle` as its revocation authority. Shared by both constructors so the
/// id allocation and registry insert live in one place.
@@ -258,6 +376,22 @@ fn register_durable<P: CapabilityPolicy>(
/// later attach fails [`OwnerIdentityCapability::admit_exercise`]). Returns the
/// number of capabilities revoked.
///
/// ARTIFACTS ARE NOT REVOKED HERE, and this is correct, not a gap (shape (ii),
/// acked by Paul 2026-08-18). The spec text reads "invalidates every
/// old-generation bearer AND artifact"; a reviewer diffing against it will look
/// for artifact handling in this function and find none. An artifact owns no
/// side-effecting teardown — it is a stamped value, not a socket or a bearer
/// entry — so its ONLY revocation mechanism is the exercise-time /
/// application-site generation compare, which the generation bump at
/// [`begin_egress_drain`] triggers by construction. The bump IS the artifact
/// invalidation, and it precedes this call (and the journal write) in the
/// coordinator sequence, so every old-generation artifact is already
/// invalidated before the durable boundary without a per-entry action. Per-entry
/// work here is reserved for authorities with side-effecting teardown (session
/// cancel-tokens, bearer entries). Any artifact entry that happens to be live
/// at barrier time (a producer between lease-drop and value-serialization)
/// self-deregisters on its imminent Drop and is skipped either way.
///
/// The C5 coordinator barrier calls this after
/// [`begin_egress_drain`]/[`await_egress_drain`] and BEFORE the journal write +
/// durable B dispatch, so no old-generation session or bearer can transmit
@@ -269,7 +403,11 @@ pub fn revoke_durable_capabilities_before(winning_generation: u64) -> usize {
let stale: Vec<u64> = durable
.entries
.iter()
.filter(|(_, (gen, _))| *gen < winning_generation)
.filter(|(_, (gen, handle))| {
// Artifacts are generation-invalidated (shape (ii)); the barrier
// acts only on side-effecting authorities.
*gen < winning_generation && !matches!(handle, RevocationHandle::Artifact)
})
.map(|(id, _)| *id)
.collect();
for id in &stale {
@@ -491,4 +629,136 @@ mod tests {
"with no transition the session still transmits"
);
}
// §7 artifact schedules (P31-C1) — three schedules proving shape (ii): the
// generation bump reaches artifacts WITHOUT a barrier registry entry, so a
// stamped value's application is refused post-bump by the generation
// compare alone. `admit_exercise` on an ArtifactPolicy capability stands in
// for both the collapsed-artifact local check and the boundary-crossing
// artifact's simulated application-site compare (C6/C7).
// P31-C1 (a): a Rust-consumed / collapsed artifact stamped under A refuses
// its local application after the generation advances to B.
#[test]
fn stale_artifact_application_refuses_after_a_generation_bump() {
let _g = guard();
let artifact = register_owner_artifact(&lease());
begin_egress_drain().unwrap();
resume_egress_live();
assert!(
artifact.admit_exercise().is_err(),
"an artifact stamped under A must refuse application after B wins"
);
}
// P31-C1 (b): the barrier does NOT revoke artifacts by entry — the bump is
// the invalidation. `revoke_durable_capabilities_before` reports zero
// side-effecting revocations for a registry holding only artifacts, and the
// stamped artifact still refuses application by generation compare. This is
// the test-level proof that the bump reaches artifacts without registry
// entries (Paul's condition 2).
#[test]
fn barrier_leaves_artifacts_to_generation_compare_not_entry_revocation() {
let _g = guard();
let artifact = register_owner_artifact(&lease());
assert_eq!(live_durable_capability_count(), 1);
let winning = begin_egress_drain().unwrap();
resume_egress_live();
assert_eq!(
revoke_durable_capabilities_before(winning),
0,
"the barrier performs no per-entry revocation for artifacts (shape (ii))"
);
assert!(
artifact.admit_exercise().is_err(),
"the bump alone invalidates the artifact — the generation compare refuses"
);
}
// P31-C1 (c): a boundary-crossing artifact's wire stamp carries the issuing
// generation, and once the generation advances the stamp is stale relative
// to the current generation — the invariant C6/C7's application-site
// compare enforces against `ArtifactStamp::generation`.
#[test]
fn boundary_crossing_artifact_stamp_is_stale_after_a_bump() {
let _g = guard();
let artifact = register_owner_artifact(&lease());
let stamp = artifact.stamp();
assert_eq!(stamp.generation, current_identity_persistence_generation());
let winning = begin_egress_drain().unwrap();
resume_egress_live();
assert_ne!(
stamp.generation, winning,
"the wire stamp is stale relative to the winning generation; the C6/C7 \
application-site compare refuses it"
);
assert!(
artifact.admit_exercise().is_err(),
"and the capability itself refuses local application"
);
}
// §7 decryption-artifact schedules (P32-C1) — two schedules for the
// decryption outputs (nip44_decrypt_from_self / decrypt_observer_event):
// the decrypted plaintext stamped under A refuses application after B, and
// a no-transition control keeps it applicable.
// P32-C1 (a): a decryption-output artifact stamped under A refuses
// application after the generation advances to B.
#[test]
fn stale_decryption_artifact_application_refuses_after_a_bump() {
let _g = guard();
let decrypted = register_owner_artifact(&lease());
begin_egress_drain().unwrap();
resume_egress_live();
assert!(
decrypted.admit_exercise().is_err(),
"a decryption output stamped under A must refuse application after B wins"
);
}
// P32-C1 (b): no-transition control — a decryption-output artifact with no
// intervening bump stays applicable and the barrier revokes nothing.
#[test]
fn no_transition_leaves_decryption_artifact_applicable() {
let _g = guard();
let decrypted = register_owner_artifact(&lease());
let current = current_identity_persistence_generation();
assert_eq!(
revoke_durable_capabilities_before(current),
0,
"no side-effecting revocation with no transition"
);
assert!(
decrypted.admit_exercise().is_ok(),
"with no transition the decryption output stays applicable"
);
}
// Wire-shape LOCK (Paul's condition 2): the boundary payload serializes as
// exactly `{ value, artifact: { id, generation } }`. C6/C7 inherits this
// contract — the frontend `StampedArtifact<T>` mirror and every
// application-site compare read `artifact.generation`, so a silent drift
// here (renamed/reshaped field) would break C6/C7 undetectably. Pinning it
// now makes any drift a test failure, not a C6/C7 rediscovery.
#[test]
fn stamped_artifact_serializes_to_the_locked_wire_shape() {
let stamped = StampedArtifact {
value: "signed-event-json",
artifact: ArtifactStamp {
id: 7,
generation: 3,
},
};
let json = serde_json::to_value(&stamped).expect("serializes");
assert_eq!(
json,
serde_json::json!({
"value": "signed-event-json",
"artifact": { "id": 7, "generation": 3 },
}),
"the boundary payload must be {{value, artifact:{{id, generation}}}} \
— the contract C6/C7 threads to application sites"
);
}
}

View File

@@ -70,8 +70,25 @@
//! `commands::channels` (×3), and the huddle STT task (per-send inline
//! owner lease).
//! - `commands::identity::create_auth_event` — the relay-WS reconnection
//! NIP-42 handshake (per-send lease; frontend session registration defers to
//! C6/C7 with the frontend identity store).
//! NIP-42 handshake (per-send lease). The signed auth event is an
//! owner-signed relay-event ARTIFACT: stamped and threaded to the frontend
//! as `{value, artifact}`, validated in C6/C7. Frontend session REGISTRATION
//! (native-WS teardown handle) defers to C6/C7 with the frontend identity
//! store.
//! - **Owner-identity artifacts** — the seven `commands::identity` producers
//! `sign_event`, `create_auth_event`, `build_observer_control_event`,
//! `sign_nostr_identity_binding`, `nip44_encrypt_to_self`,
//! `nip44_decrypt_from_self`, and `decrypt_observer_event` admit a bounded
//! lease BEFORE the owner-key sign/encrypt/decrypt, register an
//! [`OwnerIdentityCapability<ArtifactPolicy>`], and return the owner-key
//! value wrapped as [`StampedArtifact`] (`{value, artifact:{id,generation}}`).
//! Artifacts carry NO side-effecting teardown: their only revocation is the
//! application-site generation compare, triggered by the transition bump
//! (shape (ii)). The frontend threads the pair opaque; C6/C7 adds the
//! compare at each application boundary. Rust-consumed / collapsed-transaction
//! artifacts (`project_git_workflow` sign+submit, archive-ingest decrypts,
//! snapshot-import, engram-listing decrypt) exercise synchronously inside
//! their lease and keep the collapsed shape — inventoried for C5/C6.
//! - **Durable bearers** — `commands::media::mint_media_get_auth` (Blossom
//! `t=get`) and the `do_upload` `t=upload` mint sign under a bounded lease
//! and register an [`OwnerIdentityCapability<BearerPolicy>`]; the four
@@ -475,14 +492,20 @@ fn admit_managed_agent_after_wait() -> Result<ManagedAgentEgressLease, String> {
/// file-size discipline; it shares this module's one registry.
mod durable;
pub use durable::{
register_owner_bearer, register_owner_session, BearerPolicy, OwnerIdentityCapability,
SessionPolicy,
register_owner_artifact, register_owner_bearer, register_owner_session, BearerPolicy,
OwnerIdentityCapability, SessionPolicy, StampedArtifact,
};
// The C5 coordinator barrier and its registration-completeness reader; no
// production caller until C5, so the re-exports are unused today. Consumed by
// C5 (P25/P28); remove allow when C5 lands.
#[allow(unused_imports)]
pub use durable::revoke_durable_capabilities_before;
// The artifact policy marker and its wire stamp are named at the frontend
// application sites in C6/C7 (generation-compare) and by the §7 artifact
// schedules; no production Rust caller names them today. Remove allow when
// C6/C7 lands.
#[allow(unused_imports)]
pub use durable::{ArtifactPolicy, ArtifactStamp};
/// Process-global mutex serializing tests that mutate the registry's
/// process-global state. Any test that admits a lease, drives a drain, or

View File

@@ -193,10 +193,30 @@ installDOMShim();
/** @type {Map<string, (args: unknown) => Promise<unknown>>} */
const ipcHandlers = new Map();
// Owner-identity artifact producers (NIP-AP) return `{ value, artifact }` across
// the Tauri boundary; the adapters unwrap `.value`. Mocks return the bare value,
// so wrap it in the stamped shape for these commands (inert stamp in C3 — the
// generation-compare lands in C6/C7). Keep this set in sync with the producers.
const STAMPED_ARTIFACT_COMMANDS = new Set([
"sign_event",
"create_auth_event",
"build_observer_control_event",
"sign_nostr_identity_binding",
"nip44_encrypt_to_self",
"nip44_decrypt_from_self",
"decrypt_observer_event",
]);
function wrapV(cmd, value) {
return STAMPED_ARTIFACT_COMMANDS.has(cmd)
? { value, artifact: { id: 0, generation: 0 } }
: value;
}
globalThis.__TAURI_INTERNALS__ = {
invoke: (cmd, args) => {
const handler = ipcHandlers.get(cmd);
if (handler) return handler(args);
if (handler)
return Promise.resolve(handler(args)).then((v) => wrapV(cmd, v));
return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`));
},
transformCallback: (_cb) => {

View File

@@ -1,4 +1,4 @@
import { invokeTauri } from "@/shared/api/tauri";
import { invokeTauri, type StampedArtifact } from "@/shared/api/tauri";
export type NostrIdentityBindingInput = {
challengeId: string;
@@ -8,8 +8,13 @@ export type NostrIdentityBindingInput = {
expiresAt: string;
};
export function signNostrIdentityBinding(
export async function signNostrIdentityBinding(
input: NostrIdentityBindingInput,
): Promise<string> {
return invokeTauri<string>("sign_nostr_identity_binding", input);
// C6/C7: thread {value, artifact} to application sites — unwrap is temporary.
const { value } = await invokeTauri<StampedArtifact<string>>(
"sign_nostr_identity_binding",
input,
);
return value;
}

View File

@@ -0,0 +1,56 @@
import type { RelayEvent } from "@/shared/api/types";
import { invokeTauri } from "./tauri";
import type { StampedArtifact } from "./stampedArtifact";
// Owner-identity artifact producers (NIP-AP). Each Rust command returns the
// owner-key-derived value wrapped as `{ value, artifact }`; C6/C7 threads the
// pair to each identity-sensitive application site and adds the
// generation-compare. Until then these adapters unwrap `.value` at the boundary
// — the stamp is inert but the wire shape is pinned (see `StampedArtifact`).
export async function signRelayEvent(input: {
kind: number;
content: string;
createdAt?: number;
tags: string[][];
}): Promise<RelayEvent> {
// C6/C7: thread {value, artifact} to application sites — unwrap is temporary.
const r = await invokeTauri<StampedArtifact<string>>("sign_event", input);
return JSON.parse(r.value) as RelayEvent;
}
export async function createAuthEvent(input: {
challenge: string;
relayUrl: string;
}): Promise<RelayEvent> {
// C6/C7: thread {value, artifact} to application sites — unwrap is temporary.
const r = await invokeTauri<StampedArtifact<string>>(
"create_auth_event",
input,
);
return JSON.parse(r.value) as RelayEvent;
}
export async function nip44EncryptToSelf(plaintext: string): Promise<string> {
// C6/C7: thread {value, artifact} to application sites — unwrap is temporary.
const r = await invokeTauri<StampedArtifact<string>>(
"nip44_encrypt_to_self",
{
plaintext,
},
);
return r.value;
}
export async function nip44DecryptFromSelf(
ciphertext: string,
): Promise<string> {
// C6/C7: thread {value, artifact} to application sites — unwrap is temporary.
const r = await invokeTauri<StampedArtifact<string>>(
"nip44_decrypt_from_self",
{
ciphertext,
},
);
return r.value;
}

View File

@@ -105,7 +105,13 @@ function setupTauriStubs(
invoke: async (command, args) => {
calls.invokeArgs.push({ command, args });
if (command === "get_relay_http_url") return httpBase;
if (command === "sign_event") return JSON.stringify(authEvent);
// sign_event returns the NIP-AP stamped artifact `{ value, artifact }`;
// the adapter unwraps `.value` (generation-compare lands in C6/C7).
if (command === "sign_event")
return {
value: JSON.stringify(authEvent),
artifact: { id: 0, generation: 0 },
};
throw new Error(`Unexpected Tauri command: ${command}`);
},
};

View File

@@ -0,0 +1,18 @@
/**
* The `{ value, artifact }` pair the owner-identity artifact producers return
* across the Tauri boundary. `artifact` is the generation stamp
* (`OwnerIdentityCapability<ArtifactPolicy>`, NIP-AP): `id` is
* correlation/diagnostic only, `generation` is the identity-persistence
* generation the value was signed/decrypted under.
*
* The wire contract is locked here and on the Rust side (`StampedArtifact<T>`):
* C6/C7 threads this pair to each identity-sensitive application site and adds
* the generation-compare (refuse to apply once the current generation advances
* past `artifact.generation`). Until then the adapters unwrap `.value` at the
* boundary — the stamp is inert but the shape is pinned so C6/C7 inherits a
* stable contract, not a rediscovery.
*/
export interface StampedArtifact<T> {
value: T;
artifact: { id: number; generation: number };
}

View File

@@ -118,6 +118,13 @@ type RawRelayAgent = {
};
import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff";
export type { StampedArtifact } from "./stampedArtifact";
export {
signRelayEvent,
createAuthEvent,
nip44EncryptToSelf,
nip44DecryptFromSelf,
} from "./identityArtifacts";
export type RawManagedAgent = {
pubkey: string;
name: string;
@@ -655,24 +662,6 @@ export async function removeReaction(
await invokeTauri("remove_reaction", { eventId, emoji });
}
export async function signRelayEvent(input: {
kind: number;
content: string;
createdAt?: number;
tags: string[][];
}): Promise<RelayEvent> {
const eventJson = await invokeTauri<string>("sign_event", input);
return JSON.parse(eventJson) as RelayEvent;
}
export async function createAuthEvent(input: {
challenge: string;
relayUrl: string;
}): Promise<RelayEvent> {
const eventJson = await invokeTauri<string>("create_auth_event", input);
return JSON.parse(eventJson) as RelayEvent;
}
function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent {
return {
pubkey: agent.pubkey,
@@ -1116,16 +1105,6 @@ export async function probeBackendProvider(
// ── NIP-44 encrypt-to-self ───────────────────────────────────────────────────
export async function nip44EncryptToSelf(plaintext: string): Promise<string> {
return invokeTauri<string>("nip44_encrypt_to_self", { plaintext });
}
export async function nip44DecryptFromSelf(
ciphertext: string,
): Promise<string> {
return invokeTauri<string>("nip44_decrypt_from_self", { ciphertext });
}
export async function startPairing(): Promise<string> {
return invokeTauri<string>("start_pairing");
}

View File

@@ -1,21 +1,25 @@
import type { RelayEvent } from "@/shared/api/types";
import { invokeTauri } from "./tauri";
import { invokeTauri, type StampedArtifact } from "./tauri";
export async function decryptObserverEvent(
event: RelayEvent,
): Promise<unknown> {
return invokeTauri<unknown>("decrypt_observer_event", {
eventJson: JSON.stringify(event),
});
// C6/C7: thread {value, artifact} to application sites — unwrap is temporary.
const { value } = await invokeTauri<StampedArtifact<unknown>>(
"decrypt_observer_event",
{ eventJson: JSON.stringify(event) },
);
return value;
}
export async function buildObserverControlEvent(input: {
agentPubkey: string;
payload: unknown;
}): Promise<RelayEvent> {
const eventJson = await invokeTauri<string>("build_observer_control_event", {
agentPubkey: input.agentPubkey,
payload: input.payload,
});
return JSON.parse(eventJson) as RelayEvent;
// C6/C7: thread {value, artifact} to application sites — unwrap is temporary.
const { value } = await invokeTauri<StampedArtifact<string>>(
"build_observer_control_event",
{ agentPubkey: input.agentPubkey, payload: input.payload },
);
return JSON.parse(value) as RelayEvent;
}

View File

@@ -14,6 +14,13 @@ const preference = {
followSystem: false,
};
// Owner-identity artifact producers (NIP-AP) return `{ value, artifact }` across
// the Tauri boundary; the adapters unwrap `.value`. Wrap a mock's bare value in
// the stamped shape (inert stamp in C3 — the generation-compare lands in C6/C7).
function stampedArtifact(value) {
return { value, artifact: { id: 0, generation: 0 } };
}
function installFakeTimer() {
globalThis.window ??= {};
let callback = null;
@@ -147,15 +154,18 @@ test("new remote invalidates no-op suppression for A to B to A", async () => {
let signedEventId = "published-z";
globalThis.window.__TAURI_INTERNALS__ = {
invoke(command, args) {
if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher");
if (command === "nip44_encrypt_to_self")
return Promise.resolve(stampedArtifact("cipher"));
if (command === "sign_event") {
return Promise.resolve(
JSON.stringify(
relayEvent({
id: signedEventId,
content: args.content,
created_at: args.createdAt,
}),
stampedArtifact(
JSON.stringify(
relayEvent({
id: signedEventId,
content: args.content,
created_at: args.createdAt,
}),
),
),
);
}
@@ -201,16 +211,19 @@ test("serializes an in-flight publish before sending the latest edit", async ()
let signed = 0;
globalThis.window.__TAURI_INTERNALS__ = {
invoke(command, args) {
if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher");
if (command === "nip44_encrypt_to_self")
return Promise.resolve(stampedArtifact("cipher"));
if (command === "sign_event") {
signed += 1;
return Promise.resolve(
JSON.stringify(
relayEvent({
id: `event-${signed}`,
content: args.content,
created_at: args.createdAt,
}),
stampedArtifact(
JSON.stringify(
relayEvent({
id: `event-${signed}`,
content: args.content,
created_at: args.createdAt,
}),
),
),
);
}
@@ -256,16 +269,19 @@ test("republishes above a newer remote observed while publish is in flight", asy
let signed = 0;
globalThis.window.__TAURI_INTERNALS__ = {
invoke(command, args) {
if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher");
if (command === "nip44_encrypt_to_self")
return Promise.resolve(stampedArtifact("cipher"));
if (command === "sign_event") {
signed += 1;
return Promise.resolve(
JSON.stringify(
relayEvent({
id: `event-${signed}`,
content: args.content,
created_at: args.createdAt,
}),
stampedArtifact(
JSON.stringify(
relayEvent({
id: `event-${signed}`,
content: args.content,
created_at: args.createdAt,
}),
),
),
);
}
@@ -316,17 +332,21 @@ test("delayed live decryption fences publish acknowledgement and preserves local
let signed = 0;
globalThis.window.__TAURI_INTERNALS__ = {
invoke(command, args) {
if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher");
if (command === "nip44_decrypt_from_self") return remotePlaintext.promise;
if (command === "nip44_encrypt_to_self")
return Promise.resolve(stampedArtifact("cipher"));
if (command === "nip44_decrypt_from_self")
return remotePlaintext.promise.then(stampedArtifact);
if (command === "sign_event") {
signed += 1;
return Promise.resolve(
JSON.stringify(
relayEvent({
id: `event-${signed}`,
content: args.content,
created_at: args.createdAt,
}),
stampedArtifact(
JSON.stringify(
relayEvent({
id: `event-${signed}`,
content: args.content,
created_at: args.createdAt,
}),
),
),
);
}
@@ -388,15 +408,18 @@ test("transient publish failure retries and acknowledges exact event", async ()
let attempts = 0;
globalThis.window.__TAURI_INTERNALS__ = {
invoke(command, args) {
if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher");
if (command === "nip44_encrypt_to_self")
return Promise.resolve(stampedArtifact("cipher"));
if (command === "sign_event") {
return Promise.resolve(
JSON.stringify(
relayEvent({
id: "published-event",
content: args.content,
created_at: args.createdAt,
}),
stampedArtifact(
JSON.stringify(
relayEvent({
id: "published-event",
content: args.content,
created_at: args.createdAt,
}),
),
),
);
}