m20260512_000001_fediverse_protocol_schema.rs 532 lignes · 18112 octets
//! Migration `m20260512_000001_fediverse_protocol_schema`.
//!
//! Fused migration that creates every fediverse-protocol-only table
//! in one step. Replaces (and supersedes) the granular legacy
//! migrations `m20260510_000010/000012/000013/000015` that used to
//! live in `rustwarden-core/migrations`.
//!
//! - `fediverse_trusted_domains` + `fediverse_blocked_domains` (RFC 0004)
//! - seeds for default `app_settings` fediverse keys (RFC 0004)
//! - `fediverse_outbound_queue` (RFC 0011)
//! - `fediverse_auth_challenges` (RFC 0006)
//! - `fediverse_pending_invites` (RFC 0009)
//!
//! Indexes on the core `team_members` table that enforce remote-row
//! uniqueness live in the core `fediverse_interop` migration instead,
//! since they target a core-owned relation.

use sea_orm_migration::prelude::*;
use sea_orm_migration::sea_orm::ConnectionTrait;
use sea_orm_migration::sea_orm::sea_query::{Expr, OnConflict, Query};
use uuid::Uuid;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        // ---- RFC 0004: trust model -----------------------------------
        manager
            .create_table(
                Table::create()
                    .table(FediverseTrustedDomains::Table)
                    .col(
                        ColumnDef::new(FediverseTrustedDomains::Id)
                            .uuid()
                            .not_null()
                            .primary_key(),
                    )
                    .col(
                        ColumnDef::new(FediverseTrustedDomains::Domain)
                            .text()
                            .not_null()
                            .unique_key(),
                    )
                    .col(ColumnDef::new(FediverseTrustedDomains::Note).text().null())
                    .col(
                        ColumnDef::new(FediverseTrustedDomains::AddedByUserId)
                            .uuid()
                            .null(),
                    )
                    .col(
                        ColumnDef::new(FediverseTrustedDomains::CreatedAt)
                            .timestamp_with_time_zone()
                            .not_null(),
                    )
                    .to_owned(),
            )
            .await?;

        manager
            .create_table(
                Table::create()
                    .table(FediverseBlockedDomains::Table)
                    .col(
                        ColumnDef::new(FediverseBlockedDomains::Id)
                            .uuid()
                            .not_null()
                            .primary_key(),
                    )
                    .col(
                        ColumnDef::new(FediverseBlockedDomains::Domain)
                            .text()
                            .not_null()
                            .unique_key(),
                    )
                    .col(
                        ColumnDef::new(FediverseBlockedDomains::Severity)
                            .text()
                            .not_null()
                            .default("suspend"),
                    )
                    .col(
                        ColumnDef::new(FediverseBlockedDomains::Reason)
                            .text()
                            .null(),
                    )
                    .col(
                        ColumnDef::new(FediverseBlockedDomains::AddedByUserId)
                            .uuid()
                            .null(),
                    )
                    .col(
                        ColumnDef::new(FediverseBlockedDomains::CreatedAt)
                            .timestamp_with_time_zone()
                            .not_null(),
                    )
                    .to_owned(),
            )
            .await?;

        // Seed default app_settings keys. Idempotent via ON CONFLICT.
        // `app_settings` is a core table created by an earlier core
        // migration; this seeding is fediverse-specific but writes into
        // a shared table, so it lives here on purpose. Bound parameters
        // are used rather than `format!()` so the pattern stays safe if
        // any seed value ever becomes dynamic.
        for (key, value) in [
            ("fediverse_enabled", "false"),
            ("fediverse_trust_mode", "allowlist"),
            ("fediverse_interop_scope", "rustwarden_only"),
            ("fediverse_authorized_fetch", "false"),
            ("fediverse_node_name", "Rustwarden"),
            ("fediverse_node_description", ""),
        ] {
            let insert = Query::insert()
                .into_table(AppSettings::Table)
                .columns([
                    AppSettings::Id,
                    AppSettings::Key,
                    AppSettings::Value,
                    AppSettings::UpdatedAt,
                ])
                .values_panic([
                    Uuid::new_v4().into(),
                    key.into(),
                    value.into(),
                    Expr::current_timestamp(),
                ])
                .on_conflict(OnConflict::column(AppSettings::Key).do_nothing().to_owned())
                .to_owned();
            manager.get_connection().execute(&insert).await?;
        }

        // ---- RFC 0011: outbound delivery queue ------------------------
        manager
            .create_table(
                Table::create()
                    .table(FediverseOutboundQueue::Table)
                    .col(
                        ColumnDef::new(FediverseOutboundQueue::Id)
                            .uuid()
                            .not_null()
                            .primary_key(),
                    )
                    .col(
                        ColumnDef::new(FediverseOutboundQueue::ActivityId)
                            .text()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediverseOutboundQueue::PayloadJson)
                            .text()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediverseOutboundQueue::TargetInbox)
                            .text()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediverseOutboundQueue::TargetDomain)
                            .text()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediverseOutboundQueue::Status)
                            .text()
                            .not_null()
                            .default("pending"),
                    )
                    .col(
                        ColumnDef::new(FediverseOutboundQueue::Attempts)
                            .integer()
                            .not_null()
                            .default(0),
                    )
                    .col(
                        ColumnDef::new(FediverseOutboundQueue::MaxAttempts)
                            .integer()
                            .not_null()
                            .default(8),
                    )
                    .col(
                        ColumnDef::new(FediverseOutboundQueue::LastError)
                            .text()
                            .null(),
                    )
                    .col(
                        ColumnDef::new(FediverseOutboundQueue::NextRetryAt)
                            .timestamp_with_time_zone()
                            .null(),
                    )
                    .col(
                        ColumnDef::new(FediverseOutboundQueue::CreatedAt)
                            .timestamp_with_time_zone()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediverseOutboundQueue::DeliveredAt)
                            .timestamp_with_time_zone()
                            .null(),
                    )
                    .col(
                        ColumnDef::new(FediverseOutboundQueue::ActorUserId)
                            .uuid()
                            .null(),
                    )
                    .to_owned(),
            )
            .await?;

        manager
            .create_index(
                Index::create()
                    .name("fediverse_outbound_pending_idx")
                    .table(FediverseOutboundQueue::Table)
                    .col(FediverseOutboundQueue::Status)
                    .col(FediverseOutboundQueue::NextRetryAt)
                    .to_owned(),
            )
            .await?;

        // ---- RFC 0006: client-side auth challenges -------------------
        manager
            .create_table(
                Table::create()
                    .table(FediverseAuthChallenges::Table)
                    .col(
                        ColumnDef::new(FediverseAuthChallenges::Id)
                            .uuid()
                            .not_null()
                            .primary_key(),
                    )
                    .col(
                        ColumnDef::new(FediverseAuthChallenges::Nonce)
                            .text()
                            .not_null()
                            .unique_key(),
                    )
                    .col(
                        ColumnDef::new(FediverseAuthChallenges::Handle)
                            .text()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediverseAuthChallenges::Domain)
                            .text()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediverseAuthChallenges::RequestedRemoteId)
                            .uuid()
                            .null(),
                    )
                    .col(
                        ColumnDef::new(FediverseAuthChallenges::Status)
                            .text()
                            .not_null()
                            .default("pending"),
                    )
                    .col(
                        ColumnDef::new(FediverseAuthChallenges::RedirectUrl)
                            .text()
                            .null(),
                    )
                    .col(
                        ColumnDef::new(FediverseAuthChallenges::CreatedAt)
                            .timestamp_with_time_zone()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediverseAuthChallenges::ExpiresAt)
                            .timestamp_with_time_zone()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediverseAuthChallenges::ConsumedAt)
                            .timestamp_with_time_zone()
                            .null(),
                    )
                    .to_owned(),
            )
            .await?;

        manager
            .create_index(
                Index::create()
                    .name("fediverse_auth_challenges_pending_idx")
                    .table(FediverseAuthChallenges::Table)
                    .col(FediverseAuthChallenges::Status)
                    .col(FediverseAuthChallenges::ExpiresAt)
                    .to_owned(),
            )
            .await?;

        // ---- RFC 0009: cross-instance team invites -------------------
        manager
            .create_table(
                Table::create()
                    .table(FediversePendingInvites::Table)
                    .col(
                        ColumnDef::new(FediversePendingInvites::Id)
                            .uuid()
                            .not_null()
                            .primary_key(),
                    )
                    .col(
                        ColumnDef::new(FediversePendingInvites::ActivityId)
                            .text()
                            .not_null()
                            .unique_key(),
                    )
                    .col(
                        ColumnDef::new(FediversePendingInvites::TeamId)
                            .uuid()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediversePendingInvites::InvitedHandle)
                            .text()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediversePendingInvites::InvitedRemoteIdentityId)
                            .uuid()
                            .null(),
                    )
                    .col(
                        ColumnDef::new(FediversePendingInvites::Role)
                            .text()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediversePendingInvites::InvitedByUserId)
                            .uuid()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediversePendingInvites::Status)
                            .text()
                            .not_null()
                            .default("pending"),
                    )
                    .col(
                        ColumnDef::new(FediversePendingInvites::CreatedAt)
                            .timestamp_with_time_zone()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediversePendingInvites::ExpiresAt)
                            .timestamp_with_time_zone()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(FediversePendingInvites::DecidedAt)
                            .timestamp_with_time_zone()
                            .null(),
                    )
                    .to_owned(),
            )
            .await?;

        manager
            .get_connection()
            .execute_unprepared(
                "CREATE INDEX fediverse_pending_invites_status_idx \
                 ON fediverse_pending_invites (status, expires_at)",
            )
            .await?;

        Ok(())
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        let conn = manager.get_connection();
        conn.execute_unprepared("DROP INDEX IF EXISTS fediverse_pending_invites_status_idx")
            .await?;
        manager
            .drop_table(
                Table::drop()
                    .table(FediversePendingInvites::Table)
                    .to_owned(),
            )
            .await?;

        manager
            .drop_index(
                Index::drop()
                    .name("fediverse_auth_challenges_pending_idx")
                    .table(FediverseAuthChallenges::Table)
                    .to_owned(),
            )
            .await?;
        manager
            .drop_table(
                Table::drop()
                    .table(FediverseAuthChallenges::Table)
                    .to_owned(),
            )
            .await?;

        manager
            .drop_index(
                Index::drop()
                    .name("fediverse_outbound_pending_idx")
                    .table(FediverseOutboundQueue::Table)
                    .to_owned(),
            )
            .await?;
        manager
            .drop_table(
                Table::drop()
                    .table(FediverseOutboundQueue::Table)
                    .to_owned(),
            )
            .await?;

        manager
            .drop_table(
                Table::drop()
                    .table(FediverseBlockedDomains::Table)
                    .to_owned(),
            )
            .await?;
        manager
            .drop_table(
                Table::drop()
                    .table(FediverseTrustedDomains::Table)
                    .to_owned(),
            )
            .await?;

        // Best-effort: remove the seeded settings. Parameter-bound for
        // safety even though the keys are literals.
        for key in [
            "fediverse_enabled",
            "fediverse_trust_mode",
            "fediverse_interop_scope",
            "fediverse_authorized_fetch",
            "fediverse_node_name",
            "fediverse_node_description",
        ] {
            let delete = Query::delete()
                .from_table(AppSettings::Table)
                .and_where(Expr::col(AppSettings::Key).eq(key))
                .to_owned();
            conn.execute(&delete).await?;
        }

        Ok(())
    }
}

#[derive(DeriveIden)]
enum AppSettings {
    Table,
    Id,
    Key,
    Value,
    UpdatedAt,
}

#[derive(DeriveIden)]
enum FediverseTrustedDomains {
    Table,
    Id,
    Domain,
    Note,
    AddedByUserId,
    CreatedAt,
}

#[derive(DeriveIden)]
enum FediverseBlockedDomains {
    Table,
    Id,
    Domain,
    Severity,
    Reason,
    AddedByUserId,
    CreatedAt,
}

#[derive(DeriveIden)]
enum FediverseOutboundQueue {
    Table,
    Id,
    ActivityId,
    PayloadJson,
    TargetInbox,
    TargetDomain,
    Status,
    Attempts,
    MaxAttempts,
    LastError,
    NextRetryAt,
    CreatedAt,
    DeliveredAt,
    ActorUserId,
}

#[derive(DeriveIden)]
enum FediverseAuthChallenges {
    Table,
    Id,
    Nonce,
    Handle,
    Domain,
    RequestedRemoteId,
    Status,
    RedirectUrl,
    CreatedAt,
    ExpiresAt,
    ConsumedAt,
}

#[derive(DeriveIden)]
enum FediversePendingInvites {
    Table,
    Id,
    ActivityId,
    TeamId,
    InvitedHandle,
    InvitedRemoteIdentityId,
    Role,
    InvitedByUserId,
    Status,
    CreatedAt,
    ExpiresAt,
    DecidedAt,
}