···11+-- OAuth state for external auth flows (e.g., Steam OpenID)
22+CREATE TABLE external_auth_state (
33+ state TEXT PRIMARY KEY,
44+ did TEXT NOT NULL,
55+ plugin_id TEXT NOT NULL,
66+ redirect_uri TEXT NOT NULL,
77+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
88+ expires_at TIMESTAMPTZ NOT NULL
99+);
1010+1111+-- Index for cleanup of expired state
1212+CREATE INDEX idx_external_auth_state_expires ON external_auth_state(expires_at);
···11+-- OAuth state for external auth flows (e.g., Steam OpenID)
22+CREATE TABLE external_auth_state (
33+ state TEXT PRIMARY KEY,
44+ did TEXT NOT NULL,
55+ plugin_id TEXT NOT NULL,
66+ redirect_uri TEXT NOT NULL,
77+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
88+ expires_at TEXT NOT NULL
99+);
1010+1111+-- Index for cleanup of expired state
1212+CREATE INDEX idx_external_auth_state_expires ON external_auth_state(expires_at);
···11+mod bindings;
12mod http;
23mod kv;
34mod logging;
45mod lookup;
56mod secrets;
6788+pub use bindings::{PluginState, register_host_functions};
79pub use http::*;
810pub use kv::*;
911pub use logging::*;
+102-14
src/plugin/loader.rs
···11+use crate::plugin::host::{PluginState, register_host_functions};
22+use crate::plugin::memory::PluginResponse;
33+use crate::plugin::runtime::DEFAULT_FUEL;
14use crate::plugin::{LoadedPlugin, PluginInfo, PluginSource};
25use sha2::{Digest, Sha256};
66+use std::collections::HashMap;
37use std::path::Path;
88+use wasmtime::{Config, Engine, Linker, Module, Store};
49510const SUPPORTED_API_VERSION: &str = "1";
611···84898590/// Extract plugin info by instantiating WASM and calling plugin_info()
8691fn extract_plugin_info(wasm_bytes: &[u8]) -> Result<PluginInfo, LoadError> {
8787- // TODO: Full implementation with wasmtime
8888- // For now, this is a placeholder that will be filled in when we integrate wasmtime calls
9292+ match tokio::runtime::Handle::try_current() {
9393+ Ok(handle) => {
9494+ tokio::task::block_in_place(|| handle.block_on(extract_plugin_info_async(wasm_bytes)))
9595+ }
9696+ Err(_) => {
9797+ let rt = tokio::runtime::Runtime::new().map_err(|e| {
9898+ LoadError::WasmValidation(format!("failed to create runtime: {}", e))
9999+ })?;
100100+ rt.block_on(extract_plugin_info_async(wasm_bytes))
101101+ }
102102+ }
103103+}
104104+105105+/// Async implementation of plugin info extraction via WASM instantiation
106106+async fn extract_plugin_info_async(wasm_bytes: &[u8]) -> Result<PluginInfo, LoadError> {
107107+ // Create async-enabled engine with fuel
108108+ let mut config = Config::new();
109109+ config.async_support(true);
110110+ config.consume_fuel(true);
111111+ let engine = Engine::new(&config).map_err(|e| LoadError::WasmValidation(e.to_string()))?;
112112+113113+ let module =
114114+ Module::new(&engine, wasm_bytes).map_err(|e| LoadError::WasmValidation(e.to_string()))?;
115115+116116+ // Create linker with host functions
117117+ let mut linker = Linker::new(&engine);
118118+ register_host_functions(&mut linker).map_err(|e| LoadError::WasmValidation(e.to_string()))?;
891199090- // Validate it's valid WASM
9191- wasmtime::Module::validate(&wasmtime::Engine::default(), wasm_bytes)
120120+ // Create minimal state - no db needed for plugin_info()
121121+ let state = PluginState {
122122+ plugin_id: "loading".into(),
123123+ scope: "".into(),
124124+ secrets: HashMap::new(),
125125+ config: serde_json::Value::Null,
126126+ db: None, // Not needed for plugin_info
127127+ db_backend: crate::db::DatabaseBackend::Sqlite,
128128+ http_client: reqwest::Client::new(),
129129+ lexicons: std::sync::Arc::new(crate::lexicon::LexiconRegistry::new()),
130130+ usage: Default::default(),
131131+ memory: None,
132132+ alloc: None,
133133+ dealloc: None,
134134+ };
135135+136136+ let mut store = Store::new(&engine, state);
137137+ store
138138+ .set_fuel(DEFAULT_FUEL)
92139 .map_err(|e| LoadError::WasmValidation(e.to_string()))?;
931409494- // Return placeholder - real implementation calls plugin_info() export
9595- Ok(PluginInfo {
9696- id: "placeholder".into(),
9797- name: "Placeholder".into(),
9898- version: "0.0.0".into(),
9999- api_version: SUPPORTED_API_VERSION.into(),
100100- icon_url: None,
101101- required_secrets: vec![],
102102- config_schema: None,
103103- })
141141+ // Instantiate
142142+ let instance = linker
143143+ .instantiate_async(&mut store, &module)
144144+ .await
145145+ .map_err(|e| LoadError::WasmValidation(format!("instantiation failed: {}", e)))?;
146146+147147+ // Get memory and alloc/dealloc
148148+ let memory = instance
149149+ .get_memory(&mut store, "memory")
150150+ .ok_or_else(|| LoadError::WasmValidation("missing memory export".into()))?;
151151+ let alloc = instance
152152+ .get_typed_func::<u32, u32>(&mut store, "alloc")
153153+ .map_err(|_| LoadError::WasmValidation("missing alloc export".into()))?;
154154+ let dealloc = instance
155155+ .get_typed_func::<(u32, u32), ()>(&mut store, "dealloc")
156156+ .map_err(|_| LoadError::WasmValidation("missing dealloc export".into()))?;
157157+158158+ // Store in state
159159+ store.data_mut().memory = Some(memory);
160160+ store.data_mut().alloc = Some(alloc);
161161+ store.data_mut().dealloc = Some(dealloc);
162162+163163+ // Call plugin_info
164164+ let func = instance
165165+ .get_typed_func::<(), i64>(&mut store, "plugin_info")
166166+ .map_err(|_| LoadError::WasmValidation("missing plugin_info export".into()))?;
167167+168168+ let packed = func
169169+ .call_async(&mut store, ())
170170+ .await
171171+ .map_err(|e| LoadError::WasmValidation(format!("plugin_info failed: {}", e)))?;
172172+173173+ // Unpack i64: upper 32 bits = ptr, lower 32 bits = len
174174+ let ptr = (packed >> 32) as u32;
175175+ let len = (packed & 0xFFFFFFFF) as u32;
176176+177177+ // Read result from memory
178178+ let mem_data = memory.data(&store);
179179+ if (ptr as usize) + (len as usize) > mem_data.len() {
180180+ return Err(LoadError::WasmValidation(
181181+ "plugin_info returned out of bounds pointer".into(),
182182+ ));
183183+ }
184184+ let bytes = mem_data[ptr as usize..(ptr as usize + len as usize)].to_vec();
185185+186186+ // Parse response
187187+ let response: PluginResponse<PluginInfo> = serde_json::from_slice(&bytes)?;
188188+189189+ response
190190+ .into_result()
191191+ .map_err(|e| LoadError::WasmValidation(format!("plugin error: {}", e.message)))
104192}
105193106194fn validate_api_version(info: &PluginInfo) -> Result<(), LoadError> {
+193
src/plugin/memory.rs
···11+use crate::plugin::host::PluginState;
22+use serde::{Deserialize, Serialize};
33+use thiserror::Error;
44+use wasmtime::Store;
55+66+/// Error returned from a plugin via JSON envelope.
77+/// Uses a string code for flexibility in parsing arbitrary error codes from plugins.
88+#[derive(Debug, Clone, Serialize, Deserialize)]
99+pub struct PluginEnvelopeError {
1010+ pub code: String,
1111+ pub message: String,
1212+ #[serde(default)]
1313+ pub retryable: bool,
1414+}
1515+1616+impl std::fmt::Display for PluginEnvelopeError {
1717+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1818+ write!(f, "{}: {}", self.code, self.message)
1919+ }
2020+}
2121+2222+impl std::error::Error for PluginEnvelopeError {}
2323+2424+/// JSON envelope for plugin responses.
2525+/// Plugins return either `{"ok": result}` or `{"error": {...}}`.
2626+#[derive(Debug, Deserialize)]
2727+#[serde(untagged)]
2828+pub enum PluginResponse<T> {
2929+ Ok { ok: T },
3030+ Error { error: PluginEnvelopeError },
3131+}
3232+3333+impl<T> PluginResponse<T> {
3434+ pub fn into_result(self) -> Result<T, PluginEnvelopeError> {
3535+ match self {
3636+ PluginResponse::Ok { ok } => Ok(ok),
3737+ PluginResponse::Error { error } => Err(error),
3838+ }
3939+ }
4040+}
4141+4242+#[derive(Debug, Error)]
4343+pub enum MemoryError {
4444+ #[error("Memory allocation failed: alloc returned 0")]
4545+ AllocationFailed,
4646+ #[error(
4747+ "Memory access out of bounds: offset {offset} + length {length} exceeds memory size {size}"
4848+ )]
4949+ OutOfBounds {
5050+ offset: usize,
5151+ length: usize,
5252+ size: usize,
5353+ },
5454+ #[error("WASM trap during memory operation: {0}")]
5555+ Trap(#[from] wasmtime::Error),
5656+}
5757+5858+/// Write data to WASM guest memory by calling alloc and copying bytes.
5959+/// Returns (ptr, len) tuple on success.
6060+pub async fn write_to_guest(
6161+ store: &mut Store<PluginState>,
6262+ data: &[u8],
6363+) -> Result<(u32, u32), MemoryError> {
6464+ let len = data.len() as u32;
6565+ if len == 0 {
6666+ return Ok((0, 0));
6767+ }
6868+6969+ let alloc = store
7070+ .data()
7171+ .alloc
7272+ .as_ref()
7373+ .ok_or(MemoryError::AllocationFailed)?
7474+ .clone();
7575+ let memory = store.data().memory.ok_or(MemoryError::AllocationFailed)?;
7676+7777+ let ptr = alloc.call_async(&mut *store, len).await?;
7878+ if ptr == 0 {
7979+ return Err(MemoryError::AllocationFailed);
8080+ }
8181+8282+ let mem_size = memory.data_size(&*store);
8383+ let start = ptr as usize;
8484+ let end = start
8585+ .checked_add(len as usize)
8686+ .ok_or(MemoryError::OutOfBounds {
8787+ offset: start,
8888+ length: len as usize,
8989+ size: mem_size,
9090+ })?;
9191+9292+ if end > mem_size {
9393+ return Err(MemoryError::OutOfBounds {
9494+ offset: start,
9595+ length: len as usize,
9696+ size: mem_size,
9797+ });
9898+ }
9999+100100+ memory.data_mut(&mut *store)[start..end].copy_from_slice(data);
101101+ Ok((ptr, len))
102102+}
103103+104104+/// Read data from WASM guest memory at the given pointer and length.
105105+pub fn read_from_guest(
106106+ store: &Store<PluginState>,
107107+ ptr: u32,
108108+ len: u32,
109109+) -> Result<Vec<u8>, MemoryError> {
110110+ if len == 0 {
111111+ return Ok(Vec::new());
112112+ }
113113+114114+ let memory = store.data().memory.ok_or(MemoryError::AllocationFailed)?;
115115+ let mem_size = memory.data_size(store);
116116+ let start = ptr as usize;
117117+ let end = start
118118+ .checked_add(len as usize)
119119+ .ok_or(MemoryError::OutOfBounds {
120120+ offset: start,
121121+ length: len as usize,
122122+ size: mem_size,
123123+ })?;
124124+125125+ if end > mem_size {
126126+ return Err(MemoryError::OutOfBounds {
127127+ offset: start,
128128+ length: len as usize,
129129+ size: mem_size,
130130+ });
131131+ }
132132+133133+ Ok(memory.data(store)[start..end].to_vec())
134134+}
135135+136136+/// Deallocate guest memory by calling the dealloc function.
137137+pub async fn dealloc_guest(
138138+ store: &mut Store<PluginState>,
139139+ ptr: u32,
140140+ len: u32,
141141+) -> Result<(), MemoryError> {
142142+ if len == 0 {
143143+ return Ok(());
144144+ }
145145+146146+ let dealloc = store
147147+ .data()
148148+ .dealloc
149149+ .as_ref()
150150+ .ok_or(MemoryError::AllocationFailed)?
151151+ .clone();
152152+ dealloc.call_async(&mut *store, (ptr, len)).await?;
153153+ Ok(())
154154+}
155155+156156+#[cfg(test)]
157157+mod tests {
158158+ use super::*;
159159+160160+ #[test]
161161+ fn test_memory_error_display() {
162162+ let err = MemoryError::AllocationFailed;
163163+ assert!(err.to_string().contains("alloc"));
164164+165165+ let err = MemoryError::OutOfBounds {
166166+ offset: 100,
167167+ length: 50,
168168+ size: 120,
169169+ };
170170+ assert!(err.to_string().contains("100"));
171171+ }
172172+173173+ #[test]
174174+ fn test_plugin_response_ok_parses() {
175175+ let json = r#"{"ok": "hello"}"#;
176176+ let resp: PluginResponse<String> = serde_json::from_str(json).unwrap();
177177+ let result = resp.into_result();
178178+ assert!(result.is_ok());
179179+ assert_eq!(result.unwrap(), "hello");
180180+ }
181181+182182+ #[test]
183183+ fn test_plugin_response_error_parses() {
184184+ let json =
185185+ r#"{"error": {"code": "AUTH_FAILED", "message": "Invalid token", "retryable": true}}"#;
186186+ let resp: PluginResponse<String> = serde_json::from_str(json).unwrap();
187187+ let result = resp.into_result();
188188+ assert!(result.is_err());
189189+ let err = result.unwrap_err();
190190+ assert_eq!(err.code, "AUTH_FAILED");
191191+ assert!(err.retryable);
192192+ }
193193+}
+6
src/plugin/mod.rs
···11+pub mod attestation;
12pub mod encryption;
33+pub mod executor;
24pub mod host;
35pub mod loader;
66+pub mod memory;
47mod runtime;
88+pub mod sync;
59mod types;
6101111+pub use executor::{ExecutionError, PluginExecutor, PluginInstance};
1212+pub use memory::{MemoryError, PluginEnvelopeError, PluginResponse};
713pub use runtime::WasmRuntime;
814pub use types::*;
915
+35
src/plugin/runtime.rs
···11use wasmtime::*;
2233+/// Default fuel for plugin execution (≈100ms CPU time)
44+pub const DEFAULT_FUEL: u64 = 10_000_000;
55+36/// WASM runtime for executing plugins
47pub struct WasmRuntime {
58 engine: Engine,
···912 pub fn new() -> Result<Self, anyhow::Error> {
1013 let mut config = Config::new();
1114 config.async_support(true);
1515+ config.consume_fuel(true);
12161317 let engine = Engine::new(&config)?;
1418···17211822 pub fn engine(&self) -> &Engine {
1923 &self.engine
2424+ }
2525+2626+ /// Compile a WASM module
2727+ pub fn compile(&self, wasm_bytes: &[u8]) -> Result<Module, anyhow::Error> {
2828+ Module::new(&self.engine, wasm_bytes)
2029 }
2130}
2231···2534 Self::new().expect("Failed to create WASM runtime")
2635 }
2736}
3737+3838+#[cfg(test)]
3939+mod tests {
4040+ use super::*;
4141+4242+ #[test]
4343+ fn test_fuel_constant_value() {
4444+ // 10M fuel ≈ 100ms CPU time per spec
4545+ assert_eq!(DEFAULT_FUEL, 10_000_000);
4646+ }
4747+4848+ #[test]
4949+ fn test_runtime_has_fuel_enabled() {
5050+ let runtime = WasmRuntime::new().expect("Failed to create runtime");
5151+ // We can verify fuel is enabled by checking we can set it on a store
5252+ let mut store = wasmtime::Store::new(runtime.engine(), ());
5353+ assert!(store.set_fuel(1000).is_ok());
5454+ }
5555+5656+ #[test]
5757+ fn test_compile_invalid_wasm_fails() {
5858+ let runtime = WasmRuntime::new().expect("Failed to create runtime");
5959+ let result = runtime.compile(b"not valid wasm");
6060+ assert!(result.is_err());
6161+ }
6262+}
+312
src/plugin/sync.rs
···11+//! SyncRecord processing pipeline.
22+//!
33+//! Processes records returned by plugin sync_account():
44+//! - Signs records that have `sign: true`
55+//! - Resolves game references
66+//! - Prepares records for writing to PDS
77+88+use super::attestation::{AttestationError, AttestationSigner};
99+use super::types::SyncRecord;
1010+use crate::db::{DatabaseBackend, adapt_sql};
1111+use serde_json::Value;
1212+1313+/// Processed record ready for storage
1414+#[derive(Debug, Clone)]
1515+pub struct ProcessedRecord {
1616+ /// The collection (lexicon ID)
1717+ pub collection: String,
1818+ /// The processed record with signatures added
1919+ pub record: Value,
2020+ /// Deduplication key
2121+ pub dedup_key: Option<String>,
2222+ /// CID of the signed content (if signed)
2323+ pub content_cid: Option<String>,
2424+}
2525+2626+/// Error during sync record processing
2727+#[derive(Debug, thiserror::Error)]
2828+pub enum SyncError {
2929+ #[error("Attestation signing failed: {0}")]
3030+ Attestation(#[from] AttestationError),
3131+3232+ #[error("Game reference resolution failed: {0}")]
3333+ GameResolution(String),
3434+3535+ #[error("Invalid record: {0}")]
3636+ InvalidRecord(String),
3737+}
3838+3939+/// Process a batch of SyncRecords from a plugin
4040+pub struct SyncProcessor<'a> {
4141+ /// Attestation signer (optional - if None, signing is skipped)
4242+ signer: Option<&'a AttestationSigner>,
4343+ /// Repository DID for the user (used in $sig for replay protection)
4444+ repository_did: String,
4545+}
4646+4747+impl<'a> SyncProcessor<'a> {
4848+ /// Create a new sync processor
4949+ pub fn new(signer: Option<&'a AttestationSigner>, repository_did: String) -> Self {
5050+ Self {
5151+ signer,
5252+ repository_did,
5353+ }
5454+ }
5555+5656+ /// Process a batch of SyncRecords
5757+ pub fn process_records(
5858+ &self,
5959+ records: Vec<SyncRecord>,
6060+ ) -> Result<Vec<ProcessedRecord>, SyncError> {
6161+ let mut processed = Vec::with_capacity(records.len());
6262+6363+ for record in records {
6464+ processed.push(self.process_record(record)?);
6565+ }
6666+6767+ Ok(processed)
6868+ }
6969+7070+ /// Process a single SyncRecord
7171+ fn process_record(&self, sync_record: SyncRecord) -> Result<ProcessedRecord, SyncError> {
7272+ let mut record = sync_record.record;
7373+7474+ // Resolve game references if present
7575+ self.resolve_game_ref(&mut record)?;
7676+7777+ // Sign if requested and signer is available
7878+ let content_cid = if sync_record.sign {
7979+ if let Some(signer) = self.signer {
8080+ let cid = signer.sign_record(&mut record, &self.repository_did)?;
8181+ Some(cid.to_string())
8282+ } else {
8383+ tracing::warn!(
8484+ collection = %sync_record.collection,
8585+ "Record requested signing but no signer configured"
8686+ );
8787+ None
8888+ }
8989+ } else {
9090+ None
9191+ };
9292+9393+ Ok(ProcessedRecord {
9494+ collection: sync_record.collection,
9595+ record,
9696+ dedup_key: sync_record.dedup_key,
9797+ content_cid,
9898+ })
9999+ }
100100+101101+ /// Resolve game references in a record
102102+ ///
103103+ /// Looks for game references like `{"platform": "steam", "externalId": "440"}`
104104+ /// and attempts to resolve them to AT URIs.
105105+ fn resolve_game_ref(&self, record: &mut Value) -> Result<(), SyncError> {
106106+ // Look for "game" field with platform/externalId structure
107107+ if let Some(obj) = record.as_object_mut()
108108+ && let Some(game_ref) = obj.get("game")
109109+ && let Some(game_obj) = game_ref.as_object()
110110+ && game_obj.contains_key("platform")
111111+ && game_obj.contains_key("externalId")
112112+ && !game_obj.contains_key("uri")
113113+ {
114114+ // Unresolved reference - log for debugging
115115+ let platform = game_obj
116116+ .get("platform")
117117+ .and_then(|v| v.as_str())
118118+ .unwrap_or("unknown");
119119+ let external_id = game_obj
120120+ .get("externalId")
121121+ .and_then(|v| v.as_str())
122122+ .unwrap_or("unknown");
123123+124124+ tracing::debug!(
125125+ platform = %platform,
126126+ external_id = %external_id,
127127+ "Game reference left unresolved - resolution not yet implemented"
128128+ );
129129+ }
130130+131131+ Ok(())
132132+ }
133133+}
134134+135135+/// Helper to create a sync processor with common setup
136136+pub fn create_processor<'a>(
137137+ signer: Option<&'a AttestationSigner>,
138138+ user_did: &str,
139139+) -> SyncProcessor<'a> {
140140+ SyncProcessor::new(signer, user_did.to_string())
141141+}
142142+143143+/// Resolve game references in records by looking up in the database.
144144+///
145145+/// Looks for `game: {platform: "steam", externalId: "440"}` and converts to
146146+/// `game: {uri: "at://...", cid: "..."}` if found.
147147+pub async fn resolve_game_references(
148148+ db: &sqlx::AnyPool,
149149+ backend: DatabaseBackend,
150150+ records: &mut [SyncRecord],
151151+) {
152152+ for record in records.iter_mut() {
153153+ let Some(obj) = record.record.as_object_mut() else {
154154+ continue;
155155+ };
156156+ let Some(game_ref) = obj.get("game").cloned() else {
157157+ continue;
158158+ };
159159+ let Some(game_obj) = game_ref.as_object() else {
160160+ continue;
161161+ };
162162+163163+ // Check for unresolved reference
164164+ if !game_obj.contains_key("platform")
165165+ || !game_obj.contains_key("externalId")
166166+ || game_obj.contains_key("uri")
167167+ {
168168+ continue;
169169+ }
170170+171171+ let platform = game_obj
172172+ .get("platform")
173173+ .and_then(|v| v.as_str())
174174+ .unwrap_or("");
175175+ let external_id = game_obj
176176+ .get("externalId")
177177+ .and_then(|v| v.as_str())
178178+ .unwrap_or("");
179179+180180+ if let Some((uri, cid)) =
181181+ lookup_game_by_external_id(db, backend, platform, external_id).await
182182+ {
183183+ obj.insert(
184184+ "game".to_string(),
185185+ serde_json::json!({
186186+ "uri": uri,
187187+ "cid": cid
188188+ }),
189189+ );
190190+ tracing::debug!(
191191+ platform = %platform,
192192+ external_id = %external_id,
193193+ uri = %uri,
194194+ "Resolved game reference"
195195+ );
196196+ } else {
197197+ tracing::debug!(
198198+ platform = %platform,
199199+ external_id = %external_id,
200200+ "Game not found in database, leaving reference unresolved"
201201+ );
202202+ }
203203+ }
204204+}
205205+206206+/// Look up a game by external ID (e.g., Steam app ID).
207207+///
208208+/// Returns (uri, cid) if found.
209209+async fn lookup_game_by_external_id(
210210+ db: &sqlx::AnyPool,
211211+ backend: DatabaseBackend,
212212+ platform: &str,
213213+ external_id: &str,
214214+) -> Option<(String, String)> {
215215+ // Build JSON path based on platform
216216+ // Looking for records where: record.externalIds.<platform> = external_id
217217+ let json_path = match backend {
218218+ DatabaseBackend::Sqlite => {
219219+ format!("json_extract(record, '$.externalIds.{}')", platform)
220220+ }
221221+ DatabaseBackend::Postgres => {
222222+ format!("record->'externalIds'->>'{}'", platform)
223223+ }
224224+ };
225225+226226+ let sql = adapt_sql(
227227+ &format!(
228228+ "SELECT uri, cid FROM records WHERE collection = 'games.gamesgamesgamesgames.game' AND {} = ? LIMIT 1",
229229+ json_path
230230+ ),
231231+ backend,
232232+ );
233233+234234+ let result: Option<(String, String)> = sqlx::query_as(&sql)
235235+ .bind(external_id)
236236+ .fetch_optional(db)
237237+ .await
238238+ .ok()
239239+ .flatten();
240240+241241+ result
242242+}
243243+244244+#[cfg(test)]
245245+mod tests {
246246+ use super::*;
247247+248248+ #[test]
249249+ fn test_process_unsigned_record() {
250250+ let processor = SyncProcessor::new(None, "did:plc:testuser".to_string());
251251+252252+ let records = vec![SyncRecord {
253253+ collection: "test.collection".into(),
254254+ record: serde_json::json!({
255255+ "$type": "test.collection",
256256+ "data": "hello"
257257+ }),
258258+ dedup_key: Some("test:1".into()),
259259+ sign: false,
260260+ }];
261261+262262+ let processed = processor.process_records(records).unwrap();
263263+ assert_eq!(processed.len(), 1);
264264+ assert_eq!(processed[0].collection, "test.collection");
265265+ assert!(processed[0].content_cid.is_none());
266266+ }
267267+268268+ #[test]
269269+ fn test_process_signed_record() {
270270+ let signer =
271271+ AttestationSigner::for_testing("did:web:test#key".into(), "test.signature".into());
272272+ let processor = SyncProcessor::new(Some(&signer), "did:plc:testuser".to_string());
273273+274274+ let records = vec![SyncRecord {
275275+ collection: "games.gamesgamesgamesgames.actor.game".into(),
276276+ record: serde_json::json!({
277277+ "$type": "games.gamesgamesgamesgames.actor.game",
278278+ "game": {"platform": "steam", "externalId": "440"},
279279+ "platform": "steam",
280280+ "createdAt": "2024-01-01T00:00:00Z"
281281+ }),
282282+ dedup_key: Some("steam:game:440".into()),
283283+ sign: true,
284284+ }];
285285+286286+ let processed = processor.process_records(records).unwrap();
287287+ assert_eq!(processed.len(), 1);
288288+ assert!(processed[0].content_cid.is_some());
289289+290290+ // Verify signatures array was added
291291+ let signatures = processed[0].record["signatures"].as_array();
292292+ assert!(signatures.is_some());
293293+ assert_eq!(signatures.unwrap().len(), 1);
294294+ }
295295+296296+ #[test]
297297+ fn test_sign_requested_but_no_signer() {
298298+ let processor = SyncProcessor::new(None, "did:plc:testuser".to_string());
299299+300300+ let records = vec![SyncRecord {
301301+ collection: "test.collection".into(),
302302+ record: serde_json::json!({"data": "hello"}),
303303+ dedup_key: None,
304304+ sign: true, // Requested but no signer
305305+ }];
306306+307307+ let processed = processor.process_records(records).unwrap();
308308+ assert_eq!(processed.len(), 1);
309309+ // No error, but no CID either
310310+ assert!(processed[0].content_cid.is_none());
311311+ }
312312+}
+3
src/plugin/types.rs
···7474 pub record: serde_json::Value,
7575 #[serde(skip_serializing_if = "Option::is_none")]
7676 pub dedup_key: Option<String>,
7777+ /// Whether HappyView should add an attestation signature to this record
7878+ #[serde(default)]
7979+ pub sign: bool,
7780}
78817982/// Strong reference to an AT Protocol record
···11+# This file is automatically @generated by Cargo.
22+# It is not intended for manual editing.
33+version = 4
44+55+[[package]]
66+name = "test-plugin"
77+version = "0.1.0"