diff --git a/drizzle.config.ts b/drizzle.config.ts index 6c6b8c7..d537eeb 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ './src/db/schema/behavioral-flags.ts', './src/db/schema/pds-trust-factors.ts', './src/db/schema/pages.ts', + './src/db/schema/plugins.ts', ], out: './drizzle', dialect: 'postgresql', diff --git a/drizzle/0010_mature_madrox.sql b/drizzle/0010_mature_madrox.sql new file mode 100644 index 0000000..97e08c6 --- /dev/null +++ b/drizzle/0010_mature_madrox.sql @@ -0,0 +1,39 @@ +CREATE TABLE "plugin_permissions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "permission" text NOT NULL, + "granted_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "plugin_permissions_plugin_id_permission_unique" UNIQUE("plugin_id","permission") +); +--> statement-breakpoint +ALTER TABLE "plugin_permissions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "plugin_settings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "key" text NOT NULL, + "value" jsonb NOT NULL, + CONSTRAINT "plugin_settings_plugin_id_key_unique" UNIQUE("plugin_id","key") +); +--> statement-breakpoint +ALTER TABLE "plugin_settings" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "plugins" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "display_name" text NOT NULL, + "version" text NOT NULL, + "description" text NOT NULL, + "source" text NOT NULL, + "category" text NOT NULL, + "enabled" boolean DEFAULT false NOT NULL, + "manifest_json" jsonb NOT NULL, + "installed_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "plugins_name_unique" UNIQUE("name") +); +--> statement-breakpoint +ALTER TABLE "plugins" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "plugin_permissions" ADD CONSTRAINT "plugin_permissions_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_settings" ADD CONSTRAINT "plugin_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE POLICY "plugin_permissions_instance_wide" ON "plugin_permissions" AS PERMISSIVE FOR ALL TO "barazo_app" USING (true);--> statement-breakpoint +CREATE POLICY "plugin_settings_instance_wide" ON "plugin_settings" AS PERMISSIVE FOR ALL TO "barazo_app" USING (true);--> statement-breakpoint +CREATE POLICY "plugins_instance_wide" ON "plugins" AS PERMISSIVE FOR ALL TO "barazo_app" USING (true); \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..e71698f --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,4288 @@ +{ + "id": "518f80c8-6efa-48f3-a1c5-35a93911b0ba", + "prevId": "2e645169-605f-48d4-8fae-e20020f0d918", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.users": { + "name": "users", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_banned": { + "name": "is_banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reputation_score": { + "name": "reputation_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "declared_age": { + "name": "declared_age", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "maturity_pref": { + "name": "maturity_pref", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'safe'" + }, + "account_created_at": { + "name": "account_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "followers_count": { + "name": "followers_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "follows_count": { + "name": "follows_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "atproto_posts_count": { + "name": "atproto_posts_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "has_bluesky_profile": { + "name": "has_bluesky_profile", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "atproto_labels": { + "name": "atproto_labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "users_role_elevated_idx": { + "name": "users_role_elevated_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "role IN ('moderator', 'admin')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_handle_idx": { + "name": "users_handle_idx", + "columns": [ + { + "expression": "handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_account_created_at_idx": { + "name": "users_account_created_at_idx", + "columns": [ + { + "expression": "account_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.firehose_cursor": { + "name": "firehose_cursor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "cursor": { + "name": "cursor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.topics": { + "name": "topics", + "schema": "", + "columns": { + "uri": { + "name": "uri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reply_count": { + "name": "reply_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reaction_count": { + "name": "reaction_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "vote_count": { + "name": "vote_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_scope": { + "name": "pinned_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_mod_deleted": { + "name": "is_mod_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_author_deleted": { + "name": "is_author_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'approved'" + }, + "trust_status": { + "name": "trust_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'trusted'" + } + }, + "indexes": { + "topics_author_did_idx": { + "name": "topics_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_category_idx": { + "name": "topics_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_created_at_idx": { + "name": "topics_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_last_activity_at_idx": { + "name": "topics_last_activity_at_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_community_did_idx": { + "name": "topics_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_moderation_status_idx": { + "name": "topics_moderation_status_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_trust_status_idx": { + "name": "topics_trust_status_idx", + "columns": [ + { + "expression": "trust_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_community_category_activity_idx": { + "name": "topics_community_category_activity_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_pinned_scope_idx": { + "name": "topics_pinned_scope_idx", + "columns": [ + { + "expression": "pinned_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_author_did_rkey_idx": { + "name": "topics_author_did_rkey_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rkey", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.replies": { + "name": "replies", + "schema": "", + "columns": { + "uri": { + "name": "uri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_uri": { + "name": "root_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_cid": { + "name": "root_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_uri": { + "name": "parent_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_cid": { + "name": "parent_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reaction_count": { + "name": "reaction_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "vote_count": { + "name": "vote_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_author_deleted": { + "name": "is_author_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_mod_deleted": { + "name": "is_mod_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'approved'" + }, + "trust_status": { + "name": "trust_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'trusted'" + } + }, + "indexes": { + "replies_author_did_idx": { + "name": "replies_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_root_uri_idx": { + "name": "replies_root_uri_idx", + "columns": [ + { + "expression": "root_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_parent_uri_idx": { + "name": "replies_parent_uri_idx", + "columns": [ + { + "expression": "parent_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_created_at_idx": { + "name": "replies_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_community_did_idx": { + "name": "replies_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_moderation_status_idx": { + "name": "replies_moderation_status_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_trust_status_idx": { + "name": "replies_trust_status_idx", + "columns": [ + { + "expression": "trust_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_root_uri_created_at_idx": { + "name": "replies_root_uri_created_at_idx", + "columns": [ + { + "expression": "root_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_root_uri_depth_idx": { + "name": "replies_root_uri_depth_idx", + "columns": [ + { + "expression": "root_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depth", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_author_did_rkey_idx": { + "name": "replies_author_did_rkey_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rkey", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "uri": { + "name": "uri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_uri": { + "name": "subject_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_cid": { + "name": "subject_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reactions_author_did_idx": { + "name": "reactions_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_subject_uri_idx": { + "name": "reactions_subject_uri_idx", + "columns": [ + { + "expression": "subject_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_community_did_idx": { + "name": "reactions_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_subject_uri_type_idx": { + "name": "reactions_subject_uri_type_idx", + "columns": [ + { + "expression": "subject_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reactions_author_subject_type_uniq": { + "name": "reactions_author_subject_type_uniq", + "nullsNotDistinct": false, + "columns": ["author_did", "subject_uri", "type"] + } + }, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.votes": { + "name": "votes", + "schema": "", + "columns": { + "uri": { + "name": "uri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_uri": { + "name": "subject_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_cid": { + "name": "subject_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "votes_author_did_idx": { + "name": "votes_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "votes_subject_uri_idx": { + "name": "votes_subject_uri_idx", + "columns": [ + { + "expression": "subject_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "votes_community_did_idx": { + "name": "votes_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "votes_author_subject_uniq": { + "name": "votes_author_subject_uniq", + "nullsNotDistinct": false, + "columns": ["author_did", "subject_uri"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_repos": { + "name": "tracked_repos", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tracked_at": { + "name": "tracked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_settings": { + "name": "community_settings", + "schema": "", + "columns": { + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "domains": { + "name": "domains", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "initialized": { + "name": "initialized", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "admin_did": { + "name": "admin_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "community_name": { + "name": "community_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Barazo Community'" + }, + "maturity_rating": { + "name": "maturity_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'safe'" + }, + "reaction_set": { + "name": "reaction_set", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"like\"]'::jsonb" + }, + "moderation_thresholds": { + "name": "moderation_thresholds", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"autoBlockReportCount\":5,\"warnThreshold\":3,\"firstPostQueueCount\":0,\"newAccountDays\":7,\"newAccountWriteRatePerMin\":3,\"establishedWriteRatePerMin\":10,\"linkHoldEnabled\":false,\"topicCreationDelayEnabled\":false,\"burstPostCount\":5,\"burstWindowMinutes\":10,\"trustedPostThreshold\":10}'::jsonb" + }, + "word_filter": { + "name": "word_filter", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "jurisdiction_country": { + "name": "jurisdiction_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "age_threshold": { + "name": "age_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 16 + }, + "max_reply_depth": { + "name": "max_reply_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 9999 + }, + "require_login_for_mature": { + "name": "require_login_for_mature", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "community_description": { + "name": "community_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_endpoint": { + "name": "service_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_key": { + "name": "rotation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "community_logo_url": { + "name": "community_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "header_logo_url": { + "name": "header_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "show_community_name": { + "name": "show_community_name", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "maturity_rating": { + "name": "maturity_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'safe'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "categories_slug_community_did_idx": { + "name": "categories_slug_community_did_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categories_parent_id_idx": { + "name": "categories_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categories_community_did_idx": { + "name": "categories_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categories_maturity_rating_idx": { + "name": "categories_maturity_rating_idx", + "columns": [ + { + "expression": "maturity_rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "categories_parent_id_fk": { + "name": "categories_parent_id_fk", + "tableFrom": "categories", + "tableTo": "categories", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.moderation_actions": { + "name": "moderation_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_uri": { + "name": "target_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_did": { + "name": "target_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moderator_did": { + "name": "moderator_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mod_actions_moderator_did_idx": { + "name": "mod_actions_moderator_did_idx", + "columns": [ + { + "expression": "moderator_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_actions_community_did_idx": { + "name": "mod_actions_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_actions_created_at_idx": { + "name": "mod_actions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_actions_target_uri_idx": { + "name": "mod_actions_target_uri_idx", + "columns": [ + { + "expression": "target_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_actions_target_did_idx": { + "name": "mod_actions_target_did_idx", + "columns": [ + { + "expression": "target_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "reporter_did": { + "name": "reporter_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_uri": { + "name": "target_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_did": { + "name": "target_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason_type": { + "name": "reason_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "resolution_type": { + "name": "resolution_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "appeal_reason": { + "name": "appeal_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "appealed_at": { + "name": "appealed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "appeal_status": { + "name": "appeal_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_reporter_did_idx": { + "name": "reports_reporter_did_idx", + "columns": [ + { + "expression": "reporter_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_target_uri_idx": { + "name": "reports_target_uri_idx", + "columns": [ + { + "expression": "target_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_target_did_idx": { + "name": "reports_target_did_idx", + "columns": [ + { + "expression": "target_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_community_did_idx": { + "name": "reports_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_created_at_idx": { + "name": "reports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_unique_reporter_target_idx": { + "name": "reports_unique_reporter_target_idx", + "columns": [ + { + "expression": "reporter_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "recipient_did": { + "name": "recipient_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_uri": { + "name": "subject_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_did": { + "name": "actor_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_recipient_did_idx": { + "name": "notifications_recipient_did_idx", + "columns": [ + { + "expression": "recipient_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_recipient_read_idx": { + "name": "notifications_recipient_read_idx", + "columns": [ + { + "expression": "recipient_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_at_idx": { + "name": "notifications_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user_community_preferences": { + "name": "user_community_preferences", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "maturity_override": { + "name": "maturity_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "muted_words": { + "name": "muted_words", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "blocked_dids": { + "name": "blocked_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "muted_dids": { + "name": "muted_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_community_prefs_did_idx": { + "name": "user_community_prefs_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_community_prefs_community_idx": { + "name": "user_community_prefs_community_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_community_preferences_did_community_did_pk": { + "name": "user_community_preferences_did_community_did_pk", + "columns": ["did", "community_did"] + } + }, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "maturity_level": { + "name": "maturity_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sfw'" + }, + "declared_age": { + "name": "declared_age", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "muted_words": { + "name": "muted_words", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_dids": { + "name": "blocked_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "muted_dids": { + "name": "muted_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cross_post_bluesky": { + "name": "cross_post_bluesky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cross_post_frontpage": { + "name": "cross_post_frontpage", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cross_post_scopes_granted": { + "name": "cross_post_scopes_granted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cross_posts": { + "name": "cross_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "topic_uri": { + "name": "topic_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cross_post_uri": { + "name": "cross_post_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cross_post_cid": { + "name": "cross_post_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cross_posts_topic_uri_idx": { + "name": "cross_posts_topic_uri_idx", + "columns": [ + { + "expression": "topic_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cross_posts_author_did_idx": { + "name": "cross_posts_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_onboarding_fields": { + "name": "community_onboarding_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_mandatory": { + "name": "is_mandatory", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboarding_fields_community_idx": { + "name": "onboarding_fields_community_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user_onboarding_responses": { + "name": "user_onboarding_responses", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_id": { + "name": "field_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboarding_responses_did_community_idx": { + "name": "onboarding_responses_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_onboarding_responses_did_community_did_field_id_pk": { + "name": "user_onboarding_responses_did_community_did_field_id_pk", + "columns": ["did", "community_did", "field_id"] + } + }, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.moderation_queue": { + "name": "moderation_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "content_uri": { + "name": "content_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queue_reason": { + "name": "queue_reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "matched_words": { + "name": "matched_words", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mod_queue_author_did_idx": { + "name": "mod_queue_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_queue_community_did_idx": { + "name": "mod_queue_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_queue_status_idx": { + "name": "mod_queue_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_queue_created_at_idx": { + "name": "mod_queue_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_queue_content_uri_idx": { + "name": "mod_queue_content_uri_idx", + "columns": [ + { + "expression": "content_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.account_trust": { + "name": "account_trust", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_post_count": { + "name": "approved_post_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_trusted": { + "name": "is_trusted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trusted_at": { + "name": "trusted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "account_trust_did_community_idx": { + "name": "account_trust_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_trust_did_idx": { + "name": "account_trust_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.community_filters": { + "name": "community_filters", + "schema": "", + "columns": { + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "admin_did": { + "name": "admin_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_count": { + "name": "report_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reviewed_at": { + "name": "last_reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filtered_by": { + "name": "filtered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "community_filters_status_idx": { + "name": "community_filters_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "community_filters_admin_did_idx": { + "name": "community_filters_admin_did_idx", + "columns": [ + { + "expression": "admin_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "community_filters_updated_at_idx": { + "name": "community_filters_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.account_filters": { + "name": "account_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_count": { + "name": "report_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ban_count": { + "name": "ban_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reviewed_at": { + "name": "last_reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filtered_by": { + "name": "filtered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_filters_did_community_idx": { + "name": "account_filters_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_filters_did_idx": { + "name": "account_filters_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_filters_community_did_idx": { + "name": "account_filters_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_filters_status_idx": { + "name": "account_filters_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_filters_updated_at_idx": { + "name": "account_filters_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.ozone_labels": { + "name": "ozone_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "src": { + "name": "src", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "val": { + "name": "val", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "neg": { + "name": "neg", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cts": { + "name": "cts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "exp": { + "name": "exp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ozone_labels_src_uri_val_idx": { + "name": "ozone_labels_src_uri_val_idx", + "columns": [ + { + "expression": "src", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "val", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ozone_labels_uri_idx": { + "name": "ozone_labels_uri_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ozone_labels_val_idx": { + "name": "ozone_labels_val_idx", + "columns": [ + { + "expression": "val", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ozone_labels_indexed_at_idx": { + "name": "ozone_labels_indexed_at_idx", + "columns": [ + { + "expression": "indexed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_profiles": { + "name": "community_profiles", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "community_profiles_did_idx": { + "name": "community_profiles_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "community_profiles_community_idx": { + "name": "community_profiles_community_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "community_profiles_did_community_did_pk": { + "name": "community_profiles_did_community_did_pk", + "columns": ["did", "community_did"] + } + }, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.interaction_graph": { + "name": "interaction_graph", + "schema": "", + "columns": { + "source_did": { + "name": "source_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_did": { + "name": "target_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_id": { + "name": "community_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interaction_type": { + "name": "interaction_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "first_interaction_at": { + "name": "first_interaction_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_interaction_at": { + "name": "last_interaction_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_graph_source_target_community_idx": { + "name": "interaction_graph_source_target_community_idx", + "columns": [ + { + "expression": "source_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "interaction_graph_source_did_target_did_community_id_interaction_type_pk": { + "name": "interaction_graph_source_did_target_did_community_id_interaction_type_pk", + "columns": ["source_did", "target_did", "community_id", "interaction_type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trust_seeds": { + "name": "trust_seeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_id": { + "name": "community_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trust_seeds_did_community_idx": { + "name": "trust_seeds_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trust_scores": { + "name": "trust_scores", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_id": { + "name": "community_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trust_scores_did_community_idx": { + "name": "trust_scores_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trust_scores_did_community_id_pk": { + "name": "trust_scores_did_community_id_pk", + "columns": ["did", "community_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sybil_clusters": { + "name": "sybil_clusters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "cluster_hash": { + "name": "cluster_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_edge_count": { + "name": "internal_edge_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "external_edge_count": { + "name": "external_edge_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "member_count": { + "name": "member_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'flagged'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sybil_clusters_hash_idx": { + "name": "sybil_clusters_hash_idx", + "columns": [ + { + "expression": "cluster_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sybil_cluster_members": { + "name": "sybil_cluster_members", + "schema": "", + "columns": { + "cluster_id": { + "name": "cluster_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_in_cluster": { + "name": "role_in_cluster", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sybil_cluster_members_cluster_id_sybil_clusters_id_fk": { + "name": "sybil_cluster_members_cluster_id_sybil_clusters_id_fk", + "tableFrom": "sybil_cluster_members", + "tableTo": "sybil_clusters", + "columnsFrom": ["cluster_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sybil_cluster_members_cluster_id_did_pk": { + "name": "sybil_cluster_members_cluster_id_did_pk", + "columns": ["cluster_id", "did"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.behavioral_flags": { + "name": "behavioral_flags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "flag_type": { + "name": "flag_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "affected_dids": { + "name": "affected_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "behavioral_flags_flag_type_idx": { + "name": "behavioral_flags_flag_type_idx", + "columns": [ + { + "expression": "flag_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "behavioral_flags_status_idx": { + "name": "behavioral_flags_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "behavioral_flags_detected_at_idx": { + "name": "behavioral_flags_detected_at_idx", + "columns": [ + { + "expression": "detected_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pds_trust_factors": { + "name": "pds_trust_factors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pds_host": { + "name": "pds_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trust_factor": { + "name": "trust_factor", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pds_trust_factors_pds_host_idx": { + "name": "pds_trust_factors_pds_host_idx", + "columns": [ + { + "expression": "pds_host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pages_slug_community_did_idx": { + "name": "pages_slug_community_did_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_community_did_idx": { + "name": "pages_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_parent_id_idx": { + "name": "pages_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_status_community_did_idx": { + "name": "pages_status_community_did_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pages_parent_id_fk": { + "name": "pages_parent_id_fk", + "tableFrom": "pages", + "tableTo": "pages", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.plugin_permissions": { + "name": "plugin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "plugin_permissions_plugin_id_plugins_id_fk": { + "name": "plugin_permissions_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permissions", + "tableTo": "plugins", + "columnsFrom": ["plugin_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugin_permissions_plugin_id_permission_unique": { + "name": "plugin_permissions_plugin_id_permission_unique", + "nullsNotDistinct": false, + "columns": ["plugin_id", "permission"] + } + }, + "policies": { + "plugin_permissions_instance_wide": { + "name": "plugin_permissions_instance_wide", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.plugin_settings": { + "name": "plugin_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": ["plugin_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugin_settings_plugin_id_key_unique": { + "name": "plugin_settings_plugin_id_key_unique", + "nullsNotDistinct": false, + "columns": ["plugin_id", "key"] + } + }, + "policies": { + "plugin_settings_instance_wide": { + "name": "plugin_settings_instance_wide", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugins_name_unique": { + "name": "plugins_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": { + "plugins_instance_wide": { + "name": "plugins_instance_wide", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["barazo_app"], + "using": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": { + "barazo_app": { + "name": "barazo_app", + "createDb": false, + "createRole": false, + "inherit": true + } + }, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b1ba229..888e441 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1772728814082, "tag": "0009_brainy_sunspot", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1772759004891, + "tag": "0010_mature_madrox", + "breakpoints": true } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5885a4..6592caa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,9 +48,6 @@ importers: '@atproto/tap': specifier: 0.2.7 version: 0.2.7 - '@barazo-forum/lexicons': - specifier: link:../barazo-lexicons - version: link:../barazo-lexicons '@fastify/cookie': specifier: 11.0.2 version: 11.0.2 @@ -81,6 +78,9 @@ importers: '@sentry/node': specifier: 10.41.0 version: 10.41.0 + '@singi-labs/lexicons': + specifier: link:../barazo-lexicons + version: link:../barazo-lexicons cborg: specifier: 4.5.8 version: 4.5.8 diff --git a/src/app.ts b/src/app.ts index 45c1fa3..93c898a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -45,6 +45,8 @@ import { communityProfileRoutes } from './routes/community-profiles.js' import { uploadRoutes } from './routes/uploads.js' import { adminSybilRoutes } from './routes/admin-sybil.js' import { adminDesignRoutes } from './routes/admin-design.js' +import { adminPluginRoutes } from './routes/admin-plugins.js' +import { discoverPlugins, syncPluginsToDb, validateAndFilterPlugins } from './lib/plugins/loader.js' import { createRequireAdmin } from './auth/require-admin.js' import { createRequireOperator } from './auth/require-operator.js' import { OzoneService } from './services/ozone.js' @@ -117,6 +119,21 @@ export async function buildApp(env: Env) { app.decorate('db', db) app.decorate('env', env) + // Plugin discovery and DB sync + const nodeModulesPath = new URL('../node_modules', import.meta.url).pathname + const discovered = await discoverPlugins(nodeModulesPath, app.log) + if (discovered.length > 0) { + const validManifests = validateAndFilterPlugins( + discovered.map((d) => d.manifest), + '0.1.0', + app.log + ) + app.log.info({ count: validManifests.length }, 'Plugins discovered') + await syncPluginsToDb(discovered, db, app.log) + } else { + app.log.info('No plugins discovered') + } + // Cache const cache = createCache(env.VALKEY_URL, app.log) app.decorate('cache', cache) @@ -154,7 +171,7 @@ export async function buildApp(env: Env) { await app.register(cors, { origin: env.CORS_ORIGINS.split(',').map((o) => o.trim()), credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], }) @@ -340,6 +357,7 @@ export async function buildApp(env: Env) { await app.register(uploadRoutes()) await app.register(adminSybilRoutes()) await app.register(adminDesignRoutes()) + await app.register(adminPluginRoutes()) // OpenAPI spec endpoint (after routes so all schemas are registered) app.get('/api/openapi.json', { schema: { hide: true } }, async (_request, reply) => { diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index ab844eb..0b9e3a7 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -28,3 +28,4 @@ export { sybilClusterMembers } from './sybil-cluster-members.js' export { behavioralFlags } from './behavioral-flags.js' export { pdsTrustFactors } from './pds-trust-factors.js' export { pages } from './pages.js' +export { plugins, pluginSettings, pluginPermissions } from './plugins.js' diff --git a/src/db/schema/plugins.ts b/src/db/schema/plugins.ts new file mode 100644 index 0000000..10594f7 --- /dev/null +++ b/src/db/schema/plugins.ts @@ -0,0 +1,81 @@ +import { + pgTable, + pgPolicy, + text, + boolean, + timestamp, + jsonb, + uuid, + unique, +} from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' + +export const plugins = pgTable( + 'plugins', + { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').unique().notNull(), + displayName: text('display_name').notNull(), + version: text('version').notNull(), + description: text('description').notNull(), + source: text('source', { + enum: ['core', 'official', 'community', 'experimental'], + }).notNull(), + category: text('category').notNull(), + enabled: boolean('enabled').notNull().default(false), + manifestJson: jsonb('manifest_json').notNull(), + installedAt: timestamp('installed_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + () => [ + pgPolicy('plugins_instance_wide', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`true`, + }), + ] +).enableRLS() + +export const pluginSettings = pgTable( + 'plugin_settings', + { + id: uuid('id').primaryKey().defaultRandom(), + pluginId: uuid('plugin_id') + .references(() => plugins.id, { onDelete: 'cascade' }) + .notNull(), + key: text('key').notNull(), + value: jsonb('value').notNull(), + }, + (table) => [ + unique('plugin_settings_plugin_id_key_unique').on(table.pluginId, table.key), + pgPolicy('plugin_settings_instance_wide', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`true`, + }), + ] +).enableRLS() + +export const pluginPermissions = pgTable( + 'plugin_permissions', + { + id: uuid('id').primaryKey().defaultRandom(), + pluginId: uuid('plugin_id') + .references(() => plugins.id, { onDelete: 'cascade' }) + .notNull(), + permission: text('permission').notNull(), + grantedAt: timestamp('granted_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique('plugin_permissions_plugin_id_permission_unique').on(table.pluginId, table.permission), + pgPolicy('plugin_permissions_instance_wide', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`true`, + }), + ] +).enableRLS() diff --git a/src/lib/plugins/context.ts b/src/lib/plugins/context.ts new file mode 100644 index 0000000..bfb4b0f --- /dev/null +++ b/src/lib/plugins/context.ts @@ -0,0 +1,80 @@ +import type { Logger } from '../logger.js' + +import type { PluginContext, PluginSettings, ScopedCache, ScopedDatabase } from './types.js' + +/** Adapter interface for the underlying cache (e.g. Valkey/ioredis). */ +export interface CacheAdapter { + get(key: string): Promise + set(key: string, value: string, ttlSeconds?: number): Promise + del(key: string): Promise +} + +export interface PluginContextOptions { + pluginName: string + pluginVersion: string + permissions: string[] + settings: Record + db: unknown + cache: CacheAdapter | null + logger: Logger + communityDid: string +} + +function createPluginSettings(values: Record): PluginSettings { + const copy = { ...values } + return { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- matches PluginSettings interface + get(key: string): T | undefined { + return copy[key] as T | undefined + }, + getAll(): Record { + return { ...copy } + }, + } +} + +function createScopedCache(cache: CacheAdapter, pluginName: string): ScopedCache { + const prefix = `plugin:${pluginName}:` + return { + get(key: string): Promise { + return cache.get(`${prefix}${key}`) + }, + set(key: string, value: string, ttlSeconds?: number): Promise { + return cache.set(`${prefix}${key}`, value, ttlSeconds) + }, + del(key: string): Promise { + return cache.del(`${prefix}${key}`) + }, + } +} + +function createScopedDatabase(db: unknown, _permissions: string[]): ScopedDatabase { + return { + execute(query: unknown): Promise { + return (db as { execute(q: unknown): Promise }).execute(query) + }, + query(_tableName: string): unknown { + throw new Error('ScopedDatabase.query() is not yet implemented') + }, + } +} + +export function createPluginContext(options: PluginContextOptions): PluginContext { + const { pluginName, pluginVersion, permissions, settings, db, cache, logger, communityDid } = + options + + const hasCachePermission = + permissions.includes('cache:read') || permissions.includes('cache:write') + + const scopedCache = hasCachePermission && cache ? createScopedCache(cache, pluginName) : undefined + + return { + pluginName, + pluginVersion, + communityDid, + db: createScopedDatabase(db, permissions), + settings: createPluginSettings(settings), + logger: logger.child({ plugin: pluginName }), + ...(scopedCache ? { cache: scopedCache } : {}), + } satisfies PluginContext +} diff --git a/src/lib/plugins/loader.ts b/src/lib/plugins/loader.ts new file mode 100644 index 0000000..92bc847 --- /dev/null +++ b/src/lib/plugins/loader.ts @@ -0,0 +1,209 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { sql } from 'drizzle-orm' +import type { Logger } from '../logger.js' + +import { pluginManifestSchema, type PluginManifest } from '../../validation/plugin-manifest.js' + +// --------------------------------------------------------------------------- +// Topological sort +// --------------------------------------------------------------------------- + +export function topologicalSort(manifests: PluginManifest[]): PluginManifest[] { + const nameToManifest = new Map() + for (const m of manifests) { + nameToManifest.set(m.name, m) + } + + const sorted: PluginManifest[] = [] + const visited = new Set() + const visiting = new Set() + + function visit(name: string): void { + if (visited.has(name)) return + if (visiting.has(name)) { + throw new Error(`Circular dependency detected involving plugin "${name}"`) + } + + visiting.add(name) + const manifest = nameToManifest.get(name) + if (manifest?.dependencies) { + for (const dep of manifest.dependencies) { + if (nameToManifest.has(dep)) { + visit(dep) + } + } + } + visiting.delete(name) + visited.add(name) + if (manifest) { + sorted.push(manifest) + } + } + + for (const m of manifests) { + visit(m.name) + } + + return sorted +} + +// --------------------------------------------------------------------------- +// Validate and filter +// --------------------------------------------------------------------------- + +export function validateAndFilterPlugins( + rawManifests: unknown[], + _barazoVersion: string, + logger: Logger +): PluginManifest[] { + const valid: PluginManifest[] = [] + + for (const raw of rawManifests) { + const result = pluginManifestSchema.safeParse(raw) + if (!result.success) { + const name = (raw as Record).name ?? 'unknown' + logger.warn({ name, errors: result.error.issues }, 'Skipping invalid plugin manifest') + continue + } + valid.push(result.data) + } + + // Check that all declared dependencies exist in the valid set + const validNames = new Set(valid.map((m) => m.name)) + const filtered: PluginManifest[] = [] + + for (const manifest of valid) { + const missingDeps = (manifest.dependencies ?? []).filter((dep) => !validNames.has(dep)) + if (missingDeps.length > 0) { + logger.warn( + { plugin: manifest.name, missingDeps }, + 'Skipping plugin with missing dependencies' + ) + continue + } + filtered.push(manifest) + } + + return filtered +} + +// --------------------------------------------------------------------------- +// Discover plugins from node_modules +// --------------------------------------------------------------------------- + +export async function discoverPlugins( + nodeModulesPath: string, + logger: Logger +): Promise<{ manifest: PluginManifest; packagePath: string }[]> { + const results: { manifest: PluginManifest; packagePath: string }[] = [] + + // Scan @barazo/plugin-* (scoped packages) + const scopedDir = join(nodeModulesPath, '@barazo') + try { + const entries = await readdir(scopedDir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith('plugin-')) { + const packagePath = join(scopedDir, entry.name) + const manifest = await tryReadManifest(packagePath, logger) + if (manifest) { + results.push({ manifest, packagePath }) + } + } + } + } catch { + // @barazo directory may not exist -- that is fine + } + + // Scan barazo-plugin-* (unscoped packages) + try { + const entries = await readdir(nodeModulesPath, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith('barazo-plugin-')) { + const packagePath = join(nodeModulesPath, entry.name) + const manifest = await tryReadManifest(packagePath, logger) + if (manifest) { + results.push({ manifest, packagePath }) + } + } + } + } catch { + // node_modules may not exist -- that is fine + } + + return results +} + +async function tryReadManifest( + packagePath: string, + logger: Logger +): Promise { + try { + const raw = await readFile(join(packagePath, 'plugin.json'), 'utf-8') + const parsed: unknown = JSON.parse(raw) + const result = pluginManifestSchema.safeParse(parsed) + if (!result.success) { + logger.warn({ packagePath, errors: result.error.issues }, 'Invalid plugin.json, skipping') + return null + } + return result.data + } catch { + // No plugin.json or unreadable -- skip silently + return null + } +} + +// --------------------------------------------------------------------------- +// Sync discovered plugins to database +// --------------------------------------------------------------------------- + +interface DbExecutor { + execute(query: unknown): Promise +} + +export async function syncPluginsToDb( + discovered: { manifest: PluginManifest; packagePath: string }[], + db: DbExecutor, + logger: Logger +): Promise { + for (const { manifest } of discovered) { + const manifestJson = JSON.stringify(manifest) + + // Upsert plugin -- new plugins are inserted as disabled + await db.execute(sql` + INSERT INTO plugins (id, name, display_name, version, description, source, category, enabled, manifest_json, installed_at, updated_at) + VALUES (gen_random_uuid(), ${manifest.name}, ${manifest.displayName}, ${manifest.version}, ${manifest.description}, ${manifest.source}, ${manifest.category}, false, ${manifestJson}::jsonb, now(), now()) + ON CONFLICT (name) DO UPDATE SET + version = EXCLUDED.version, + display_name = EXCLUDED.display_name, + description = EXCLUDED.description, + source = EXCLUDED.source, + category = EXCLUDED.category, + manifest_json = EXCLUDED.manifest_json, + updated_at = now() + `) + + // Sync permissions: delete old, insert current + const allPermissions = [...manifest.permissions.backend, ...manifest.permissions.frontend] + + await db.execute(sql` + DELETE FROM plugin_permissions + WHERE plugin_id = (SELECT id FROM plugins WHERE name = ${manifest.name}) + `) + + for (const permission of allPermissions) { + await db.execute(sql` + INSERT INTO plugin_permissions (id, plugin_id, permission, granted_at) + VALUES ( + gen_random_uuid(), + (SELECT id FROM plugins WHERE name = ${manifest.name}), + ${permission}, + now() + ) + `) + } + + logger.info({ plugin: manifest.name, version: manifest.version }, 'Synced plugin to database') + } +} diff --git a/src/lib/plugins/types.ts b/src/lib/plugins/types.ts new file mode 100644 index 0000000..339946b --- /dev/null +++ b/src/lib/plugins/types.ts @@ -0,0 +1,77 @@ +import type { Logger } from '../logger.js' + +/** Scoped database access for plugins -- queries are restricted to plugin-owned tables. */ +export interface ScopedDatabase { + execute(query: unknown): Promise + query(tableName: string): unknown +} + +/** Scoped AT Protocol operations (only available if plugin has pds:read or pds:write permission). */ +export interface ScopedAtProto { + getRecord(did: string, collection: string, rkey: string): Promise + putRecord(collection: string, rkey: string, record: unknown): Promise + deleteRecord(collection: string, rkey: string): Promise +} + +/** Scoped Valkey cache -- keys are auto-prefixed with plugin:: */ +export interface ScopedCache { + get(key: string): Promise + set(key: string, value: string, ttlSeconds?: number): Promise + del(key: string): Promise +} + +/** Scoped HTTP client (only available if plugin has http:outbound permission). */ +export interface ScopedHttp { + fetch(url: string, init?: RequestInit): Promise +} + +/** Read-only access to plugin settings configured by the community admin. */ +export interface PluginSettings { + get(key: string): unknown + getAll(): Record +} + +/** The sandbox API surface provided to every plugin. */ +export interface PluginContext { + readonly pluginName: string + readonly pluginVersion: string + readonly db: ScopedDatabase + readonly settings: PluginSettings + readonly atproto?: ScopedAtProto + readonly cache?: ScopedCache + readonly http?: ScopedHttp + readonly logger: Logger + readonly communityDid: string +} + +/** Lifecycle hooks that a plugin can implement. */ +export interface PluginHooks { + onInstall?(ctx: PluginContext): Promise + onUninstall?(ctx: PluginContext): Promise + onEnable?(ctx: PluginContext): Promise + onDisable?(ctx: PluginContext): Promise + onProfileSync?(ctx: PluginContext, userDid: string): Promise +} + +/** A validated plugin ready for initialization. */ +export interface LoadedPlugin { + name: string + displayName: string + version: string + description: string + source: 'core' | 'official' | 'community' | 'experimental' + category: string + manifest: Record + packagePath: string + hooks?: PluginHooks + routesPath?: string + migrationsPath?: string +} + +/** Thrown when a plugin attempts an operation it lacks permission for. */ +export class PluginPermissionError extends Error { + constructor(pluginName: string, operation: string) { + super(`Plugin "${pluginName}" does not have permission for: ${operation}`) + this.name = 'PluginPermissionError' + } +} diff --git a/src/routes/admin-plugins.ts b/src/routes/admin-plugins.ts new file mode 100644 index 0000000..48ffca1 --- /dev/null +++ b/src/routes/admin-plugins.ts @@ -0,0 +1,613 @@ +import { eq } from 'drizzle-orm' +import { execFile } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { promisify } from 'node:util' +import type { FastifyPluginCallback } from 'fastify' +import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' +import { updatePluginSettingsSchema, installPluginSchema } from '../validation/admin-plugins.js' +import { pluginManifestSchema } from '../validation/plugin-manifest.js' +import { plugins, pluginSettings } from '../db/schema/plugins.js' + +const execFileAsync = promisify(execFile) + +// --------------------------------------------------------------------------- +// OpenAPI JSON Schema definitions +// --------------------------------------------------------------------------- + +const pluginJsonSchema = { + type: 'object' as const, + properties: { + id: { type: 'string' as const }, + name: { type: 'string' as const }, + displayName: { type: 'string' as const }, + version: { type: 'string' as const }, + description: { type: 'string' as const }, + source: { type: 'string' as const }, + category: { type: 'string' as const }, + enabled: { type: 'boolean' as const }, + manifestJson: { type: 'object' as const }, + settings: { type: 'object' as const }, + installedAt: { type: 'string' as const, format: 'date-time' as const }, + updatedAt: { type: 'string' as const, format: 'date-time' as const }, + }, +} + +const pluginListJsonSchema = { + type: 'object' as const, + properties: { + plugins: { + type: 'array' as const, + items: pluginJsonSchema, + }, + }, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function serializePlugin(row: typeof plugins.$inferSelect, settings?: Record) { + return { + id: row.id, + name: row.name, + displayName: row.displayName, + version: row.version, + description: row.description, + source: row.source, + category: row.category, + enabled: row.enabled, + manifestJson: row.manifestJson, + settings: settings ?? {}, + installedAt: row.installedAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +// --------------------------------------------------------------------------- +// Manifest type for dependency checking +// --------------------------------------------------------------------------- + +interface ManifestJson { + name?: string + dependencies?: string[] + settings?: Record + [key: string]: unknown +} + +// --------------------------------------------------------------------------- +// Admin plugin routes +// --------------------------------------------------------------------------- + +/** + * Admin plugin management routes for the Barazo forum. + * + * - GET /api/plugins -- List all plugins with settings + * - GET /api/plugins/:id -- Get single plugin + * - PATCH /api/plugins/:id/enable -- Enable a plugin + * - PATCH /api/plugins/:id/disable -- Disable a plugin + * - PATCH /api/plugins/:id/settings -- Update plugin settings + * - DELETE /api/plugins/:id -- Uninstall a plugin + * - POST /api/plugins/install -- Install from npm + */ +export function adminPluginRoutes(): FastifyPluginCallback { + return (app, _opts, done) => { + const { db } = app + const requireAdmin = app.requireAdmin + + // ------------------------------------------------------------------- + // GET /api/plugins (admin only) + // ------------------------------------------------------------------- + + app.get( + '/api/plugins', + { + preHandler: [requireAdmin], + schema: { + tags: ['Plugins'], + summary: 'List all plugins with their settings', + security: [{ bearerAuth: [] }], + response: { + 200: pluginListJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + }, + }, + }, + async (_request, reply) => { + const allPlugins = await db.select().from(plugins) + const allSettings = await db.select().from(pluginSettings) + + // Group settings by pluginId + const settingsMap = new Map>() + for (const setting of allSettings) { + let map = settingsMap.get(setting.pluginId) + if (!map) { + map = {} + settingsMap.set(setting.pluginId, map) + } + map[setting.key] = setting.value + } + + return reply.status(200).send({ + plugins: allPlugins.map((p) => serializePlugin(p, settingsMap.get(p.id))), + }) + } + ) + + // ------------------------------------------------------------------- + // GET /api/plugins/:id (admin only) + // ------------------------------------------------------------------- + + app.get( + '/api/plugins/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Plugins'], + summary: 'Get single plugin details', + security: [{ bearerAuth: [] }], + params: { + type: 'object' as const, + properties: { + id: { type: 'string' as const }, + }, + required: ['id'], + }, + response: { + 200: pluginJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { id } = request.params as { id: string } + + const rows = await db.select().from(plugins).where(eq(plugins.id, id)) + + const plugin = rows[0] + if (!plugin) { + throw notFound('Plugin not found') + } + + const settings = await db + .select() + .from(pluginSettings) + .where(eq(pluginSettings.pluginId, id)) + + const settingsObj: Record = {} + for (const s of settings) { + settingsObj[s.key] = s.value + } + + return reply.status(200).send(serializePlugin(plugin, settingsObj)) + } + ) + + // ------------------------------------------------------------------- + // PATCH /api/plugins/:id/enable (admin only) + // ------------------------------------------------------------------- + + app.patch( + '/api/plugins/:id/enable', + { + preHandler: [requireAdmin], + schema: { + tags: ['Plugins'], + summary: 'Enable a plugin', + security: [{ bearerAuth: [] }], + params: { + type: 'object' as const, + properties: { + id: { type: 'string' as const }, + }, + required: ['id'], + }, + response: { + 200: pluginJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { id } = request.params as { id: string } + + const rows = await db.select().from(plugins).where(eq(plugins.id, id)) + + const plugin = rows[0] + if (!plugin) { + throw notFound('Plugin not found') + } + + if (plugin.enabled) { + return reply.status(200).send(serializePlugin(plugin)) + } + + // Check dependencies: all declared deps must be enabled + const manifest = plugin.manifestJson as ManifestJson + const deps = manifest.dependencies ?? [] + + if (deps.length > 0) { + const allPlugins = await db.select().from(plugins) + const enabledNames = new Set(allPlugins.filter((p) => p.enabled).map((p) => p.name)) + const missing = deps.filter((dep) => !enabledNames.has(dep)) + + if (missing.length > 0) { + throw badRequest(`Missing required dependencies: ${missing.join(', ')}`) + } + } + + const updated = await db + .update(plugins) + .set({ enabled: true, updatedAt: new Date() }) + .where(eq(plugins.id, id)) + .returning() + + const updatedPlugin = updated[0] + if (!updatedPlugin) { + throw notFound('Plugin not found after update') + } + + app.log.info( + { + event: 'plugin_enabled', + pluginId: id, + pluginName: plugin.name, + did: request.user?.did, + }, + 'Plugin enabled' + ) + + return reply.status(200).send(serializePlugin(updatedPlugin)) + } + ) + + // ------------------------------------------------------------------- + // PATCH /api/plugins/:id/disable (admin only) + // ------------------------------------------------------------------- + + app.patch( + '/api/plugins/:id/disable', + { + preHandler: [requireAdmin], + schema: { + tags: ['Plugins'], + summary: 'Disable a plugin', + security: [{ bearerAuth: [] }], + params: { + type: 'object' as const, + properties: { + id: { type: 'string' as const }, + }, + required: ['id'], + }, + response: { + 200: pluginJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { id } = request.params as { id: string } + + const rows = await db.select().from(plugins).where(eq(plugins.id, id)) + + const plugin = rows[0] + if (!plugin) { + throw notFound('Plugin not found') + } + + if (!plugin.enabled) { + return reply.status(200).send(serializePlugin(plugin)) + } + + // Check no enabled plugins depend on this one + const allPlugins = await db.select().from(plugins) + const dependents = allPlugins.filter((p) => { + if (!p.enabled || p.id === id) return false + const manifest = p.manifestJson as ManifestJson + const deps = manifest.dependencies ?? [] + return deps.includes(plugin.name) + }) + + if (dependents.length > 0) { + const names = dependents.map((d) => d.name).join(', ') + throw conflict( + `Cannot disable: the following enabled plugins depend on this one: ${names}` + ) + } + + const updated = await db + .update(plugins) + .set({ enabled: false, updatedAt: new Date() }) + .where(eq(plugins.id, id)) + .returning() + + const updatedPlugin = updated[0] + if (!updatedPlugin) { + throw notFound('Plugin not found after update') + } + + app.log.info( + { + event: 'plugin_disabled', + pluginId: id, + pluginName: plugin.name, + did: request.user?.did, + }, + 'Plugin disabled' + ) + + return reply.status(200).send(serializePlugin(updatedPlugin)) + } + ) + + // ------------------------------------------------------------------- + // PATCH /api/plugins/:id/settings (admin only) + // ------------------------------------------------------------------- + + app.patch( + '/api/plugins/:id/settings', + { + preHandler: [requireAdmin], + schema: { + tags: ['Plugins'], + summary: 'Update plugin settings', + security: [{ bearerAuth: [] }], + params: { + type: 'object' as const, + properties: { + id: { type: 'string' as const }, + }, + required: ['id'], + }, + body: { + type: 'object' as const, + additionalProperties: true, + }, + response: { + 200: { + type: 'object' as const, + properties: { + success: { type: 'boolean' as const }, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { id } = request.params as { id: string } + + const rows = await db.select().from(plugins).where(eq(plugins.id, id)) + + const plugin = rows[0] + if (!plugin) { + throw notFound('Plugin not found') + } + + const parsed = updatePluginSettingsSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid settings data') + } + + const entries = Object.entries(parsed.data) + for (const [key, value] of entries) { + await db + .insert(pluginSettings) + .values({ pluginId: id, key, value }) + .onConflictDoUpdate({ + target: [pluginSettings.pluginId, pluginSettings.key], + set: { value }, + }) + } + + app.log.info( + { + event: 'plugin_settings_updated', + pluginId: id, + pluginName: plugin.name, + keys: entries.map(([k]) => k), + did: request.user?.did, + }, + 'Plugin settings updated' + ) + + return reply.status(200).send({ success: true }) + } + ) + + // ------------------------------------------------------------------- + // DELETE /api/plugins/:id (admin only) + // ------------------------------------------------------------------- + + app.delete( + '/api/plugins/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Plugins'], + summary: 'Uninstall a plugin', + security: [{ bearerAuth: [] }], + params: { + type: 'object' as const, + properties: { + id: { type: 'string' as const }, + }, + required: ['id'], + }, + response: { + 204: { + type: 'null' as const, + description: 'Plugin uninstalled successfully', + }, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { id } = request.params as { id: string } + + const rows = await db.select().from(plugins).where(eq(plugins.id, id)) + + const plugin = rows[0] + if (!plugin) { + throw notFound('Plugin not found') + } + + // Core plugins cannot be uninstalled + if (plugin.source === 'core') { + throw conflict('Core plugins cannot be uninstalled') + } + + // Check no enabled plugins depend on this one + const allPlugins = await db.select().from(plugins) + const dependents = allPlugins.filter((p) => { + if (!p.enabled || p.id === id) return false + const manifest = p.manifestJson as ManifestJson + const deps = manifest.dependencies ?? [] + return deps.includes(plugin.name) + }) + + if (dependents.length > 0) { + const names = dependents.map((d) => d.name).join(', ') + throw conflict( + `Cannot uninstall: the following enabled plugins depend on this one: ${names}` + ) + } + + await db.delete(plugins).where(eq(plugins.id, id)) + + app.log.info( + { + event: 'plugin_uninstalled', + pluginId: id, + pluginName: plugin.name, + did: request.user?.did, + }, + 'Plugin uninstalled' + ) + + return reply.status(204).send() + } + ) + + // ------------------------------------------------------------------- + // POST /api/plugins/install (admin only) + // ------------------------------------------------------------------- + + app.post( + '/api/plugins/install', + { + preHandler: [requireAdmin], + schema: { + tags: ['Plugins'], + summary: 'Install a plugin from npm', + security: [{ bearerAuth: [] }], + body: { + type: 'object' as const, + properties: { + packageName: { type: 'string' as const }, + }, + required: ['packageName'], + }, + response: { + 200: pluginJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 409: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const parsed = installPluginSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid package name') + } + + const { packageName } = parsed.data + + // In SaaS mode, only @barazo scoped packages are allowed + if (app.env.HOSTING_MODE === 'saas' && !packageName.startsWith('@barazo/')) { + throw badRequest('Only @barazo scoped plugins are allowed in SaaS mode') + } + + // Extract bare name (without version specifier) for duplicate check + const bareName = packageName.replace(/@[\w.-]+$/, '') + + // Check not already installed + const existing = await db.select().from(plugins).where(eq(plugins.name, bareName)) + + if (existing.length > 0) { + throw conflict(`Plugin "${bareName}" is already installed`) + } + + // Install via npm + await execFileAsync('npm', ['install', '--ignore-scripts', packageName]) + + // Read plugin.json from installed package + const require = createRequire(import.meta.url) + const packageDir = require.resolve(`${bareName}/plugin.json`) + const manifestRaw = await readFile(packageDir, 'utf-8') + const manifestData: unknown = JSON.parse(manifestRaw) + + const manifestResult = pluginManifestSchema.safeParse(manifestData) + if (!manifestResult.success) { + throw badRequest('Invalid plugin manifest (plugin.json)') + } + + const manifest = manifestResult.data + + // Insert into plugins table (disabled by default) + const inserted = await db + .insert(plugins) + .values({ + name: manifest.name, + displayName: manifest.displayName, + version: manifest.version, + description: manifest.description, + source: manifest.source, + category: manifest.category, + enabled: false, + manifestJson: manifest, + }) + .returning() + + const newPlugin = inserted[0] + if (!newPlugin) { + throw badRequest('Failed to insert plugin') + } + + app.log.info( + { + event: 'plugin_installed', + pluginId: newPlugin.id, + pluginName: newPlugin.name, + version: newPlugin.version, + did: request.user?.did, + }, + 'Plugin installed' + ) + + return reply.status(200).send(serializePlugin(newPlugin)) + } + ) + + done() + } +} diff --git a/src/validation/admin-plugins.ts b/src/validation/admin-plugins.ts new file mode 100644 index 0000000..99972eb --- /dev/null +++ b/src/validation/admin-plugins.ts @@ -0,0 +1,16 @@ +import { z } from 'zod/v4' + +export const updatePluginSettingsSchema = z.record( + z.string(), + z.union([z.boolean(), z.string(), z.number()]) +) + +export const installPluginSchema = z.object({ + packageName: z + .string() + .min(1) + .regex( + /^(@barazo\/plugin-[\w-]+|barazo-plugin-[\w-]+)(@[\w.-]+)?$/, + 'Must match @barazo/plugin-* or barazo-plugin-* with optional version' + ), +}) diff --git a/src/validation/plugin-manifest.ts b/src/validation/plugin-manifest.ts new file mode 100644 index 0000000..7ed25b5 --- /dev/null +++ b/src/validation/plugin-manifest.ts @@ -0,0 +1,116 @@ +import { z } from 'zod/v4' + +// --------------------------------------------------------------------------- +// Plugin source enum +// --------------------------------------------------------------------------- + +const pluginSourceSchema = z.enum(['core', 'official', 'community', 'experimental']) + +export type PluginSource = z.infer + +// --------------------------------------------------------------------------- +// Plugin setting schemas (discriminated union on `type`) +// --------------------------------------------------------------------------- + +const booleanSettingSchema = z.object({ + type: z.literal('boolean'), + label: z.string().min(1), + description: z.string().optional(), + default: z.boolean(), +}) + +const stringSettingSchema = z.object({ + type: z.literal('string'), + label: z.string().min(1), + description: z.string().optional(), + default: z.string(), + placeholder: z.string().optional(), +}) + +const numberSettingSchema = z.object({ + type: z.literal('number'), + label: z.string().min(1), + description: z.string().optional(), + default: z.number(), + min: z.number().optional(), + max: z.number().optional(), +}) + +const selectSettingSchema = z.object({ + type: z.literal('select'), + label: z.string().min(1), + description: z.string().optional(), + default: z.string(), + options: z.array(z.string()).min(1), +}) + +const pluginSettingSchema = z.discriminatedUnion('type', [ + booleanSettingSchema, + stringSettingSchema, + numberSettingSchema, + selectSettingSchema, +]) + +export type PluginSettingSchema = z.infer + +// --------------------------------------------------------------------------- +// Plugin manifest schema +// --------------------------------------------------------------------------- + +/** Name must be scoped @barazo/plugin-* or unscoped barazo-plugin-*. */ +const pluginNamePattern = /^(@barazo\/plugin-[\w-]+|barazo-plugin-[\w-]+)$/ + +/** Strict semver: major.minor.patch with optional pre-release and build metadata. */ +const semverPattern = /^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/ + +/** Semver range expression (^, ~, >=, <, ||, *, x, etc.). */ +const semverRangePattern = /^[\^~>=<|*\s\d.x-]+$/ + +export const pluginManifestSchema = z.object({ + // Required fields + name: z + .string() + .regex(pluginNamePattern, 'Plugin name must match @barazo/plugin-* or barazo-plugin-*'), + displayName: z.string().min(1).max(100), + version: z.string().regex(semverPattern, 'Version must be valid semver (e.g. 1.0.0)'), + description: z.string().min(1).max(500), + barazoVersion: z.string().regex(semverRangePattern, 'barazoVersion must be a valid semver range'), + source: pluginSourceSchema, + category: z.string().min(1).max(50), + author: z.object({ + name: z.string().min(1), + url: z.string().optional(), + }), + license: z.string().min(1), + permissions: z.object({ + backend: z.array(z.string()), + frontend: z.array(z.string()), + }), + + // Optional fields + lexicons: z.array(z.string()).optional(), + dependencies: z.array(z.string()).optional(), + settings: z.record(z.string(), pluginSettingSchema).optional(), + hooks: z + .object({ + onInstall: z.string().optional(), + onUninstall: z.string().optional(), + onEnable: z.string().optional(), + onDisable: z.string().optional(), + onProfileSync: z.string().optional(), + }) + .optional(), + backend: z + .object({ + routes: z.string().optional(), + migrations: z.string().optional(), + }) + .optional(), + frontend: z + .object({ + register: z.string().optional(), + }) + .optional(), +}) + +export type PluginManifest = z.infer diff --git a/tests/unit/db/schema/plugins.test.ts b/tests/unit/db/schema/plugins.test.ts new file mode 100644 index 0000000..f07c639 --- /dev/null +++ b/tests/unit/db/schema/plugins.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { plugins, pluginSettings, pluginPermissions } from '../../../../src/db/schema/plugins.js' + +describe('plugins schema', () => { + const columns = getTableColumns(plugins) + + it('has the correct table name', () => { + expect(getTableName(plugins)).toBe('plugins') + }) + + it('has all required columns', () => { + const columnNames = Object.keys(columns) + + const expected = [ + 'id', + 'name', + 'displayName', + 'version', + 'description', + 'source', + 'category', + 'enabled', + 'manifestJson', + 'installedAt', + 'updatedAt', + ] + + for (const col of expected) { + expect(columnNames).toContain(col) + } + }) + + it('has non-nullable columns', () => { + expect(columns.id.notNull).toBe(true) + expect(columns.name.notNull).toBe(true) + expect(columns.displayName.notNull).toBe(true) + expect(columns.version.notNull).toBe(true) + expect(columns.description.notNull).toBe(true) + expect(columns.source.notNull).toBe(true) + expect(columns.category.notNull).toBe(true) + expect(columns.enabled.notNull).toBe(true) + expect(columns.manifestJson.notNull).toBe(true) + expect(columns.installedAt.notNull).toBe(true) + expect(columns.updatedAt.notNull).toBe(true) + }) + + it('has default values for enabled and timestamps', () => { + expect(columns.enabled.hasDefault).toBe(true) + expect(columns.installedAt.hasDefault).toBe(true) + expect(columns.updatedAt.hasDefault).toBe(true) + }) +}) + +describe('pluginSettings schema', () => { + const columns = getTableColumns(pluginSettings) + + it('has the correct table name', () => { + expect(getTableName(pluginSettings)).toBe('plugin_settings') + }) + + it('has all required columns', () => { + const columnNames = Object.keys(columns) + + const expected = ['id', 'pluginId', 'key', 'value'] + + for (const col of expected) { + expect(columnNames).toContain(col) + } + }) + + it('has non-nullable columns', () => { + expect(columns.id.notNull).toBe(true) + expect(columns.pluginId.notNull).toBe(true) + expect(columns.key.notNull).toBe(true) + expect(columns.value.notNull).toBe(true) + }) +}) + +describe('pluginPermissions schema', () => { + const columns = getTableColumns(pluginPermissions) + + it('has the correct table name', () => { + expect(getTableName(pluginPermissions)).toBe('plugin_permissions') + }) + + it('has all required columns', () => { + const columnNames = Object.keys(columns) + + const expected = ['id', 'pluginId', 'permission', 'grantedAt'] + + for (const col of expected) { + expect(columnNames).toContain(col) + } + }) + + it('has non-nullable columns', () => { + expect(columns.id.notNull).toBe(true) + expect(columns.pluginId.notNull).toBe(true) + expect(columns.permission.notNull).toBe(true) + expect(columns.grantedAt.notNull).toBe(true) + }) + + it('has default value for grantedAt', () => { + expect(columns.grantedAt.hasDefault).toBe(true) + }) +}) diff --git a/tests/unit/lib/plugins/context.test.ts b/tests/unit/lib/plugins/context.test.ts new file mode 100644 index 0000000..625ab2a --- /dev/null +++ b/tests/unit/lib/plugins/context.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createPluginContext } from '../../../../src/lib/plugins/context.js' +import type { PluginContextOptions } from '../../../../src/lib/plugins/context.js' + +function makeLogger() { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn().mockReturnThis(), + level: 'info', + } as unknown as PluginContextOptions['logger'] +} + +function makeCacheAdapter() { + return { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + del: vi.fn().mockResolvedValue(undefined), + } +} + +const BASE_OPTIONS: PluginContextOptions = { + pluginName: '@barazo/plugin-signatures', + pluginVersion: '1.2.0', + permissions: ['db:write:plugin_signatures'], + settings: { maxLength: 200, prefix: '--' }, + db: {}, + cache: null, + logger: makeLogger(), + communityDid: 'did:plc:testcommunity123', +} + +describe('createPluginContext', () => { + it('creates context with correct pluginName, pluginVersion, and communityDid', () => { + const ctx = createPluginContext({ ...BASE_OPTIONS, logger: makeLogger() }) + + expect(ctx.pluginName).toBe('@barazo/plugin-signatures') + expect(ctx.pluginVersion).toBe('1.2.0') + expect(ctx.communityDid).toBe('did:plc:testcommunity123') + }) + + it('settings.get() returns values and undefined for missing keys', () => { + const ctx = createPluginContext({ ...BASE_OPTIONS, logger: makeLogger() }) + + expect(ctx.settings.get('maxLength')).toBe(200) + expect(ctx.settings.get('prefix')).toBe('--') + expect(ctx.settings.get('nonexistent')).toBeUndefined() + }) + + it('settings.getAll() returns a copy of settings', () => { + const ctx = createPluginContext({ ...BASE_OPTIONS, logger: makeLogger() }) + const all = ctx.settings.getAll() + + expect(all).toEqual({ maxLength: 200, prefix: '--' }) + // Verify it is a copy, not the original reference + all['maxLength'] = 999 + expect(ctx.settings.get('maxLength')).toBe(200) + }) + + it('scoped cache prefixes keys with plugin:: for get/set/del', async () => { + const adapter = makeCacheAdapter() + const logger = makeLogger() + const ctx = createPluginContext({ + ...BASE_OPTIONS, + permissions: ['cache:read', 'cache:write'], + cache: adapter, + logger, + }) + + const cache = ctx.cache + expect(cache).toBeDefined() + if (!cache) throw new Error('Expected cache to be defined') + + await cache.get('mykey') + expect(adapter.get).toHaveBeenCalledWith('plugin:@barazo/plugin-signatures:mykey') + + await cache.set('mykey', 'val', 60) + expect(adapter.set).toHaveBeenCalledWith('plugin:@barazo/plugin-signatures:mykey', 'val', 60) + + await cache.del('mykey') + expect(adapter.del).toHaveBeenCalledWith('plugin:@barazo/plugin-signatures:mykey') + }) + + it('does not provide cache when no cache permissions', () => { + const adapter = makeCacheAdapter() + const ctx = createPluginContext({ + ...BASE_OPTIONS, + permissions: ['db:write:plugin_signatures'], + cache: adapter, + logger: makeLogger(), + }) + + expect(ctx.cache).toBeUndefined() + }) + + it('provides cache when cache:read is in permissions', () => { + const adapter = makeCacheAdapter() + const ctx = createPluginContext({ + ...BASE_OPTIONS, + permissions: ['cache:read'], + cache: adapter, + logger: makeLogger(), + }) + + expect(ctx.cache).toBeDefined() + }) + + it('provides cache when cache:write is in permissions', () => { + const adapter = makeCacheAdapter() + const ctx = createPluginContext({ + ...BASE_OPTIONS, + permissions: ['cache:write'], + cache: adapter, + logger: makeLogger(), + }) + + expect(ctx.cache).toBeDefined() + }) + + it('creates a child logger with plugin name', () => { + const childFn = vi.fn().mockReturnThis() + const logger = { ...makeLogger(), child: childFn } as unknown as PluginContextOptions['logger'] + createPluginContext({ ...BASE_OPTIONS, logger }) + + expect(childFn).toHaveBeenCalledWith({ plugin: '@barazo/plugin-signatures' }) + }) +}) diff --git a/tests/unit/lib/plugins/loader.test.ts b/tests/unit/lib/plugins/loader.test.ts new file mode 100644 index 0000000..c4bce8a --- /dev/null +++ b/tests/unit/lib/plugins/loader.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest' + +import { topologicalSort, validateAndFilterPlugins } from '../../../../src/lib/plugins/loader.js' +import type { PluginManifest } from '../../../../src/validation/plugin-manifest.js' + +function makeManifest(overrides: Partial & { name: string }): PluginManifest { + return { + displayName: overrides.name, + version: '1.0.0', + description: 'Test plugin', + barazoVersion: '^1.0.0', + source: 'community', + category: 'social', + author: { name: 'Test' }, + license: 'MIT', + permissions: { backend: [], frontend: [] }, + ...overrides, + } +} + +function makeLogger() { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn().mockReturnThis(), + level: 'info', + } +} + +describe('topologicalSort', () => { + it('returns plugins with no dependencies in original order', () => { + const a = makeManifest({ name: '@barazo/plugin-a' }) + const b = makeManifest({ name: '@barazo/plugin-b' }) + const c = makeManifest({ name: '@barazo/plugin-c' }) + + const sorted = topologicalSort([a, b, c]) + expect(sorted.map((m) => m.name)).toEqual([ + '@barazo/plugin-a', + '@barazo/plugin-b', + '@barazo/plugin-c', + ]) + }) + + it('orders dependencies before dependents', () => { + const a = makeManifest({ + name: '@barazo/plugin-a', + dependencies: ['@barazo/plugin-b'], + }) + const b = makeManifest({ name: '@barazo/plugin-b' }) + + const sorted = topologicalSort([a, b]) + const names = sorted.map((m) => m.name) + expect(names.indexOf('@barazo/plugin-b')).toBeLessThan(names.indexOf('@barazo/plugin-a')) + }) + + it('handles multi-level dependency chains (A -> B -> C)', () => { + const a = makeManifest({ + name: '@barazo/plugin-a', + dependencies: ['@barazo/plugin-b'], + }) + const b = makeManifest({ + name: '@barazo/plugin-b', + dependencies: ['@barazo/plugin-c'], + }) + const c = makeManifest({ name: '@barazo/plugin-c' }) + + const sorted = topologicalSort([a, b, c]) + const names = sorted.map((m) => m.name) + expect(names).toEqual(['@barazo/plugin-c', '@barazo/plugin-b', '@barazo/plugin-a']) + }) + + it('throws on circular dependencies', () => { + const a = makeManifest({ + name: '@barazo/plugin-a', + dependencies: ['@barazo/plugin-b'], + }) + const b = makeManifest({ + name: '@barazo/plugin-b', + dependencies: ['@barazo/plugin-a'], + }) + + expect(() => topologicalSort([a, b])).toThrow(/circular/i) + }) +}) + +describe('validateAndFilterPlugins', () => { + it('passes valid manifests through', () => { + const logger = makeLogger() + const manifests = [ + makeManifest({ name: '@barazo/plugin-a' }), + makeManifest({ name: '@barazo/plugin-b' }), + ] + + const result = validateAndFilterPlugins(manifests, '1.0.0', logger as never) + expect(result).toHaveLength(2) + }) + + it('filters out invalid manifests and logs warning', () => { + const logger = makeLogger() + const manifests = [ + makeManifest({ name: '@barazo/plugin-a' }), + { name: 'invalid-name', version: 'not-semver' }, // invalid + ] + + const result = validateAndFilterPlugins(manifests, '1.0.0', logger as never) + expect(result).toHaveLength(1) + expect(result[0].name).toBe('@barazo/plugin-a') + expect(logger.warn).toHaveBeenCalled() + }) + + it('filters out plugins with missing dependencies and logs warning', () => { + const logger = makeLogger() + const manifests = [ + makeManifest({ + name: '@barazo/plugin-a', + dependencies: ['@barazo/plugin-missing'], + }), + makeManifest({ name: '@barazo/plugin-b' }), + ] + + const result = validateAndFilterPlugins(manifests, '1.0.0', logger as never) + expect(result).toHaveLength(1) + expect(result[0].name).toBe('@barazo/plugin-b') + expect(logger.warn).toHaveBeenCalled() + }) +}) diff --git a/tests/unit/routes/admin-plugins.test.ts b/tests/unit/routes/admin-plugins.test.ts new file mode 100644 index 0000000..a6fb233 --- /dev/null +++ b/tests/unit/routes/admin-plugins.test.ts @@ -0,0 +1,390 @@ +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { RequestUser } from '../../../src/auth/middleware.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' + +import { adminPluginRoutes } from '../../../src/routes/admin-plugins.js' + +// --------------------------------------------------------------------------- +// Mock env +// --------------------------------------------------------------------------- + +const mockEnv = { + HOSTING_MODE: 'selfhosted', +} as Env + +// --------------------------------------------------------------------------- +// Test constants +// --------------------------------------------------------------------------- + +const ADMIN_DID = 'did:plc:admin999' + +const ADMIN_USER: RequestUser = { + did: ADMIN_DID, + handle: 'admin.bsky.social', + sid: 'a'.repeat(64), +} + +// --------------------------------------------------------------------------- +// Mock plugin fixture +// --------------------------------------------------------------------------- + +const MOCK_PLUGIN_ROW = { + id: '550e8400-e29b-41d4-a716-446655440000', + name: '@barazo/plugin-test', + displayName: 'Test Plugin', + version: '1.0.0', + description: 'A test plugin', + source: 'core' as const, + category: 'social', + enabled: false, + manifestJson: { name: '@barazo/plugin-test', settings: {}, dependencies: [] }, + installedAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), +} + +// --------------------------------------------------------------------------- +// Mock DB +// --------------------------------------------------------------------------- + +const mockDb = createMockDb() + +let selectChain: DbChain +let updateChain: DbChain +let deleteChain: DbChain +let insertChain: DbChain + +function resetAllDbMocks(): void { + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + deleteChain = createChainableProxy() + insertChain = createChainableProxy() + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(deleteChain) + mockDb.insert.mockReturnValue(insertChain) + mockDb.execute.mockReset() + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction + mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { + await fn(mockDb) + }) +} + +// --------------------------------------------------------------------------- +// Mock requireAdmin +// --------------------------------------------------------------------------- + +function createMockRequireAdmin(user?: RequestUser) { + return async ( + request: { user?: RequestUser }, + reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } } + ) => { + if (!user) { + await reply.status(401).send({ error: 'Authentication required' }) + return + } + request.user = user + } +} + +// --------------------------------------------------------------------------- +// Build test app +// --------------------------------------------------------------------------- + +async function buildTestApp(user?: RequestUser): Promise { + const app = Fastify({ logger: false }) + + const requireAdmin = createMockRequireAdmin(user) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('requireAdmin', requireAdmin as never) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + + await app.register(adminPluginRoutes()) + await app.ready() + + return app +} + +// =========================================================================== +// Test suite +// =========================================================================== + +describe('admin plugin routes', () => { + // ========================================================================= + // GET /api/plugins + // ========================================================================= + + describe('GET /api/plugins', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(ADMIN_USER) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns list of plugins (200)', async () => { + // Routes call `await db.select().from(plugins)` (no .where()), + // so from() must return a thenable that resolves to the mock data. + const pluginSelectChain = createChainableProxy([MOCK_PLUGIN_ROW]) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain + pluginSelectChain.from.mockImplementation(() => ({ + ...pluginSelectChain, + then: (resolve: (v: unknown) => void, reject?: (e: unknown) => void) => + Promise.resolve([MOCK_PLUGIN_ROW]).then(resolve, reject), + })) + + const settingsSelectChain = createChainableProxy([]) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain + settingsSelectChain.from.mockImplementation(() => ({ + ...settingsSelectChain, + then: (resolve: (v: unknown) => void, reject?: (e: unknown) => void) => + Promise.resolve([]).then(resolve, reject), + })) + + mockDb.select.mockReturnValueOnce(pluginSelectChain).mockReturnValueOnce(settingsSelectChain) + + const response = await app.inject({ + method: 'GET', + url: '/api/plugins', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ plugins: unknown[] }>() + expect(body.plugins).toHaveLength(1) + expect(body.plugins[0]).toMatchObject({ + id: MOCK_PLUGIN_ROW.id, + name: MOCK_PLUGIN_ROW.name, + displayName: MOCK_PLUGIN_ROW.displayName, + }) + }) + }) + + // ========================================================================= + // GET /api/plugins/:id + // ========================================================================= + + describe('GET /api/plugins/:id', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(ADMIN_USER) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns 404 when plugin not found', async () => { + const pluginSelectChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(pluginSelectChain) + + const response = await app.inject({ + method: 'GET', + url: '/api/plugins/nonexistent-id', + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns plugin details when found (200)', async () => { + const pluginSelectChain = createChainableProxy([MOCK_PLUGIN_ROW]) + const settingsSelectChain = createChainableProxy([]) + + mockDb.select.mockReturnValueOnce(pluginSelectChain).mockReturnValueOnce(settingsSelectChain) + + const response = await app.inject({ + method: 'GET', + url: `/api/plugins/${MOCK_PLUGIN_ROW.id}`, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ id: string; name: string }>() + expect(body.id).toBe(MOCK_PLUGIN_ROW.id) + expect(body.name).toBe(MOCK_PLUGIN_ROW.name) + }) + }) + + // ========================================================================= + // PATCH /api/plugins/:id/enable + // ========================================================================= + + describe('PATCH /api/plugins/:id/enable', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(ADMIN_USER) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('enables a disabled plugin (200)', async () => { + const pluginSelectChain = createChainableProxy([MOCK_PLUGIN_ROW]) + mockDb.select.mockReturnValueOnce(pluginSelectChain) + + const updatedRow = { ...MOCK_PLUGIN_ROW, enabled: true, updatedAt: new Date() } + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain + updateChain.returning.mockImplementation(() => ({ + ...updateChain, + then: (resolve: (val: unknown) => void, reject?: (err: unknown) => void) => + Promise.resolve([updatedRow]).then(resolve, reject), + })) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/plugins/${MOCK_PLUGIN_ROW.id}/enable`, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ enabled: boolean }>() + expect(body.enabled).toBe(true) + }) + }) + + // ========================================================================= + // PATCH /api/plugins/:id/disable + // ========================================================================= + + describe('PATCH /api/plugins/:id/disable', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(ADMIN_USER) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('disables an enabled plugin (200)', async () => { + const enabledPlugin = { ...MOCK_PLUGIN_ROW, enabled: true } + // First select: db.select().from(plugins).where(...) -- has .where(), default chain works + const pluginSelectChain = createChainableProxy([enabledPlugin]) + // Second select: db.select().from(plugins) -- no .where(), from() must be thenable + const allPluginsChain = createChainableProxy([enabledPlugin]) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain + allPluginsChain.from.mockImplementation(() => ({ + ...allPluginsChain, + then: (resolve: (v: unknown) => void, reject?: (e: unknown) => void) => + Promise.resolve([enabledPlugin]).then(resolve, reject), + })) + + mockDb.select.mockReturnValueOnce(pluginSelectChain).mockReturnValueOnce(allPluginsChain) + + const updatedRow = { ...MOCK_PLUGIN_ROW, enabled: false, updatedAt: new Date() } + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain + updateChain.returning.mockImplementation(() => ({ + ...updateChain, + then: (resolve: (val: unknown) => void, reject?: (err: unknown) => void) => + Promise.resolve([updatedRow]).then(resolve, reject), + })) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/plugins/${MOCK_PLUGIN_ROW.id}/disable`, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ enabled: boolean }>() + expect(body.enabled).toBe(false) + }) + }) + + // ========================================================================= + // PATCH /api/plugins/:id/settings + // ========================================================================= + + describe('PATCH /api/plugins/:id/settings', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(ADMIN_USER) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('updates settings (200)', async () => { + const pluginSelectChain = createChainableProxy([MOCK_PLUGIN_ROW]) + mockDb.select.mockReturnValueOnce(pluginSelectChain) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/plugins/${MOCK_PLUGIN_ROW.id}/settings`, + payload: { enabled: true, threshold: 5 }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + }) + }) + + // ========================================================================= + // DELETE /api/plugins/:id + // ========================================================================= + + describe('DELETE /api/plugins/:id', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(ADMIN_USER) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns 404 when plugin not found', async () => { + const pluginSelectChain = createChainableProxy([]) + mockDb.select.mockReturnValueOnce(pluginSelectChain) + + const response = await app.inject({ + method: 'DELETE', + url: '/api/plugins/nonexistent-id', + }) + + expect(response.statusCode).toBe(404) + }) + }) +}) diff --git a/tests/unit/validation/plugin-manifest.test.ts b/tests/unit/validation/plugin-manifest.test.ts new file mode 100644 index 0000000..ed790bb --- /dev/null +++ b/tests/unit/validation/plugin-manifest.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' + +import { pluginManifestSchema } from '../../../src/validation/plugin-manifest.js' + +const VALID_MANIFEST = { + name: '@barazo/plugin-signatures', + displayName: 'User Signatures', + version: '1.0.0', + description: 'Portable user signatures with per-community overrides', + barazoVersion: '^1.0.0', + source: 'core', + category: 'social', + author: { name: 'Barazo', url: 'https://barazo.forum' }, + license: 'MIT', + permissions: { + backend: ['db:write:plugin_signatures', 'pds:read', 'pds:write'], + frontend: ['ui:inject:settings-community', 'ui:inject:post-content'], + }, +} + +describe('pluginManifestSchema', () => { + it('validates a complete manifest with all fields', () => { + const complete = { + ...VALID_MANIFEST, + lexicons: ['forum.barazo.plugin.signatures'], + dependencies: ['@barazo/plugin-profiles'], + settings: { + maxLength: { + type: 'number', + label: 'Max signature length', + description: 'Maximum character count for signatures', + default: 200, + min: 50, + max: 1000, + }, + }, + hooks: { + onInstall: './hooks/install.js', + onUninstall: './hooks/uninstall.js', + onEnable: './hooks/enable.js', + onDisable: './hooks/disable.js', + onProfileSync: './hooks/profile-sync.js', + }, + backend: { + routes: './routes/index.js', + migrations: './migrations/', + }, + frontend: { + register: './frontend/register.js', + }, + } + + const result = pluginManifestSchema.safeParse(complete) + expect(result.success).toBe(true) + }) + + it('validates a minimal manifest with only required fields', () => { + const result = pluginManifestSchema.safeParse(VALID_MANIFEST) + expect(result.success).toBe(true) + }) + + it('rejects an empty object', () => { + const result = pluginManifestSchema.safeParse({}) + expect(result.success).toBe(false) + }) + + it('rejects an invalid source value', () => { + const result = pluginManifestSchema.safeParse({ + ...VALID_MANIFEST, + source: 'unknown', + }) + expect(result.success).toBe(false) + }) + + it('rejects an invalid version (not semver)', () => { + const result = pluginManifestSchema.safeParse({ + ...VALID_MANIFEST, + version: 'v1.0', + }) + expect(result.success).toBe(false) + }) + + it('accepts a manifest with all 4 settings types', () => { + const result = pluginManifestSchema.safeParse({ + ...VALID_MANIFEST, + settings: { + enabled: { + type: 'boolean', + label: 'Enabled', + default: true, + }, + prefix: { + type: 'string', + label: 'Prefix', + description: 'Text shown before the signature', + default: '--', + placeholder: 'Enter prefix...', + }, + maxLength: { + type: 'number', + label: 'Max length', + default: 200, + min: 1, + max: 5000, + }, + position: { + type: 'select', + label: 'Display position', + default: 'bottom', + options: ['top', 'bottom'], + }, + }, + }) + expect(result.success).toBe(true) + }) + + it('accepts a manifest with hooks', () => { + const result = pluginManifestSchema.safeParse({ + ...VALID_MANIFEST, + hooks: { + onInstall: './hooks/install.js', + onEnable: './hooks/enable.js', + }, + }) + expect(result.success).toBe(true) + }) + + it('accepts a manifest with backend and frontend entry points', () => { + const result = pluginManifestSchema.safeParse({ + ...VALID_MANIFEST, + backend: { routes: './routes/index.js', migrations: './migrations/' }, + frontend: { register: './frontend/register.js' }, + }) + expect(result.success).toBe(true) + }) + + it('accepts a manifest with dependencies and lexicons', () => { + const result = pluginManifestSchema.safeParse({ + ...VALID_MANIFEST, + dependencies: ['@barazo/plugin-profiles'], + lexicons: ['forum.barazo.plugin.signatures'], + }) + expect(result.success).toBe(true) + }) + + it('rejects a name that does not match plugin naming convention', () => { + const result = pluginManifestSchema.safeParse({ + ...VALID_MANIFEST, + name: 'random-package', + }) + expect(result.success).toBe(false) + }) +})