add frontend

This commit is contained in:
Bradley Nelson
2026-06-16 21:26:36 -06:00
parent 87d68c5b6f
commit 803150635c
32 changed files with 1654 additions and 152 deletions
+1
View File
@@ -3,6 +3,7 @@
pub mod caldav_adapter;
pub mod carddav_adapter;
pub mod plugin_lifecycle_hook;
pub mod plugin_user_lifecycle_hook;
pub mod webdav_adapter;
#[cfg(test)]
@@ -13,7 +13,7 @@ use uuid::Uuid;
use crate::application::ports::file_lifecycle::FileLifecycleHook;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::plugin_ports::{
EVENT_FILE_UPLOADED, FileUploadedEvent, PluginDispatchPort,
EVENT_FILE_UPLOADED, PluginDispatchPort, PluginEvent,
};
use crate::application::services::FileRetrievalService;
@@ -59,12 +59,15 @@ impl PluginLifecycleHook {
}
};
dispatch.dispatch_file_uploaded(FileUploadedEvent {
path: dto.path,
size: dto.size,
mime: dto.mime_type.to_string(),
dispatch.dispatch(PluginEvent {
name: EVENT_FILE_UPLOADED,
user_id: dto.owner_id,
invocation_id: Uuid::new_v4().to_string(),
payload: serde_json::json!({
"path": dto.path,
"size": dto.size,
"mime": dto.mime_type.to_string(),
}),
});
});
}
@@ -0,0 +1,160 @@
//! Bridges the [`UserLifecycleHook`] fan-out to the plugin runtime.
//!
//! `UserLifecycleService` already notifies hooks on user create/login/logout/
//! delete. This adapter turns the *login* event into a `user.login` plugin
//! event. It references only the [`PluginDispatchPort`] trait (not Extism), so
//! it is always compiled and the Extism dependency stays in the infrastructure
//! layer.
//!
//! Privacy note: the `user.login` payload includes the user's email — PII handed
//! to untrusted plugins with no permission gate in M0. This is acceptable only
//! because plugins are admin-installed today; when the permissions system lands,
//! sensitive payload fields should be gated behind a granted permission.
use std::sync::Arc;
use async_trait::async_trait;
use uuid::Uuid;
use crate::application::ports::plugin_ports::{EVENT_USER_LOGIN, PluginDispatchPort, PluginEvent};
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
use crate::common::errors::DomainError;
use crate::domain::entities::user::User;
/// Lifecycle hook that forwards successful logins to subscribed plugins.
pub struct PluginUserLifecycleHook {
dispatch: Arc<dyn PluginDispatchPort>,
}
impl PluginUserLifecycleHook {
pub fn new(dispatch: Arc<dyn PluginDispatchPort>) -> Self {
Self { dispatch }
}
}
#[async_trait]
impl UserLifecycleHook for PluginUserLifecycleHook {
fn name(&self) -> &'static str {
"plugins"
}
async fn on_user_login(&self, user: &User) -> Result<(), DomainError> {
if self.dispatch.has_subscribers(EVENT_USER_LOGIN) {
self.dispatch.dispatch(PluginEvent {
name: EVENT_USER_LOGIN,
user_id: Some(user.id().to_string()),
invocation_id: Uuid::new_v4().to_string(),
payload: serde_json::json!({
"user_id": user.id().to_string(),
"username": user.username(),
"email": user.email(),
"first_login": user.last_login_at().is_none(),
"is_external": user.is_external(),
}),
});
}
// Returns immediately — dispatch is fire-and-forget (the runtime runs the
// plugin on the blocking pool), so login latency is unaffected.
Ok(())
}
// M0 emits only `user.login`. The trait forces an explicit decision on the
// other three events; they are deliberate no-ops (reserved for future events
// like `user.created` / `user.deleted`).
async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> {
Ok(())
}
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
Ok(())
}
async fn on_user_deleted(
&self,
_user: &User,
_mode: DeletionMode,
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), DomainError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
use crate::domain::entities::user::UserRole;
/// Records dispatched events so the test can assert the bridge built the
/// right `user.login` event without a runtime or DB.
#[derive(Default)]
struct RecordingDispatch {
events: Mutex<Vec<PluginEvent>>,
}
impl PluginDispatchPort for RecordingDispatch {
fn dispatch(&self, event: PluginEvent) {
self.events.lock().unwrap().push(event);
}
fn has_subscribers(&self, _event: &str) -> bool {
true
}
}
#[tokio::test]
async fn on_user_login_dispatches_user_login_event() {
let recorder = Arc::new(RecordingDispatch::default());
let hook = PluginUserLifecycleHook::new(recorder.clone());
let user = User::new(
"alice@example.com".to_string(),
Some("alice".to_string()),
None,
None,
None,
UserRole::User,
0,
false,
)
.unwrap();
hook.on_user_login(&user).await.unwrap();
let events = recorder.events.lock().unwrap();
assert_eq!(events.len(), 1, "exactly one event dispatched");
let ev = &events[0];
assert_eq!(ev.name, EVENT_USER_LOGIN);
assert_eq!(ev.user_id.as_deref(), Some(user.id().to_string().as_str()));
assert_eq!(ev.payload["email"], "alice@example.com");
assert_eq!(ev.payload["username"], "alice");
assert_eq!(ev.payload["first_login"], true); // last_login_at is None
assert_eq!(ev.payload["is_external"], false);
}
#[tokio::test]
async fn skips_dispatch_when_no_subscribers() {
struct NoSubscribers;
impl PluginDispatchPort for NoSubscribers {
fn dispatch(&self, _event: PluginEvent) {
panic!("must not dispatch when nothing subscribes");
}
fn has_subscribers(&self, _event: &str) -> bool {
false
}
}
let hook = PluginUserLifecycleHook::new(Arc::new(NoSubscribers));
let user = User::new(
"bob@example.com".to_string(),
None,
None,
None,
None,
UserRole::User,
0,
false,
)
.unwrap();
hook.on_user_login(&user).await.unwrap();
}
}