From a605dc58eb19bbe4f69ebddfa0f242cf384fa53c Mon Sep 17 00:00:00 2001 From: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co> Date: Fri, 10 Jul 2026 16:49:30 -0700 Subject: [PATCH] feat: add owner-gated community hard delete Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co> --- crates/buzz-db/src/lib.rs | 115 ++++++++++++++++++ crates/buzz-db/src/migration.rs | 31 ++++- crates/buzz-relay/src/api/operator.rs | 115 +++++++++++++++++- .../src/handlers/community_provisioning.rs | 65 ++++++++++ crates/buzz-relay/src/router.rs | 4 + migrations/0007_community_cascade_delete.sql | 72 +++++++++++ schema/schema.sql | 56 +++++---- 7 files changed, 434 insertions(+), 24 deletions(-) create mode 100644 migrations/0007_community_cascade_delete.sql diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index dd00ab1ff4..337f18f9fb 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -532,6 +532,54 @@ impl Db { })) } + /// Hard-deletes a community when `owner_pubkey` is still its current owner. + /// + /// The community and matching owner row are locked before deletion so an + /// owner rotation cannot race authorization. All tenant-scoped rows are + /// removed by the community foreign keys' `ON DELETE CASCADE` actions. + /// Returns the deleted community, or `None` when the host does not exist or + /// the asserted pubkey is not its owner. + pub async fn delete_community_owned_by( + &self, + normalized_host: &str, + owner_pubkey: &str, + ) -> Result> { + let mut tx = self.pool.begin().await?; + let row = sqlx::query( + r#" + SELECT c.id, c.host + FROM communities c + JOIN relay_members rm ON rm.community_id = c.id + WHERE lower(c.host) = lower($1) + AND lower(rm.pubkey) = lower($2) + AND rm.role = 'owner' + FOR UPDATE OF c, rm + "#, + ) + .bind(normalized_host) + .bind(owner_pubkey) + .fetch_optional(&mut *tx) + .await?; + + let Some(row) = row else { + tx.rollback().await?; + return Ok(None); + }; + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + + Ok(Some(CommunityRecord { + id: CommunityId::from_uuid(id), + host, + })) + } + /// Returns the community that owns a channel, if the channel exists. /// /// Internal relay producers use this to derive tenant context from the row @@ -3055,6 +3103,73 @@ mod tests { assert!(post_rotation_retry.is_none()); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn hard_delete_requires_current_owner_and_cascades_tenant_rows() { + let db = setup_db().await; + let host = format!("hard-delete-{}.example", Uuid::new_v4().simple()); + let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + let original = db + .create_community_with_owner(&host, owner) + .await + .expect("create community") + .expect("new host"); + sqlx::query( + "INSERT INTO pubkey_allowlist (community_id, pubkey) VALUES ($1, decode($2, 'hex'))", + ) + .bind(original.id.as_uuid()) + .bind(other) + .execute(&db.pool) + .await + .expect("insert tenant child row"); + + assert!(db + .delete_community_owned_by(&host, other) + .await + .expect("reject non-owner delete") + .is_none()); + assert!(db + .lookup_community_by_host(&host) + .await + .expect("lookup retained community") + .is_some()); + + let deleted = db + .delete_community_owned_by(&host.to_ascii_uppercase(), owner) + .await + .expect("delete owned community") + .expect("community deleted"); + assert_eq!(deleted.id, original.id); + assert_eq!(deleted.host, host); + assert!(db + .lookup_community_by_host(&host) + .await + .expect("lookup deleted community") + .is_none()); + + let child_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM pubkey_allowlist WHERE community_id = $1") + .bind(original.id.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("count cascaded children"); + assert_eq!(child_count, 0); + assert!(db + .delete_community_owned_by(&host, owner) + .await + .expect("repeat delete") + .is_none()); + + let recreated = db + .create_community_with_owner(&host, owner) + .await + .expect("recreate community") + .expect("deleted host reusable"); + assert_ne!(recreated.id, original.id); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn concurrent_same_owner_create_returns_the_winning_row_to_both_callers() { diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index b255e79cbe..7fbc902ea8 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -471,7 +471,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 6); + assert_eq!(migrations.len(), 7); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -555,6 +555,18 @@ mod tests { ); } assert!(!migrations[0].sql.as_str().contains("moderation_reports")); + + assert_eq!(migrations[6].version, 7); + assert_eq!(&*migrations[6].description, "community cascade delete"); + assert_eq!( + migrations[6] + .sql + .as_str() + .matches("REFERENCES communities(id) ON DELETE CASCADE") + .count(), + 22, + "migration 0007 must replace all 22 parent-table community foreign keys" + ); } #[test] @@ -769,5 +781,22 @@ mod tests { ); assert!(exists, "migration should create {table}"); } + + let non_cascading_community_fks: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = 'communities'::regclass + AND confdeltype <> 'c' + "#, + ) + .fetch_one(&pool) + .await + .expect("count non-cascading community foreign keys"); + assert_eq!( + non_cascading_community_fks, 0, + "every direct community foreign key, including partition clones, must cascade" + ); } } diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 4c3be6e2be..38123bd934 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use axum::{ - extract::{Query, RawQuery, State}, + extract::{Path, Query, RawQuery, State}, http::{HeaderMap, StatusCode}, response::Json, }; @@ -15,7 +15,8 @@ use serde::Deserialize; use serde_json::Value; use crate::handlers::community_provisioning::{ - normalize_candidate_host, validate_pubkey_hex, ProvisionCommunityRequest, + normalize_candidate_host, validate_pubkey_hex, DeleteCommunityRequest, + ProvisionCommunityRequest, }; use crate::state::AppState; @@ -171,6 +172,51 @@ pub async fn provision_community( } } +/// Permanently delete a community after validating an operator-proxied owner request. +pub async fn delete_community( + State(state): State>, + headers: HeaderMap, + Path(host): Path, + RawQuery(raw_query): RawQuery, + Query(request): Query, +) -> Result, (StatusCode, Json)> { + let path = format!("/operator/communities/{host}"); + let pubkey = authorize_operator_request( + &state, + &headers, + "DELETE", + &path, + raw_query.as_deref(), + None, + ) + .await?; + + match crate::handlers::community_provisioning::delete_community( + &state, + &pubkey, + &host, + &request.owner_pubkey, + ) + .await + { + Ok(response) => Ok(Json(serde_json::to_value(response).map_err(|e| { + tracing::error!("failed to serialize delete-community response: {e}"); + internal_error("operator delete response serialization failed") + })?)), + Err(msg) if msg.starts_with("actor not authorized") => { + Err(api_error(StatusCode::FORBIDDEN, &msg)) + } + Err(msg) if msg == "community not found or owner does not match" => { + Err(api_error(StatusCode::NOT_FOUND, &msg)) + } + Err(msg) if msg.starts_with("failed to delete community:") => { + tracing::error!(error = %msg, "operator community deletion failed"); + Err(internal_error("operator community deletion failed")) + } + Err(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)), + } +} + /// List communities where a pubkey currently holds the `owner` role. pub async fn list_owned_communities( State(state): State>, @@ -500,6 +546,71 @@ mod tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn delete_requires_matching_owner_and_removes_community() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let outsider = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("delete-community-{}.example", Uuid::new_v4().simple()); + let original = state + .db + .create_community_with_owner(&host, &owner.public_key().to_hex()) + .await + .expect("create community") + .expect("new community"); + + let outsider_query = format!("owner_pubkey={}", outsider.public_key().to_hex()); + let outsider_url = + format!("http://{INGRESS_HOST}/operator/communities/{host}?{outsider_query}"); + let outsider_auth = nip98_auth_header(&operator, &outsider_url, "DELETE", None); + let response = build_router(state.clone()) + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/operator/communities/{host}?{outsider_query}")) + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, outsider_auth) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let owner_query = format!("owner_pubkey={}", owner.public_key().to_hex()); + let owner_url = format!("http://{INGRESS_HOST}/operator/communities/{host}?{owner_query}"); + let owner_auth = nip98_auth_header(&operator, &owner_url, "DELETE", None); + let response = build_router(state.clone()) + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/operator/communities/{host}?{owner_query}")) + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, owner_auth) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!(json.get("status").and_then(Value::as_str), Some("deleted")); + assert_eq!( + json.get("community_id").and_then(Value::as_str), + Some(original.id.to_string().as_str()) + ); + assert!(state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup community") + .is_none()); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn happy_path_create_returns_created_and_bootstraps_owner() { diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index d80d6be1dc..97d66ff2a2 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -53,6 +53,24 @@ pub struct ProvisionCommunityRequest { pub create_only: bool, } +/// Query parameters for `DELETE /operator/communities/{host}`. +#[derive(Debug, Deserialize)] +pub struct DeleteCommunityRequest { + /// Pubkey asserted by the operator proxy as the deleting community owner. + pub owner_pubkey: String, +} + +/// JSON response from `DELETE /operator/communities/{host}`. +#[derive(Debug, Serialize)] +pub struct DeleteCommunityResponse { + /// UUID of the community row that was permanently deleted. + pub community_id: String, + /// Canonical host stored on the deleted community row. + pub host: String, + /// Always `deleted` for a successful response. + pub status: &'static str, +} + /// JSON response from `POST /operator/communities`. #[derive(Debug, Serialize)] pub struct ProvisionCommunityResponse { @@ -309,6 +327,53 @@ pub async fn provision_community( }) } +/// Permanently delete a community after verifying the operator's owner assertion. +/// +/// The NIP-98 signer is a deployment operator acting as a trusted proxy. The +/// asserted owner must still hold `role = 'owner'`; the database checks that +/// predicate atomically with deletion so a concurrent owner rotation cannot +/// authorize deletion with stale state. +pub async fn delete_community( + state: &Arc, + operator_pubkey: &nostr::PublicKey, + host: &str, + owner_pubkey: &str, +) -> Result { + let operator_hex = operator_pubkey.to_hex(); + if !state + .config + .relay_operator_pubkeys + .iter() + .any(|pk| pk == &operator_hex) + { + return Err("actor not authorized: not a relay operator".to_string()); + } + + let normalized_host = normalize_candidate_host(host)?; + let owner_hex = validate_pubkey_hex(owner_pubkey) + .ok_or_else(|| "invalid owner_pubkey: expected 64-char hex pubkey".to_string())?; + let deleted = state + .db + .delete_community_owned_by(&normalized_host, &owner_hex) + .await + .map_err(|e| format!("failed to delete community: {e}"))? + .ok_or_else(|| "community not found or owner does not match".to_string())?; + + info!( + operator = %operator_hex, + community = %deleted.id, + host = %deleted.host, + owner = %owner_hex, + "community permanently deleted via operator endpoint" + ); + + Ok(DeleteCommunityResponse { + community_id: deleted.id.to_string(), + host: deleted.host, + status: "deleted", + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 5f4bbee7e2..885ee62877 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -68,6 +68,10 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities/availability", get(api::operator::community_availability), ) + .route( + "/operator/communities/{host}", + axum::routing::delete(api::operator::delete_community), + ) // Relay invites: mint (owner/admin) + claim (membership-gate exempt) .route("/api/invites", post(api::invites::mint_invite)) .route("/api/invites/claim", post(api::invites::claim_invite)) diff --git a/migrations/0007_community_cascade_delete.sql b/migrations/0007_community_cascade_delete.sql new file mode 100644 index 0000000000..c136820cb3 --- /dev/null +++ b/migrations/0007_community_cascade_delete.sql @@ -0,0 +1,72 @@ +-- Hard community deletion is intentionally implemented at the ownership root. +-- Every tenant-scoped row has a direct community foreign key, so cascading +-- these 22 parent-table constraints makes one DELETE atomic and complete. +-- PostgreSQL propagates the events/delivery_log parent constraint changes to +-- their partitions; partition constraint clones must not be altered directly. + +ALTER TABLE api_tokens + DROP CONSTRAINT api_tokens_community_id_fkey, + ADD CONSTRAINT api_tokens_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE archived_identities + DROP CONSTRAINT archived_identities_community_id_fkey, + ADD CONSTRAINT archived_identities_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE audit_log + DROP CONSTRAINT audit_log_community_id_fkey, + ADD CONSTRAINT audit_log_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE channel_members + DROP CONSTRAINT channel_members_community_id_fkey, + ADD CONSTRAINT channel_members_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE channels + DROP CONSTRAINT channels_community_id_fkey, + ADD CONSTRAINT channels_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE community_bans + DROP CONSTRAINT community_bans_community_id_fkey, + ADD CONSTRAINT community_bans_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE delivery_log + DROP CONSTRAINT delivery_log_community_id_fkey, + ADD CONSTRAINT delivery_log_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE event_mentions + DROP CONSTRAINT event_mentions_community_id_fkey, + ADD CONSTRAINT event_mentions_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE events + DROP CONSTRAINT events_community_id_fkey, + ADD CONSTRAINT events_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE git_repo_names + DROP CONSTRAINT git_repo_names_community_id_fkey, + ADD CONSTRAINT git_repo_names_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE moderation_actions + DROP CONSTRAINT moderation_actions_community_id_fkey, + ADD CONSTRAINT moderation_actions_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE moderation_reports + DROP CONSTRAINT moderation_reports_community_id_fkey, + ADD CONSTRAINT moderation_reports_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE pubkey_allowlist + DROP CONSTRAINT pubkey_allowlist_community_id_fkey, + ADD CONSTRAINT pubkey_allowlist_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE reactions + DROP CONSTRAINT reactions_community_id_fkey, + ADD CONSTRAINT reactions_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE relay_members + DROP CONSTRAINT relay_members_community_id_fkey, + ADD CONSTRAINT relay_members_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE scheduled_workflow_fires + DROP CONSTRAINT scheduled_workflow_fires_community_id_fkey, + ADD CONSTRAINT scheduled_workflow_fires_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE subscriptions + DROP CONSTRAINT subscriptions_community_id_fkey, + ADD CONSTRAINT subscriptions_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE thread_metadata + DROP CONSTRAINT thread_metadata_community_id_fkey, + ADD CONSTRAINT thread_metadata_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE users + DROP CONSTRAINT users_community_id_fkey, + ADD CONSTRAINT users_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE workflow_approvals + DROP CONSTRAINT workflow_approvals_community_id_fkey, + ADD CONSTRAINT workflow_approvals_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE workflow_runs + DROP CONSTRAINT workflow_runs_community_id_fkey, + ADD CONSTRAINT workflow_runs_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE workflows + DROP CONSTRAINT workflows_community_id_fkey, + ADD CONSTRAINT workflows_community_id_fkey FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; diff --git a/schema/schema.sql b/schema/schema.sql index e31347d726..f1c0379b95 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -71,7 +71,7 @@ CREATE UNIQUE INDEX idx_communities_host ON communities (lower(host)); CREATE TABLE channels ( id UUID NOT NULL DEFAULT gen_random_uuid(), - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, channel_type channel_type NOT NULL DEFAULT 'stream', visibility channel_visibility NOT NULL DEFAULT 'open', @@ -130,7 +130,7 @@ CREATE TRIGGER trg_channels_community_id_immutable -- Conformance: "Channels and channel membership". PK leads with community_id. CREATE TABLE channel_members ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, channel_id UUID NOT NULL, pubkey BYTEA NOT NULL, role member_role NOT NULL DEFAULT 'member', @@ -152,7 +152,7 @@ CREATE INDEX idx_channel_members_pubkey ON channel_members (community_id, pubkey -- (community, pubkey): the same key reposts kind:0 in each community it joins. CREATE TABLE users ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, pubkey BYTEA NOT NULL, nip05_handle VARCHAR(255), display_name VARCHAR(255), @@ -188,7 +188,7 @@ CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id) -- (community_id, created_at, id) dedupes within one, allows across. CREATE TABLE events ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, id BYTEA NOT NULL, pubkey BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL, @@ -272,7 +272,7 @@ CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); -- mentions (Max, verified at event.rs:222). CREATE TABLE event_mentions ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, pubkey_hex VARCHAR(64) NOT NULL, event_id BYTEA NOT NULL, event_created_at TIMESTAMPTZ NOT NULL, @@ -290,7 +290,7 @@ CREATE INDEX idx_event_mentions_pubkey_kind_created -- Conformance: "Mesh, agents, ACP/MCP, and CLI" (persisted subscriptions). CREATE TABLE subscriptions ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, id VARCHAR(255) NOT NULL, owner_pubkey BYTEA NOT NULL, filter_kinds JSONB, @@ -315,7 +315,7 @@ CREATE TABLE subscriptions ( -- attribution; child of subscriptions. CREATE TABLE delivery_log ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, id BIGINT GENERATED ALWAYS AS IDENTITY, subscription_id VARCHAR(255), event_id BYTEA, @@ -348,7 +348,7 @@ CREATE INDEX idx_delivery_log_community_sub ON delivery_log (community_id, subsc -- community fixed at create from req.community; runs/approvals inherit it. CREATE TABLE workflows ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, id UUID NOT NULL DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, owner_pubkey BYTEA NOT NULL, @@ -372,7 +372,7 @@ CREATE INDEX idx_workflows_enabled ON workflows (enabled, status) WHERE enabled; -- ── Workflow runs ───────────────────────────────────────────────────────────── CREATE TABLE workflow_runs ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, id UUID NOT NULL DEFAULT gen_random_uuid(), workflow_id UUID NOT NULL, status run_status NOT NULL DEFAULT 'pending', @@ -397,7 +397,7 @@ CREATE INDEX idx_workflow_runs_status ON workflow_runs (community_id, status); -- community's same hash (conformance). CREATE TABLE workflow_approvals ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, token BYTEA NOT NULL, workflow_id UUID NOT NULL, run_id UUID NOT NULL, @@ -437,7 +437,7 @@ CREATE INDEX idx_workflow_approvals_status ON workflow_approvals (community_id, -- claim row. workflow_runs are not pruned today, so this is a guardrail, not a path. CREATE TABLE scheduled_workflow_fires ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, workflow_id UUID NOT NULL, scheduled_for TIMESTAMPTZ NOT NULL, claimed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -458,7 +458,7 @@ CREATE INDEX idx_scheduled_fires_claimed_at ON scheduled_workflow_fires (claimed -- (community_id, token_hash); channel claims reference channels in same community. CREATE TABLE api_tokens ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, id UUID NOT NULL DEFAULT gen_random_uuid(), token_hash BYTEA NOT NULL, owner_pubkey BYTEA NOT NULL, @@ -498,7 +498,7 @@ CREATE TABLE rate_limit_violations ( -- Conformance: thread lookups filter by community before event matching. CREATE TABLE thread_metadata ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, event_created_at TIMESTAMPTZ NOT NULL, event_id BYTEA NOT NULL, channel_id UUID NOT NULL, @@ -525,7 +525,7 @@ CREATE INDEX idx_thread_metadata_event_id ON thread_metadata (community_id, even -- Conformance: reactions filter by community before event/pubkey matching. CREATE TABLE reactions ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, event_created_at TIMESTAMPTZ NOT NULL, event_id BYTEA NOT NULL, pubkey BYTEA NOT NULL, @@ -547,7 +547,7 @@ CREATE UNIQUE INDEX idx_reactions_source_event ON reactions (community_id, react -- PK becomes (community_id, pubkey). CREATE TABLE pubkey_allowlist ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, pubkey BYTEA NOT NULL, added_by BYTEA, added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -560,7 +560,7 @@ CREATE TABLE pubkey_allowlist ( -- (unchanged wire form). PK (community_id, pubkey). CREATE TABLE relay_members ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, pubkey TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), added_by TEXT, @@ -575,7 +575,7 @@ CREATE INDEX idx_relay_members_role ON relay_members (community_id, role); -- Conformance: archive cannot hide a key in another community. PK scoped. CREATE TABLE archived_identities ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, pubkey TEXT NOT NULL, consent_path TEXT NOT NULL CHECK (consent_path IN ('self', 'owner', 'admin')), actor TEXT NOT NULL, @@ -592,7 +592,7 @@ CREATE TABLE archived_identities ( -- (Lane Audit/Dawn builds the chain logic; Lane 0 fixes the scoped schema.) CREATE TABLE audit_log ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, seq BIGINT NOT NULL, hash BYTEA NOT NULL, prev_hash BYTEA, @@ -612,7 +612,7 @@ CREATE UNIQUE INDEX idx_audit_log_hash ON audit_log (community_id, hash); -- moderators in the queue but never revealed to the reported author. CREATE TABLE moderation_reports ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, id UUID NOT NULL DEFAULT gen_random_uuid(), -- The signed kind:1984 event id (stored for audit/idempotency). report_event_id BYTEA NOT NULL CHECK (length(report_event_id) = 32), @@ -669,7 +669,7 @@ CREATE UNIQUE INDEX idx_moderation_reports_event -- A row may be ban-only, timeout-only, or both over its lifetime. CREATE TABLE community_bans ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, pubkey BYTEA NOT NULL CHECK (length(pubkey) = 32), banned BOOLEAN NOT NULL DEFAULT false, -- NULL + banned=true ⇒ permanent. @@ -691,7 +691,7 @@ CREATE TABLE community_bans ( -- tombstone carries only action_id + reason_code + sanitized public_reason. CREATE TABLE moderation_actions ( - community_id UUID NOT NULL REFERENCES communities(id), + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, id UUID NOT NULL DEFAULT gen_random_uuid(), actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), action TEXT NOT NULL CHECK (action IN ( @@ -722,6 +722,20 @@ CREATE INDEX idx_moderation_actions_target_pubkey ON moderation_actions (community_id, target_pubkey) WHERE target_pubkey IS NOT NULL; +-- ── Git repo name registry (NIP-34 kind:30617) ─────────────────────────────── +-- Final-state snapshot of migration 0002 plus the cascade policy in 0007. + +CREATE TABLE git_repo_names ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + repo_id TEXT NOT NULL, + owner_pubkey TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, repo_id) +); + +CREATE INDEX idx_git_repo_names_owner + ON git_repo_names (community_id, owner_pubkey); + -- Same-community resolution provenance: a report can only be resolved by an -- action row in its own community. Added after moderation_actions exists. ALTER TABLE moderation_reports