diff --git a/apps/api/.gitignore b/apps/api/.gitignore index 0b346262cc..62590c7b9f 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -27,6 +27,7 @@ yarn-error.log* # Local storage adapter uploads /public/uploads +/storage/private # VitNode /docker diff --git a/apps/api/migrations/20261030210852_add_content_access/migration.sql b/apps/api/migrations/20261030210852_add_content_access/migration.sql new file mode 100644 index 0000000000..727beb5eb5 --- /dev/null +++ b/apps/api/migrations/20261030210852_add_content_access/migration.sql @@ -0,0 +1,25 @@ +CREATE TABLE "core_content_access_rule_roles" ( + "ruleId" integer, + "roleId" integer, + CONSTRAINT "core_content_access_rule_roles_pkey" PRIMARY KEY("ruleId","roleId") +); +--> statement-breakpoint +ALTER TABLE "core_content_access_rule_roles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_content_access_rules" ( + "id" serial PRIMARY KEY, + "family" varchar(100) NOT NULL, + "scopeId" varchar(64) NOT NULL, + "action" varchar(32) NOT NULL, + "mode" varchar(16) NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_content_access_rules" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "core_files" ADD COLUMN "visibility" varchar(16) DEFAULT 'public' NOT NULL;--> statement-breakpoint +ALTER TABLE "core_search_index" ADD COLUMN "accessScopes" text[] DEFAULT '{}'::text[] NOT NULL;--> statement-breakpoint +CREATE INDEX "core_content_access_rule_roles_role_id_idx" ON "core_content_access_rule_roles" ("roleId");--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_access_rules_scope_action_key" ON "core_content_access_rules" ("family","scopeId","action");--> statement-breakpoint +CREATE INDEX "core_search_index_access_scopes_idx" ON "core_search_index" USING gin ("accessScopes");--> statement-breakpoint +ALTER TABLE "core_content_access_rule_roles" ADD CONSTRAINT "core_content_access_rule_roles_AM3iX4qZcV8y_fkey" FOREIGN KEY ("ruleId") REFERENCES "core_content_access_rules"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_content_access_rule_roles" ADD CONSTRAINT "core_content_access_rule_roles_roleId_core_roles_id_fkey" FOREIGN KEY ("roleId") REFERENCES "core_roles"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/apps/api/migrations/20261030210852_add_content_access/snapshot.json b/apps/api/migrations/20261030210852_add_content_access/snapshot.json new file mode 100644 index 0000000000..277c177489 --- /dev/null +++ b/apps/api/migrations/20261030210852_add_content_access/snapshot.json @@ -0,0 +1,23754 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "2d32a353-3dc9-4b47-8d67-0bca5da5a89e", + "prevIds": [ + "ac6bc2f3-4afc-4768-be17-a5b36fd6180e" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_translation_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_audit_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_audit_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_access_rule_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_access_rules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_containers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_drafts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_field_locks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_submissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_analysis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_reasons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_reports", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_payments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_payments_webhook_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_popularity", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_lock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_results", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sso_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sso_pending", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_topics", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_bulletins", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_bulletins_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_related_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_related_explainers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_letters", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_stories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_stories_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags_related_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "payments_demo_purchases", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "area", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(150)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorName", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetType", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetLabel", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "changes", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeFamily", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "correlationId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_audit_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "retentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_audit_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_audit_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ruleId", + "entityType": "columns", + "schema": "public", + "table": "core_content_access_rule_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_content_access_rule_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_access_rules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "family", + "entityType": "columns", + "schema": "public", + "table": "core_content_access_rules" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_access_rules" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_access_rules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_content_access_rules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_access_rules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_access_rules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "definitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "policies", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "acquiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerDefinitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseValues", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseUpdatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reviewerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "decidedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'automatic'", + "generated": null, + "identity": null, + "name": "altPolicy", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'public'", + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reportId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "requiresDetails", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetType", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reporterId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetTitle", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'new'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "assigneeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolution", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolutionNote", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedById", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeFamily", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "openKey", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reference", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerType", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(3)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "currency", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerStatus", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "checkoutId", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "checkoutUrl", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "livemode", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "syncedAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "paymentId", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "receivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowBrowseAnonymously", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "forceBrowseAnonymously", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditOwnContent", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "editOwnContentMinutes", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowDeleteOwnContent", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowHideOwnContent", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "accessScopes", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isHidden", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "imageId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "logScore", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "clicks", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "comments", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "owner", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastManualAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "varchar(214)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "componentId", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "versions", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasUnknownVersion", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coreVersion", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "result", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastAttemptAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSuccessAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "etag", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastModified", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastAttemptAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSuccessAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stateHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bindingHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeVerifier", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nonce", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profile", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "confirmHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "confirmExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "confirmSends", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "confirmSentAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSeenAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hideOnlineStatus", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeVerifier", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nonce", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedBy", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "article", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedBy", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "topic", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "forum", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "varchar(80)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "article", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "newsroom_events_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primaryTag", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "desk", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "periodStart", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_budget_periods_scope_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_run_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "costSource", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reconciledAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_reconcile_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "callId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_cost_adjustments_call_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "budgetPeriodId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_run_period_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actionKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_action_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "leaseExpiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotencyKey\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_translation_sources_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_actor_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_plugin_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "area", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_area_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeFamily", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scopeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_scope_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_access_rule_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_access_rule_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "family", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scopeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_access_rules_scope_action_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_access_rules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_definition_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_definition_parent_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_doc_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_pending_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "containerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_queue_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_file_language_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileFingerprint", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_analysis_file_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status <> 'completed'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_state_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reportId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_report_events_report_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_report_reasons_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "assigneeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_assignee_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reporterId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_reporter_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeFamily", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scopeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_scope_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "eventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"eventId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reference", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_open_reference_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "checkoutId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"checkoutId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_checkout_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reference", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_reference_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_open_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "receivedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_webhook_events_received_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "completedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"completedAt\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_completed_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dedupeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"dedupeKey\" IS NOT NULL AND status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_dedupe_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "to_tsvector('simple'::regconfig, \"title\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_title_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "imageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_image_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "containerType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "containerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_container_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "accessScopes", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_access_scopes_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "logScore", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_popularity_log_score_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_popularity" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sso_attempts_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sso_pending_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sso_pending" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "article", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_article_parent_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "article", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_article_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deletedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_deleted_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_topic_parent_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_topic_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deletedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_deleted_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "forum", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_forum_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "article", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_article_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deletedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_deleted_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primaryTag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_primary_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_related_events_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_related_events_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_sessions_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_related_explainers_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_related_explainers_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "desk", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_desk_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_code_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_related_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_related_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "payments_demo_purchases_user_item_unique", + "entityType": "indexes", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_translation_sources_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_audit_logs_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": false, + "columns": [ + "ruleId" + ], + "schemaTo": "public", + "tableTo": "core_content_access_rules", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_content_access_rule_roles_AM3iX4qZcV8y_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_access_rule_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_content_access_rule_roles_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_access_rule_roles" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_content_containers_q6QIucJ4HJ7B_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_documents_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_drafts_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_field_locks_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_content_submissions_authorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": false, + "columns": [ + "reviewerId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_content_submissions_reviewerId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_languageCode_core_languages_code_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_analysis_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_analysis_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_state_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "reportId" + ], + "schemaTo": "public", + "tableTo": "core_moderation_reports", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderation_report_events_4CqHOxfheaUm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_report_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "reporterId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_reporterId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "assigneeId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_assigneeId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "resolvedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_resolvedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_payments_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": false, + "columns": [ + "paymentId" + ], + "schemaTo": "public", + "tableTo": "core_payments", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_payments_webhook_events_paymentId_core_payments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "nameExplicit": false, + "columns": [ + "imageId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_search_index_imageId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "deletedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_deletedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "article" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_comments_article_blog_posts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "blog_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_parent_blog_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_posts_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "deletedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_comments_deletedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "topic" + ], + "schemaTo": "public", + "tableTo": "forum_topics", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "forum_replies_topic_forum_topics_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "forum_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_parent_forum_replies_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "forum" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "forum_topics_forum_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_articles_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_articles_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_bulletins", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_bulletins_translations_kgMZwrpzwwU7_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_bulletins_translations_rkkiMJQiOrEo_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "deletedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_deletedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "article" + ], + "schemaTo": "public", + "tableTo": "newsroom_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_comments_article_newsroom_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "newsroom_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_parent_newsroom_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "primaryTag" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_primaryTag_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_gallery_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_related_events_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_related_events_0xHNSLM3JAJj_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_sessions_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_tags_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_translations_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_categories_9dOmLv3k6xDX_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_categories_mnxKvproEUm5_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_faq_itemId_newsroom_explainers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_related_explainers_TFvIU3TogSGy_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_related_explainers_74JhaXF5PsDV_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_tags_itemId_newsroom_explainers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_translations_hcPB0jdTm8MV_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_translations_qqggtF6jhcsZ_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_letters_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_letters_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "desk" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_letters_desk_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_stories_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_stories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_stories_gallery_itemId_newsroom_stories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_tags_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_tags_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_related_tags_itemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_related_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_translations_itemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_tags_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "payments_demo_purchases_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "ruleId", + "roleId" + ], + "nameExplicit": false, + "name": "core_content_access_rule_roles_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_content_access_rule_roles" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "providerId", + "eventId" + ], + "nameExplicit": false, + "name": "core_payments_webhook_events_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "columns": [ + "itemType", + "itemId" + ], + "nameExplicit": false, + "name": "core_search_popularity_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_search_popularity" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_bulletins_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_related_events_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_events_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_related_explainers_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_stories_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_tags_related_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_tags_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_audit_logs_pkey", + "schema": "public", + "table": "core_audit_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_audit_settings_pkey", + "schema": "public", + "table": "core_audit_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_access_rules_pkey", + "schema": "public", + "table": "core_content_access_rules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_containers_pkey", + "schema": "public", + "table": "core_content_containers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_documents_pkey", + "schema": "public", + "table": "core_content_documents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_drafts_pkey", + "schema": "public", + "table": "core_content_drafts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_field_locks_pkey", + "schema": "public", + "table": "core_content_field_locks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_submissions_pkey", + "schema": "public", + "table": "core_content_submissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_pkey", + "schema": "public", + "table": "core_files_alt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_analysis_pkey", + "schema": "public", + "table": "core_files_alt_analysis", + "entityType": "pks" + }, + { + "columns": [ + "fileId" + ], + "nameExplicit": false, + "name": "core_files_alt_state_pkey", + "schema": "public", + "table": "core_files_alt_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_events_pkey", + "schema": "public", + "table": "core_moderation_report_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_reasons_pkey", + "schema": "public", + "table": "core_moderation_report_reasons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_reports_pkey", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_payments_pkey", + "schema": "public", + "table": "core_payments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_update_lock_pkey", + "schema": "public", + "table": "core_update_lock", + "entityType": "pks" + }, + { + "columns": [ + "componentId" + ], + "nameExplicit": false, + "name": "core_update_results_pkey", + "schema": "public", + "table": "core_update_results", + "entityType": "pks" + }, + { + "columns": [ + "url" + ], + "nameExplicit": false, + "name": "core_update_sources_pkey", + "schema": "public", + "table": "core_update_sources", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sso_attempts_pkey", + "schema": "public", + "table": "core_sso_attempts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sso_pending_pkey", + "schema": "public", + "table": "core_sso_pending", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_comments_pkey", + "schema": "public", + "table": "blog_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_replies_pkey", + "schema": "public", + "table": "forum_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_topics_pkey", + "schema": "public", + "table": "forum_topics", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_articles_pkey", + "schema": "public", + "table": "newsroom_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_bulletins_pkey", + "schema": "public", + "table": "newsroom_bulletins", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_categories_pkey", + "schema": "public", + "table": "newsroom_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_comments_pkey", + "schema": "public", + "table": "newsroom_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_events_pkey", + "schema": "public", + "table": "newsroom_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_events_sessions_pkey", + "schema": "public", + "table": "newsroom_events_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_explainers_pkey", + "schema": "public", + "table": "newsroom_explainers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_explainers_faq_pkey", + "schema": "public", + "table": "newsroom_explainers_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_letters_pkey", + "schema": "public", + "table": "newsroom_letters", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_pages_pkey", + "schema": "public", + "table": "newsroom_pages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_stories_pkey", + "schema": "public", + "table": "newsroom_stories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_tags_pkey", + "schema": "public", + "table": "newsroom_tags", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "payments_demo_purchases_pkey", + "schema": "public", + "table": "payments_demo_purchases", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_moderation_report_reasons_code_key", + "schema": "public", + "table": "core_moderation_report_reasons", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "openKey" + ], + "nullsNotDistinct": false, + "name": "core_moderation_reports_openKey_key", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "stateHash" + ], + "nullsNotDistinct": false, + "name": "core_sso_attempts_stateHash_key", + "schema": "public", + "table": "core_sso_attempts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_sso_pending_tokenHash_key", + "schema": "public", + "table": "core_sso_pending", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"amount\" > 0", + "name": "core_payments_amount_positive", + "entityType": "checks", + "schema": "public", + "table": "core_payments" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 97ddc898a5..941c1d6117 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -12,3 +12,6 @@ dist-ssr .vinxi __unconfig* todos.json + +# Local storage adapter private files +/storage/private diff --git a/apps/web/content/docs/content-engine/access-api.mdx b/apps/web/content/docs/content-engine/access-api.mdx new file mode 100644 index 0000000000..01463d897d --- /dev/null +++ b/apps/web/content/docs/content-engine/access-api.mdx @@ -0,0 +1,220 @@ +--- +title: Access rules API +description: Reference for VitNode Content Engine access rules - the access option of defineContentContainer and defineContentType, actions and audiences, createContentAccess, access flags, the 403 access-required body, AdminCP routes, errors, the content.access.updated event and the private storage store. +icon: FileBraces +--- + +Exact names, values and behavior of Content Engine access rules. For a guided setup, follow [Access rules](/docs/content-engine/access) or [Category access](/docs/content-engine/access-categories). [How access is enforced](/docs/content-engine/access-behavior) explains where the rules apply. + +## `access` option + +### Container collection + +`defineContentContainer({ access })` from `@vitnode/core/content`: + +| Value | Behavior | +| ------------------------ | ------------------------------------------------------------------------- | +| omitted or `false` | No access rules. The collection behaves as before | +| `true` | Every container gets rules; every action starts open to everyone | +| `{ defaults }` | Same, with collection defaults for some actions | + +`defaults` is a partial `Record`. Role ids belong to one installation, so roles are chosen only in the AdminCP. + +Content filed with a `field.container()` into the collection, and records under them (`container: true` relations, thread replies), follow the container's rules. + +### Content type + +`defineContentType({ access })` takes exactly one of: + +| Value | Behavior | +| ---------------------------------- | --------------------------------------------------------------------------------------------- | +| `{ scope: true }` | Each record of this type is an access scope with its own rules, like a Blog category | +| `{ scope: { defaults } }` | Same, with defaults as above | +| `{ from: "" }` | Records are filed under the scope records the relation points at. Every one of them must allow the action | + +`from` must name a shared (not localized) `field.relation()` whose target has `access: { scope }`. Anything else throws a `ContentEngineError` when the plugin loads. + +## Actions and audiences + +| Action | Needs | Checked for | +| --------------------- | ------ | ------------------------------------------------------------------ | +| `view` | - | Containers in lists, trees and breadcrumbs; the scope's own page | +| `read` | `view` | Records: lists, detail, search, Discover, profiles, notifications | +| `comment` | `read` | Replies in a thread | +| `downloadAttachments` | `read` | Files other than images through the download route | + +| Audience | Passes | +| ---------------------------------------- | -------------------------------------------------------------- | +| `{ mode: "everyone" }` | Guests and members | +| `{ mode: "members" }` | Signed-in members | +| `{ mode: "roles", roleIds: number[] }` | Members holding any of the roles, primary or secondary. Up to 100 roles | + +A member with a root role passes every audience. A rule whose roles were all deleted, or that cannot be read, lets nobody in. Stored rules are per scope and action; an action without one inherits from the nearest ancestor, then the defaults, then `everyone`. + +The constants are exported from `@vitnode/core/content`: `CONTENT_ACCESS_ACTIONS`, `CONTENT_ACCESS_MODES`, `CONTENT_ACCESS_PREREQUISITES`, `CONTENT_ACCESS_MAX_ROLES` and `CONTENT_ACCESS_REQUIRED_CODE`. + +## `createContentAccess` + +`createContentAccess(c)` from `@vitnode/core/content/server` checks the current viewer inside a Hono route. Roles are read from the database once per request; the results are memoized for that request. + +```ts title="plugins/forum/src/api/modules/forum/stats.routes.ts" +import { createContentAccess } from "@vitnode/core/content/server"; + +const access = createContentAccess(c); +const [row] = await c + .get("db") + .select({ id: topics.id, slug: topics.slug }) + .from(topics) + .where( + and( + eq(topics.id, id), + publicTopic(), + await access.condition(topicContent, "read"), // [!code highlight] + ), + ) + .limit(1); + +if (!(await access.isOpen(topicContent))) { + return c.json(row, 200, { "Cache-Control": "private, no-store" }); +} +``` + +| Method | Returns | Behavior | +| ---------------------------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------- | +| `actor()` | `ContentAccessActor` | `{ kind: "guest" }` or `{ kind: "member", userId, roleIds, root }` | +| `condition(model, action)` | `SQL \| undefined` | A `WHERE` condition keeping only rows the viewer may act on. `undefined` when nothing is restricted | +| `canRecord(model, id, action)` | `boolean` | One record, from its current scopes. A missing record is `false` | +| `canScope(familyId, scopeId, action)` | `boolean` | One container or scope record. An unknown scope is `false` | +| `flags(model, ids)` | `Map` | The four flags per record | +| `allowedScopes(familyId, action)` | `{ all: boolean; ids: string[] }` | The scopes of a family that allow the action. `all` when every scope does | +| `isOpen(model)` | `boolean` | `true` when no rule restricts anything in the model's family: the response may be shared | +| `scopesFor(model, values)` | `ContentAccessScopeRef[] \| null` | The scopes a record would be in with these values, before it is saved | + +`model` is the content model from `createContentModel`. Records of a model without access are always allowed. Put `condition` in the query before `limit` and `offset`, so pages and counts never include what the viewer may not read. + +Lower-level helpers are exported from the same path for plugin authors who build their own scope checks: `contentAccessCondition`, `canAccessContentRecord`, `contentRecordAccessFlags`, `decideContentAccessForScopes`, `contentAccessFlagsForScopes`, `contentAccessRequirementsForScopes`, `contentRecordAccessScopes`, `contentAccessAllowedScopes`, `isContentAccessOpen`, `resolveContentAccessActor` and `expireContentAccessFamily`. + +## Response fields + +### Access flags + +```ts +interface ContentAccessFlags { + canView: boolean; + canRead: boolean; + canComment: boolean; + canDownloadAttachments: boolean; +} +``` + +- Every record of the public container routes carries `access: ContentAccessFlags`. Collections without access answer every flag `true`. +- `GET /permissions` of a content type adds the four flags to each record it returns, and leaves out records the viewer may not read. + +Flags tell the UI what to offer. The server checks again on every request. + +### Access required + +A detail route answers `404` when the record is in a scope the viewer may not see, and `403` with this body when the viewer may see the scope but not read it: + +```json title="GET /api/@vitnode/forum/content/topics/42 → 403" +{ "accessRequired": true, "code": "CONTENT_ACCESS_REQUIRED" } +``` + +The body never holds the record. Render it with `ContentAccessRequired` from `@vitnode/core/tanstack/content`: + +| Prop | Type | Default | Description | +| -------------- | ---------------------------- | -------- | ------------------------------------------------- | +| `kind` | `"read"` \| `"comment"` | `"read"` | What is locked: the page, or replying | +| `headingLevel` | `"h1"` \| `"h2"` \| `"h3"` | `"h1"` | The title's level | +| `children` | `ReactNode` | - | Rendered under the message, such as a link back | + +Guests get a sign-in link that returns to the page. Members get the message only. + +### Download route + +`GET /api/{pluginId}/content/{publicApi.path}/{id}/files/{fileId}` is mounted for every content type with access rules. It answers `200` with the file, `403` when the viewer may read the record but not download attachments, and `404` otherwise. See [Protected attachments](/docs/content-engine/access-attachments). + +## AdminCP routes + +Mounted under the scope family's admin module, `/api/{pluginId}/admin/content/{admin.permissionModule}`. For Forum that is `/api/@vitnode/forum/admin/content/forum_forums`; for Blog categories `/api/@vitnode/blog/admin/content/categories`. + +| Route | Permission | Behavior | +| ---------------------------------- | ---------- | ---------------------------------------------------------------------------------------- | +| `GET /{id}/access` | `can_view` | The scope's report: own rules, inherited rules and their source, every requirement, warnings, descendants | +| `POST /{id}/access/preview` | `can_view` | The same report for `{ overrides }` without saving | +| `PUT /{id}/access` | `can_edit` | Replaces the scope's rules with `{ overrides }` and expires the family's caches | +| `GET /access/files` | `can_view` | `{ privateStorage, private, public }`: the family's files by visibility | +| `POST /access/files/protect` | `can_edit` | Moves up to 100 public files to private storage: `{ moved, remaining, shared }` | + +A content type filed under scopes gets `POST /access/effective` in its own module, such as `/api/@vitnode/blog/admin/content/posts/access/effective`. It takes `{ scopeIds }` (up to 50) and answers what readers need for each action across all of them, plus `missing` scope ids. `ContentAccessEffectiveNotice` from `@vitnode/core/views/admin/views/access/effective-notice` renders it in an editor. + +`overrides` is a partial `Record`. An action left out inherits. Call these routes with `rawFetcher`: + +```ts +import { rawFetcher } from "@vitnode/core/tanstack/fetcher"; + +await rawFetcher({ + method: "put", + pluginId: "@vitnode/forum", + prefixPath: "/admin", + module: "content/forum_forums", + path: "/3/access", + body: { overrides: { read: { mode: "roles", roleIds: [4] } } }, +}); +``` + +### Errors + +The routes answer `{ code, message }`: + +| Status | `code` | When | +| ------ | -------------------- | ----------------------------------------------------------------------- | +| `400` | `invalidAccess` | The body does not match the schema, such as `roles` with no `roleIds` | +| `400` | `unknownRole` | A role does not exist, or is the guest role | +| `400` | `unsupportedStorage` | A `downloadAttachments` rule other than `everyone`, or protecting files, without a private store | +| `404` | `notFound` | No such scope, or the type has no access rules | + +## Activity Logs and event + +Every save that changes a rule records `content.access_updated` in +**AdminCP → Staff → Administrators → Logs**, in the same transaction as the +rules, with the old and new rule of each changed action. **Protect public +files** records `content.access_files_protected` with the counts it returned. +See [Activity logs](/docs/content-engine/activity-logs#access-rules). + +`PUT /{id}/access` also emits `content.access.updated` after saving: + +```ts title="plugins/site-notes/src/api/lib/access.listener.ts" +import { buildEventListener } from "@vitnode/core/api/lib/events"; + +export const accessListener = buildEventListener({ + event: "content.access.updated", + name: "log-access-change", + handler: async (c, { changedActions, family, scopeId, userId }) => { + await c + .get("log") + .debug(`${family}:${scopeId} ${changedActions.join(", ")} by ${userId}`); + }, +}); +``` + +See [Built-in events](/docs/dev/events/built-in-events#content-access-events). + +## Private storage store + +A storage adapter opts into private files with `private` on its `StorageApiPlugin`: + +```ts +interface StoragePrivateStore { + upload: (args: StorageUploadArgs) => Promise<{ key: string }>; + read: (key: string) => Promise; size?: number }>; + delete: (key: string) => Promise; +} +``` + +Objects in it have no URL; VitNode serves them only through routes that check the viewer. Leave `private` out when the adapter cannot guarantee that nothing public reaches it. The Local adapter, and S3 or Supabase with `privateBucket`, provide one. + +## Related + +- [How access is enforced](/docs/content-engine/access-behavior) +- [Protected attachments](/docs/content-engine/access-attachments) diff --git a/apps/web/content/docs/content-engine/access-attachment-restricted.webp b/apps/web/content/docs/content-engine/access-attachment-restricted.webp new file mode 100644 index 0000000000..8c1837f64e Binary files /dev/null and b/apps/web/content/docs/content-engine/access-attachment-restricted.webp differ diff --git a/apps/web/content/docs/content-engine/access-attachments.mdx b/apps/web/content/docs/content-engine/access-attachments.mdx new file mode 100644 index 0000000000..8814bebe76 --- /dev/null +++ b/apps/web/content/docs/content-engine/access-attachments.mdx @@ -0,0 +1,106 @@ +--- +title: Protected attachments +description: Keep the files of restricted VitNode content private. Configure a private store for the storage adapter, let access rules decide who downloads attachments, and move existing public files into private storage. +icon: FileLock +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import attachmentRestricted from "./access-attachment-restricted.webp"; + +A rule on **Download attachments** only protects files nobody can fetch by URL. This guide sets up private storage, so files of access-controlled content are served only through a route that checks the reader every time, and moves the files that are still public. + +## How files are served + +Every content type with access rules gets a download route, `GET /api/{plugin}/content/{publicApi.path}/{id}/files/{fileId}`. It answers only when: + +1. the reader may **read** the record, under its scopes' rules now; +2. the file belongs to that record: a file field holds it, or one of its rich-text documents names it; +3. for anything but an image, the reader may also **download attachments**. + +Images follow **Read**: anyone who can read an article sees its images, and anyone who can see an image can save a copy of it. **Download attachments** covers files offered for download, such as PDFs and archives. A reader without it sees **An attachment is available. You do not have permission to download it.**, with no name, size or link: + + + +File fields work the same way, with `restricted: true` on the descriptor. + +Responses are `private, no-store`, `nosniff` and sandboxed. Nothing is served through a signed URL, so taking a role away blocks the next download at once. + +## Before you begin + +You need [access rules](/docs/content-engine/access) on a container collection or a [scope content type](/docs/content-engine/access-categories), and a storage adapter. + + + + + +### Give the storage adapter a private store + +The Local adapter keeps private files in `storage/private` in the API's working directory, which no static mount serves. It needs no configuration; do not serve that folder. + +For S3 or Cloudflare R2, create a second bucket that is **not** public - no public bucket policy, no public R2 domain, no CDN in front of it - and pass it as `privateBucket`: + +```ts title="apps/api/src/vitnode.api.config.ts" +import { S3StorageAdapter } from "@vitnode/s3"; + +storage: { + adapter: S3StorageAdapter({ + accessKeyId: process.env.S3_ACCESS_KEY_ID, + bucket: process.env.S3_BUCKET, + privateBucket: process.env.S3_PRIVATE_BUCKET, // [!code ++] + region: process.env.S3_REGION, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, + }), +}, +``` + +`SupabaseStorageAdapter` takes `privateBucket` the same way; create that bucket as private in Supabase. + +Without a private store, the AdminCP refuses a **Download attachments** rule other than **Everyone** and says why. + + + + + +### Upload new files privately + +Nothing to change in code. Once a content type has access rules and the adapter has a private store, its uploads are stored privately: file fields uploaded in the AdminCP, and rich-text uploads from editors that pass the content type, such as the Blog article editor. + +To pass it from your own editor field: + +```tsx title="plugins/blog/src/views/admin/article/editor-field.tsx" + +``` + +The generated AdminCP form does this for every rich-text field with `uploads`. + + + + + +### Move the files that are still public + +Files uploaded before you set this up keep their public addresses. Open the **Access** sheet of any forum or category in the collection. Its **Files** section counts the files the collection's content uses, private and still public. Choose **Protect public files** to move up to 100 public files into the private store; run it again until none are left. + +Moving a file deletes its public object, so the address handed out before stops working. A file that content outside the collection also uses stays public, and the result counts it as **used elsewhere** - a private reference cannot make a shared public file confidential, so upload it again where it should be private. Nothing private is ever made public. + + + + + +## Check the result + +Restrict **Download attachments** of a forum or category to a role, then attach a PDF to an article in it. As a member without the role, the article shows the attachment note instead of the file, and the download route answers `403`. With the role, the file downloads. Copy the download address, sign out and open it: the route answers `404`, because a guest cannot read the article. + +## Related + +- [Access rules](/docs/content-engine/access) +- [How access is enforced](/docs/content-engine/access-behavior) +- [Storage](/docs/dev/storage) diff --git a/apps/web/content/docs/content-engine/access-behavior.mdx b/apps/web/content/docs/content-engine/access-behavior.mdx new file mode 100644 index 0000000000..8e70f461ed --- /dev/null +++ b/apps/web/content/docs/content-engine/access-behavior.mdx @@ -0,0 +1,71 @@ +--- +title: How access is enforced +description: How VitNode Content Engine access rules apply to public reads and writes, search and Discover, caches, notifications, sitemaps, moderators and root roles, and what to check when somebody cannot see what they should. +icon: ShieldQuestionMark +--- + +Access rules are enforced on the server, on every request, from the current rules, the current scope tree and the reader's current roles. This page explains where they apply, so you can predict what a member sees and debug when it is not what you expected. + +## How a decision is made + +For one scope and one action, the server collects every requirement and lets the reader in only when all of them pass: + +- the scope's own rule for the action and for each action it needs (**Read** needs **View**); +- the **View** rule of every ancestor, because a container inside a hidden one is hidden; +- for **Read** and what needs it, the **Read** rule of every ancestor, because a child can be stricter than its parent but never more open. + +A rule left on **Inherit** comes from the nearest ancestor that sets it, then the collection's `defaults`, then **Everyone**. A record filed under several scopes, such as an article in two categories, needs every scope's requirements. Comments and replies follow their parent record. + +Roles are read from the database for each request, never from a cached session. Taking a role away, moving a container under a restricted parent or changing a rule applies from the next request, including to forms a member already has open. + +## Where the rules apply + +| Path | Behavior | +| -------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Public lists, detail, thread, delivery | Records the reader may not read are left out before pagination, so pages, cursors and totals never count them | +| Detail of a record in a hidden scope | `404`, the same as a record that never existed | +| Detail in a scope the reader may see | `403` with `{ accessRequired: true, code: "CONTENT_ACCESS_REQUIRED" }` and nothing else | +| Container list and detail | Hidden containers are left out, children included; every container carries `access` flags | +| Writes, edits, hide, delete, reports | Checked again on the server: a reply needs **Comment**, anything else **Read** | +| `GET /permissions` | Leaves out records the reader may not read; adds `canView`, `canRead`, `canComment`, `canDownloadAttachments` | +| Sitemaps | Only what a guest may read, in every language | +| Notifications and emails | Each recipient must be able to read the record when it is sent and every time the inbox loads | + +Staff permissions grant nothing here. A moderator reads a restricted forum on the public site only when an access rule lets one of their roles in; give your moderator role access explicitly. A **root role** passes every rule. The AdminCP and ModCP are staff areas behind their own permissions, so moderators keep reviewing reports and approvals there. A pending approval follows its record to its new container when the record moves, so it reaches that container's moderators. + +Preview links are signed by staff for one revision. They show that revision whatever its scope's rules, as before, but protected files inside it still need a reader the rules let in. + +## Search and Discover + +Search, suggestions, Discover, popular items, facets, author lists and profile timelines all use the same rules, with every supported adapter: + +1. Each search document stores the scopes its record is filed in, such as `forum.forums:12` or `blog.category:3`. They are read from the content tables whenever the document is written. +2. A query keeps only documents whose every scope the reader may read. Postgres compares arrays; Elasticsearch uses a `terms_set` query. +3. Each result is checked against the content tables before it is returned. A document indexed before its record moved into a restricted scope is dropped, re-stamped, and left out of the total. + +The index never grants access on its own. While a stale document waits for its fix, totals are an upper bound that never includes a result the reader was shown. An hourly job, `content-search-access`, re-stamps any document that drifted, so failed or delayed indexing heals without a rebuild. + +## Caching + +A response is cached for everybody only when no access rule can change it: the family has no restriction at all. As soon as one forum or category is restricted, every response that depends on the reader is sent `private, no-store`, skips the API's shared public-read cache and stays out of the page cache. Pages for guests and members are then rendered per request. + +Changing a rule, moving or deleting a container expires every cache the family decides: shared pages (hard), cached public reads, the container tree and sitemaps. Signing in, signing out or switching accounts clears the browser's cached queries, except translations. + +External search engines may keep showing pages they crawled while they were public. Making a scope private removes it from your sitemap and makes its pages answer `404` or `403`; ask the search engine to recrawl or remove the URLs if it matters. + +## Troubleshooting + +**Who changed a rule, and when?** Open **AdminCP → Staff → Administrators → Logs** and filter by **Changed access rules**. Each entry names the forum or category and shows the rule before and after. + +**A member cannot see a forum they should.** Open the forum's **Access** sheet. **Effective access** lists every requirement, including the ones a parent adds. A **Read** rule on a parent hides every child's topics, even when the child is open. + +**Nobody can open a forum.** A **Selected roles** rule whose roles were all deleted lets nobody in; the sheet warns about it. Deleting a role removes it from every rule, so a restricted scope stays restricted instead of opening up. Pick the roles again, or choose another audience. + +**Attachments still download for everyone.** The files are probably still public. See [Protected attachments](/docs/content-engine/access-attachments#move-the-files-that-are-still-public). + +**A custom route shows restricted content.** A plugin route that queries content tables directly ignores the rules. Read through the public service, or add `await createContentAccess(c).condition(model, "read")` to its `where`. See the [Access rules API](/docs/content-engine/access-api#createcontentaccess). + +## Related + +- [Access rules](/docs/content-engine/access) +- [Access rules API](/docs/content-engine/access-api) diff --git a/apps/web/content/docs/content-engine/access-categories.mdx b/apps/web/content/docs/content-engine/access-categories.mdx new file mode 100644 index 0000000000..cce30570d3 --- /dev/null +++ b/apps/web/content/docs/content-engine/access-categories.mdx @@ -0,0 +1,120 @@ +--- +title: Category access +description: Make each record of a VitNode content type an access scope, like Blog categories, and file other records under several of them. A record is readable only when every one of its scopes allows it. +icon: Tags +implementation: + objective: Let administrators restrict content by the categories (or other records) it is filed under. + decisions: + - Which content type holds the scopes, such as categories? It needs no publication or public API of its own. + - Which content type is filed under them, and through which relation field? + constraints: + - 'Mark the scope type with `access: { scope: true }` and the filed type with `access: { from: "" }`. The relation must be shared, not localized.' + - Records that answer a parent, such as comments under an article, follow the parent's access without an option of their own. + - Make every custom route that lists the filed records or counts them go through the public service or `createContentAccess(c)`. + related: + - title: Access rules API + href: /docs/content-engine/access-api +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import categoryNotice from "./access-category-notice.webp"; + +A content type can hold access scopes of its own: every record gets view, read, comment and download rules, like a forum does. The Blog plugin uses this for categories, and an article is readable only when **every** one of its categories allows it. + +Use it when the scopes are records you already manage in the AdminCP, such as categories or tags, rather than a [container tree](/docs/content-engine/access). The actions, audiences and prerequisites are the same as for containers. + +## Before you begin + +You need a content type for the scopes and one filed under them through a relation. In the Blog plugin these are `blog.category` and `blog.post`, whose `categoryId` is a to-many relation to categories. + + + + + +### Make each category a scope + +Add `access: { scope: true }` to the scope content type: + +```ts title="plugins/blog/src/content/category.ts" +export const blogCategoryContentType = defineContentType({ + id: "blog.category", + tableName: "blog_categories", + access: { scope: true }, // [!code ++] + // localization, fields and admin stay as they are +}); +``` + +Every action starts open to everyone. `{ scope: { defaults: { comment: { mode: "members" } } } }` starts one stricter for every category. + + + + + +### File articles under their categories + +Point the article's `access.from` at the relation: + +```ts title="plugins/blog/src/content/post.ts" +export const blogPostContentType = defineContentType({ + id: "blog.post", + tableName: "blog_posts", + publication: true, + access: { from: "categoryId" }, // [!code ++] + fields: { + categoryId: field.relation({ + min: 1, + multiple: true, + onDelete: "restrict", + target: () => blogCategoryContentType, + }), + // the other fields stay as they are + }, +}); +``` + +Comments need nothing: a record that answers a parent through its thread root, like `blog.comment` under its `article`, follows the parent's access. + + + + + +### Restrict a category + +Open **AdminCP → Categories** (`/admin/content/blog/categories`). In a category's row menu, choose **Access**, set **Read** to **Selected roles**, pick a role and choose **Save access**. + +The article editor shows **Who can see this** under the categories field. It asks the server what the selected categories require together, so it is right before you save. + + + + + + + +## How several categories combine + +An article needs **all** of its categories' rules. Inside one category a **Selected roles** rule lets in members with **any** of its roles; across categories, the rules add up: + +| Category A | Category B | Who can read the article | +| ------------- | ------------- | --------------------------------------- | +| Everyone | Everyone | Everyone | +| Everyone | VIP | Members with VIP | +| VIP | Staff | Members with both VIP and Staff | +| VIP or Staff | Members | Signed-in members with VIP or Staff | + +Adding a category can only narrow who reads an article, never widen it. The article editor warns about this under **Who can see this**. + +A category a reader may not see is left out of the category list and its counts. An article they can read only ever names categories they can see, because reading it needs every one of them. + +## Check the result + +Create two categories, "News" (open) and "Insiders" (**Read**: a role). Publish one article in "News" and one in both. Signed out, `/blog` lists only the first; `/blog/category/insiders` asks you to sign in, and the second article's address does too. Signed in with the role, both articles show, and their comments work as before. Search for the second article's title in both states: only the member with the role finds it. + +## Related + +- [Access rules](/docs/content-engine/access) +- [Protected attachments](/docs/content-engine/access-attachments) +- [Access rules API](/docs/content-engine/access-api) diff --git a/apps/web/content/docs/content-engine/access-category-notice.webp b/apps/web/content/docs/content-engine/access-category-notice.webp new file mode 100644 index 0000000000..856edeeaee Binary files /dev/null and b/apps/web/content/docs/content-engine/access-category-notice.webp differ diff --git a/apps/web/content/docs/content-engine/access-effective-child.webp b/apps/web/content/docs/content-engine/access-effective-child.webp new file mode 100644 index 0000000000..b8d8bc6980 Binary files /dev/null and b/apps/web/content/docs/content-engine/access-effective-child.webp differ diff --git a/apps/web/content/docs/content-engine/access-forum-restricted.webp b/apps/web/content/docs/content-engine/access-forum-restricted.webp new file mode 100644 index 0000000000..fc29131840 Binary files /dev/null and b/apps/web/content/docs/content-engine/access-forum-restricted.webp differ diff --git a/apps/web/content/docs/content-engine/access-sheet.webp b/apps/web/content/docs/content-engine/access-sheet.webp new file mode 100644 index 0000000000..3b2d7851df Binary files /dev/null and b/apps/web/content/docs/content-engine/access-sheet.webp differ diff --git a/apps/web/content/docs/content-engine/access-sign-in.webp b/apps/web/content/docs/content-engine/access-sign-in.webp new file mode 100644 index 0000000000..eb6a9725b0 Binary files /dev/null and b/apps/web/content/docs/content-engine/access-sign-in.webp differ diff --git a/apps/web/content/docs/content-engine/access.mdx b/apps/web/content/docs/content-engine/access.mdx new file mode 100644 index 0000000000..c294959a60 --- /dev/null +++ b/apps/web/content/docs/content-engine/access.mdx @@ -0,0 +1,165 @@ +--- +title: Access rules +description: Restrict who can see a forum, read its topics, reply and download attachments with VitNode Content Engine access rules. Turn on access for a container collection, then choose everyone, signed-in members or selected roles per forum in the AdminCP. +icon: Lock +implementation: + objective: Let administrators restrict who can view, read, comment in and download attachments from the containers of a collection. + decisions: + - Which container collection gets access rules? Offer the collections defined with `defineContentContainer` in the project. + - Should any action start restricted for every container, for example comments for signed-in members only? + constraints: + - 'Add `access: true` or `access: { defaults }` to `defineContentContainer`. Code defaults are `{ mode: "everyone" }` or `{ mode: "members" }`; roles are chosen in the AdminCP.' + - Before turning access on, make every custom route of the plugin that reads content filed in the collection go through the Content Engine public services or `createContentAccess(c)`. + - Run `vitnode db migrate` once after upgrading so `core_content_access_rules` exists. + related: + - title: Access rules API + href: /docs/content-engine/access-api + - title: How access is enforced + href: /docs/content-engine/access-behavior +--- + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + +import { ImgDocs } from "@/components/fumadocs/img"; +import effectiveChild from "./access-effective-child.webp"; +import forumRestricted from "./access-forum-restricted.webp"; +import accessSheet from "./access-sheet.webp"; +import signIn from "./access-sign-in.webp"; + +Access rules decide who may see a container, read what is filed in it, reply there and download its attachments. Use them for a staff-only forum, a members-only board, or a VIP section that only some roles can open. + +This guide uses the Forum plugin: the `forum.forums` collection, topics filed into forums and comments that follow their topic. + +## The four actions + +Each container has one rule per action. An action needs the one above it, so a role allowed to comment but not to read still cannot comment. + +| Action | What it allows | Needs | +| ------------------------ | --------------------------------------------------------------------------- | ------ | +| **View** | See that the forum exists: its name and description in lists and breadcrumbs | - | +| **Read** | Open its topics, find them in search, Discover and profiles | View | +| **Comment** | Reply to its topics | Read | +| **Download attachments** | Download protected files attached to what the reader can read | Read | + +Each rule is one of three audiences: **Everyone** (guests too), **Signed-in members**, or **Selected roles** (members holding at least one of the roles, as a primary or secondary role). Access rules only add restrictions. Replying still needs a signed-in member and the forum's [publishing rules](/docs/content-engine/container-policies), whatever **Comment** says. + +## Before you begin + +You need a [container collection](/docs/content-engine/containers) with content filed into it. After upgrading VitNode, apply the migration that adds the access tables: + + + +```bash tab="bun" +bun vitnode db migrate +``` + +```bash tab="pnpm" +pnpm vitnode db migrate +``` + +```bash tab="npm" +npx vitnode db migrate +``` + + + + + + + +### Turn on access for the collection + +Add `access` to the collection's definition: + +```ts title="plugins/forum/src/content/forums.ts" +import { defineContentContainer, field } from "@vitnode/core/content"; + +export const forumContainers = defineContentContainer({ + id: "forum.forums", + fields: { + description: field.textarea({ maxLength: 500, nullable: true }), + }, + hierarchy: { maxDepth: 4 }, + access: true, // [!code ++] + publicApi: { path: "forums", fields: ["description"] }, + admin: { path: "forum/forums" }, +}); +``` + +`access: true` starts every action open to everyone, so nothing changes until an administrator restricts a forum. To start stricter, pass defaults instead, such as `access: { defaults: { comment: { mode: "members" } } }`. + + +Turn access on only when every custom route of the plugin that reads topics or comments goes through the Content Engine's public services or `createContentAccess(c)`. A route that queries the table directly ignores the rules. The Forum plugin's stats and topic routes already do this. + + + + + + +### Restrict a forum in the AdminCP + +Open **AdminCP → Forums** (`/admin/containers/forum/forums`). In a forum's row menu, choose **Access**. + +Set **View** to **Signed-in members** for a forum called "Members", and choose **Save access**. Every action left on **Inherit** follows the nearest parent forum that sets it, then the collection's defaults. The **Effective access** panel below the fields shows what applies once parent forums are counted, and updates as you change the fields. + + + +Saving needs the collection's `can_edit` admin permission. The rules apply from the next request, including to members who are signed in right now. + + + + + +### Restrict a child forum to a role + +Under "Members", create a forum called "VIP". Open its **Access** sheet, set **View** to **Selected roles** and pick a VIP role, then save. + +A child can be stricter than its parent, never more open: to reach "VIP", a member has to pass the rules of "Members" too. Its **Effective access** panel lists both requirements: + + + +With "General" left on **Inherit** beside it: + +| Visitor | Members | General | VIP | +| ----------------------------- | ------- | ------- | --- | +| Guest | No | No | No | +| Member without the VIP role | Yes | Yes | No | +| Member with the VIP role | Yes | Yes | Yes | + + + + + +## Check the result + +Sign out and open `/forum`. "Members" and its children are gone from the forum list, and `/forum/members` answers **Forum not found**, as if it never existed. Sign in as a member without the VIP role: "Members" and "General" appear, "VIP" does not. Search for a topic title from "VIP" as both visitors: only a member with the role finds it. + +To try a forum readers may see but not read, set **View** to **Everyone** and **Read** to **Signed-in members**. Guests then see the forum with a **Restricted** label: + + + +Its page and its topics ask guests to sign in instead of showing anything. A signed-in member without access sees **You don't have access here** instead. + + + +## Related + +- [Blog category access](/docs/content-engine/access-categories) +- [Protected attachments](/docs/content-engine/access-attachments) +- [How access is enforced](/docs/content-engine/access-behavior) diff --git a/apps/web/content/docs/content-engine/activity-logs.mdx b/apps/web/content/docs/content-engine/activity-logs.mdx index 107dc00bf7..13670dc2c8 100644 --- a/apps/web/content/docs/content-engine/activity-logs.mdx +++ b/apps/web/content/docs/content-engine/activity-logs.mdx @@ -55,6 +55,18 @@ operation the person asked for: | Duplicate a record | One `content.duplicated` on the copy, with the source id | | Save shared fields and a translation together | One entry per part that moved, sharing a request id | +## Access rules + +Saving a forum's or a category's [access rules](/docs/content-engine/access) +records `content.access_updated` under the plugin that owns them, labelled +with the forum or category name. Its changes hold the old and new rule of each +action that moved, as `inherit`, `everyone`, `members` or `roles: VIP, Staff` +with the role names of that moment. A save that changes nothing records +nothing, and a save that fails leaves no entry. + +**Protect public files** records `content.access_files_protected` with how +many files moved, how many other content still uses and how many are left. + ## Scheduled publishing The system runs a scheduled publish or unpublish, so its entry has the diff --git a/apps/web/content/docs/content-engine/meta.json b/apps/web/content/docs/content-engine/meta.json index ab12947918..9257ce7687 100644 --- a/apps/web/content/docs/content-engine/meta.json +++ b/apps/web/content/docs/content-engine/meta.json @@ -31,6 +31,11 @@ "containers", "container-hierarchy", "container-frontend", + "---Access---", + "access", + "access-categories", + "access-attachments", + "access-behavior", "---Moderation---", "approvals", "approval-notices", @@ -69,6 +74,7 @@ "public-permissions-api", "comments-api", "container-policies-api", + "access-api", "live-editing-api" ] } diff --git a/apps/web/content/docs/dev/audit-logs/index.mdx b/apps/web/content/docs/dev/audit-logs/index.mdx index bb84690923..a0569c6c02 100644 --- a/apps/web/content/docs/dev/audit-logs/index.mdx +++ b/apps/web/content/docs/dev/audit-logs/index.mdx @@ -47,7 +47,7 @@ after, an **Open** link to the target and **Show this person's activity**. | Area | Recorded automatically | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| Content Engine | Create, edit, delete, duplicate, publish, unpublish, hide, unhide, restore, schedule and every translation change of every content type | +| Content Engine | Create, edit, delete, duplicate, publish, unpublish, hide, unhide, restore, schedule and every translation change of every content type; access rule changes and protected files | | Roles and staff | Roles created, edited and deleted; administrator and moderator permissions added, changed and removed | | Users | Profile and role changes made in the AdminCP, password resets, email verification, signed-out devices | | Settings | AI settings and access, notification types and danger-zone actions, report reasons, log retention and log exports | diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index 90e2989a14..675f9647df 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -24,6 +24,7 @@ the event map is global. | [Page layouts](#page-layout-events) | `core.page-layout.updated` | | [Content Engine](#content-engine-events) | `content..` | | [Content containers](#content-container-events) | `content.container.*` | +| [Content access](#content-access-events) | `content.access.updated` | | [Content approvals](#content-approval-events) | `content.approval.*` | | [Blog](#blog-events-vitnodeblog) | `blog.post.*`, `blog.category.*` | @@ -1284,6 +1285,64 @@ export const deskDeletedListener = buildEventListener({ +## Content access events + +| Event | Payload | Emitted when | +| ------------------------ | --------------------------------------------------------------- | ----------------------------------------------------------------- | +| `content.access.updated` | `{ family, scopeId, pluginId, changedActions, userId }` | An administrator saved [access rules](/docs/content-engine/access) that changed at least one action of a container, a blog category or another scope | + + + + +Emitted by the AdminCP access routes after the new rules are saved and every +cache they decide has been expired. Saving the same rules again emits nothing. + + + +```ts title="plugins/forum/src/api/lib/events.ts" +import { buildEventListener } from '@vitnode/core/api/lib/events' + +export const logForumAccess = buildEventListener({ + event: 'content.access.updated', + name: 'log-forum-access', + handler: async (c, payload) => { + if (payload.family !== 'forum.forums') return + + await c + .get('log') + .info(`Forum ${payload.scopeId}: ${payload.changedActions.join(', ')}`) + }, +}) +``` + + + + ## Content approval events Three names, declared in `VitNodeEvents` in diff --git a/apps/web/content/docs/dev/storage/custom-adapter.mdx b/apps/web/content/docs/dev/storage/custom-adapter.mdx index 2501a08c40..b1a3e3746c 100644 --- a/apps/web/content/docs/dev/storage/custom-adapter.mdx +++ b/apps/web/content/docs/dev/storage/custom-adapter.mdx @@ -185,6 +185,28 @@ error message in it. keys](/docs/dev/storage#storage-keys). +## The optional private store + +An adapter that can keep objects where no public URL reaches them adds +`private`. VitNode then stores [protected +attachments](/docs/content-engine/access-attachments) there and streams them +through routes that check the reader: + +```ts +export interface StoragePrivateStore { + upload: (args: StorageUploadArgs) => Promise<{ key: string }> + read: ( + key: string, + ) => Promise; size?: number }> + delete: (key: string) => Promise +} +``` + +`read` returns `null` for a missing object. Leave `private` out when the +adapter cannot guarantee privacy, such as a bucket whose policy makes every +object public: the AdminCP then refuses to restrict attachment downloads +instead of promising something storage cannot keep. + ## The optional static descriptor Disk-backed adapters can expose a fourth field, and only they should: diff --git a/apps/web/content/docs/dev/storage/local.mdx b/apps/web/content/docs/dev/storage/local.mdx index 5de237e774..9ec3420d2d 100644 --- a/apps/web/content/docs/dev/storage/local.mdx +++ b/apps/web/content/docs/dev/storage/local.mdx @@ -262,6 +262,13 @@ outside the API's own base path strips nothing and every file answers `404`. the table (or `deleteFile`) so the row and the bytes go together. + + Files of [protected attachments](/docs/content-engine/access-attachments) go + to `storage/private`, also under `process.cwd()`. Nothing serves that folder; + VitNode streams its files through a route that checks the reader. Never mount + it as static files, and back it up with `public/uploads`. + + `public/uploads` is state. A container that recreates its filesystem on deploy loses every upload, and no `core_files` row can bring the bytes back. Mount a diff --git a/apps/web/content/docs/dev/storage/s3-r2.mdx b/apps/web/content/docs/dev/storage/s3-r2.mdx index 4f48392954..bdf7c457db 100644 --- a/apps/web/content/docs/dev/storage/s3-r2.mdx +++ b/apps/web/content/docs/dev/storage/s3-r2.mdx @@ -235,9 +235,31 @@ hostname in `publicUrl`. 'Use path-style addressing (`endpoint/bucket/key`). Defaults to `true` whenever `endpoint` is set.', type: 'boolean', }, + privateBucket: { + description: + 'A second bucket nothing public reaches, for [protected attachments](/docs/content-engine/access-attachments). Without it, attachment downloads cannot be restricted.', + type: 'string', + }, }} /> +## Keep some files private + +[Access rules](/docs/content-engine/access) can restrict who downloads the +attachments of a forum or category. Those files go to `privateBucket` and are +served only through a route that checks the reader. Create a second bucket with +no public policy, no `r2.dev` or custom domain and no CDN in front of it, give +the access key the same three actions on it, and pass its name: + +```ts title="apps/api/src/vitnode.api.config.ts" +S3StorageAdapter({ + accessKeyId: process.env.S3_ACCESS_KEY_ID, + bucket: process.env.S3_BUCKET, + privateBucket: process.env.S3_PRIVATE_BUCKET, // [!code ++] + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, +}) +``` + ## Gotchas - - VitNode serves files by URL, and the built-in download routes fetch that URL + + VitNode serves the files in `bucket` by URL, and the built-in download routes fetch that URL server-side before re-streaming it. If neither the bucket nor `publicUrl` is publicly readable, thumbnails never load and both download routes turn the refused fetch into a `404`. Front the bucket with CloudFront, an R2 custom - domain, or make it public. + domain, or make it public. Files that must stay private belong in + `privateBucket` instead. diff --git a/apps/web/content/docs/dev/storage/supabase.mdx b/apps/web/content/docs/dev/storage/supabase.mdx index 42d3c94bae..afcdf69a16 100644 --- a/apps/web/content/docs/dev/storage/supabase.mdx +++ b/apps/web/content/docs/dev/storage/supabase.mdx @@ -199,9 +199,30 @@ public matters more here than it looks. type: 'string', required: true, }, + privateBucket: { + description: + 'A second bucket with **Public bucket** off, for [protected attachments](/docs/content-engine/access-attachments). Without it, attachment downloads cannot be restricted.', + type: 'string', + }, }} /> +## Keep some files private + +[Access rules](/docs/content-engine/access) can restrict who downloads the +attachments of a forum or category. Those files go to `privateBucket` and are +served only through a route that checks the reader. Create a second bucket with +**Public bucket** off and pass its name: + +```ts title="apps/api/src/vitnode.api.config.ts" +SupabaseStorageAdapter({ + bucket: process.env.SUPABASE_STORAGE_BUCKET, + privateBucket: process.env.SUPABASE_STORAGE_PRIVATE_BUCKET, // [!code ++] + secretKey: process.env.SUPABASE_SECRET_KEY, + url: process.env.SUPABASE_URL, +}) +``` + ## Gotchas diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template b/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template index 0b346262cc..62590c7b9f 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template @@ -27,6 +27,7 @@ yarn-error.log* # Local storage adapter uploads /public/uploads +/storage/private # VitNode /docker diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template b/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template index 59c94b1263..9175728cdc 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template @@ -35,6 +35,7 @@ yarn-error.log* # Local storage adapter uploads /public/uploads +/storage/private # VitNode /docker diff --git a/packages/elasticsearch/src/index.ts b/packages/elasticsearch/src/index.ts index 4c47e94944..e0ce36d822 100644 --- a/packages/elasticsearch/src/index.ts +++ b/packages/elasticsearch/src/index.ts @@ -45,6 +45,9 @@ export interface ElasticsearchAdapterOptions { } interface EsSource { + /** `family:scopeId` of every access scope the item is filed in. */ + accessScopeCount?: number; + accessScopes?: string[]; authorIds?: number[]; containerId: null | number; containerType: null | string; @@ -94,6 +97,8 @@ const toSource = (doc: SearchDocument): EsSource => ({ content: doc.content, containerType: doc.containerType ?? null, containerId: doc.containerId ?? null, + accessScopes: [...new Set(doc.accessScopes ?? [])], + accessScopeCount: new Set(doc.accessScopes ?? []).size, url: doc.url ?? null, isPublic: doc.isPublic ?? true, isHidden: doc.isHidden ?? false, @@ -168,6 +173,27 @@ const buildFilters = ( if (!params.includePrivate) { filter.push(visibleFilter(params)); } + if (params.accessScopes) { + // Every scope of the document has to be allowed: `terms_set` matches when + // as many of its tokens are in the list as it has tokens. A document in no + // scope has none, which `exists` cannot see, so it passes on its own. + filter.push({ + bool: { + minimum_should_match: 1, + should: [ + { bool: { must_not: { exists: { field: "accessScopes" } } } }, + { + terms_set: { + accessScopes: { + minimum_should_match_field: "accessScopeCount", + terms: [...params.accessScopes], + }, + }, + }, + ], + }, + }); + } return filter; }; @@ -310,6 +336,8 @@ export const ElasticsearchSearchAdapter = ( await es.indices.putMapping({ index, properties: { + accessScopeCount: { type: "integer" }, + accessScopes: { type: "keyword" }, imageId: { type: "integer" }, isHidden: { type: "boolean" }, itemKey: { type: "keyword" }, @@ -334,6 +362,8 @@ export const ElasticsearchSearchAdapter = ( content: { type: "text" }, containerType: { type: "keyword" }, containerId: { type: "integer" }, + accessScopes: { type: "keyword" }, + accessScopeCount: { type: "integer" }, url: { type: "keyword" }, isPublic: { type: "boolean" }, isHidden: { type: "boolean" }, diff --git a/packages/s3/src/index.ts b/packages/s3/src/index.ts index 563342f37f..2fc3a4c056 100644 --- a/packages/s3/src/index.ts +++ b/packages/s3/src/index.ts @@ -25,6 +25,7 @@ export const S3StorageAdapter = ({ bucket = "", endpoint, forcePathStyle, + privateBucket, publicUrl, region = "auto", secretAccessKey = "", @@ -33,6 +34,13 @@ export const S3StorageAdapter = ({ bucket: string | undefined; endpoint?: string; forcePathStyle?: boolean; + /** + * A second bucket for protected attachments, read only with these + * credentials. It must not be public - no public bucket policy, no public + * R2 domain, no CDN in front of it. Leave it out and attachment downloads + * cannot be restricted. + */ + privateBucket?: string; publicUrl?: string; region?: string; secretAccessKey: string | undefined; @@ -69,6 +77,46 @@ export const S3StorageAdapter = ({ }; return { + ...(privateBucket + ? { + private: { + delete: async (key: string): Promise => { + await getClient().send( + new DeleteObjectCommand({ Bucket: privateBucket, Key: key }), + ); + }, + read: async (key: string) => { + try { + const object = await getClient().send( + new GetObjectCommand({ Bucket: privateBucket, Key: key }), + ); + if (!object.Body) return null; + + return { + body: object.Body.transformToWebStream() as ReadableStream, + ...(object.ContentLength === undefined + ? {} + : { size: object.ContentLength }), + }; + } catch { + return null; + } + }, + upload: async ({ body, contentType, key }: StorageUploadArgs) => { + await getClient().send( + new PutObjectCommand({ + Body: body, + Bucket: privateBucket, + ContentType: contentType, + Key: key, + }), + ); + + return { key }; + }, + }, + } + : {}), delete: async (key: string): Promise => { await getClient().send( new DeleteObjectCommand({ Bucket: bucket, Key: key }), diff --git a/packages/supabase-storage/src/index.ts b/packages/supabase-storage/src/index.ts index 6bde75dac2..e076f39945 100644 --- a/packages/supabase-storage/src/index.ts +++ b/packages/supabase-storage/src/index.ts @@ -52,10 +52,16 @@ const readCapped = async ( // sent verbatim as the `apikey`/`Authorization` header. export const SupabaseStorageAdapter = ({ bucket = "", + privateBucket, secretKey = "", url = "", }: { bucket: string | undefined; + /** + * A second, **private** bucket for protected attachments, read with the + * secret key. Leave it out and attachment downloads cannot be restricted. + */ + privateBucket?: string; secretKey: string | undefined; url: string | undefined; }): StorageApiPlugin => { @@ -78,6 +84,35 @@ export const SupabaseStorageAdapter = ({ getClient().from(bucket).getPublicUrl(key).data.publicUrl; return { + ...(privateBucket + ? { + private: { + delete: async (key: string): Promise => { + const { error } = await getClient() + .from(privateBucket) + .remove([key]); + if (error) throw error; + }, + read: async (key: string) => { + const { data, error } = await getClient() + .from(privateBucket) + .download(key) + .asStream(); + if (error) return null; + + return { body: data as ReadableStream }; + }, + upload: async ({ body, contentType, key }: StorageUploadArgs) => { + const { error } = await getClient() + .from(privateBucket) + .upload(key, body, { contentType, upsert: true }); + if (error) throw error; + + return { key }; + }, + }, + } + : {}), read: async (key: string, { maxBytes }: { maxBytes: number }) => { const { data, error } = await getClient() .from(bucket) diff --git a/packages/vitnode/src/api/adapters/storage/local.ts b/packages/vitnode/src/api/adapters/storage/local.ts index 9c17467d42..ab99a3653c 100644 --- a/packages/vitnode/src/api/adapters/storage/local.ts +++ b/packages/vitnode/src/api/adapters/storage/local.ts @@ -1,5 +1,7 @@ +import { createReadStream } from "node:fs"; import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { Readable } from "node:stream"; import type { StorageApiPlugin, @@ -16,7 +18,9 @@ import { CONFIG } from "@/lib/config"; * ephemeral filesystem. * * Files are served only once the app mounts `serveStatic` from the adapter's - * `static` descriptor in its server entry. Server-only. + * `static` descriptor in its server entry. Private files (protected + * attachments) go to `storage/private`, which no static mount reaches - keep + * it that way. Server-only. * * @example * ```ts @@ -34,6 +38,9 @@ export const LocalStorageAdapter = ({ /** The URL path files are served under. Defaults to `/api/uploads`. */ publicPath?: string; } = {}): StorageApiPlugin => { + // Outside `public/` on purpose: nothing serves this folder statically. + const resolvePrivatePath = (key: string): string => + join(process.cwd(), "storage", "private", key); // Static path segments (not a variable) so Next's file tracer can scope the // trace to `public/uploads` instead of the whole project. const resolvePath = (key: string): string => @@ -63,6 +70,33 @@ export const LocalStorageAdapter = ({ return null; } }, + private: { + delete: async (key: string): Promise => { + await rm(resolvePrivatePath(key), { force: true }); + }, + read: async (key: string) => { + const filePath = resolvePrivatePath(key); + try { + const { size } = await stat(filePath); + + return { + body: Readable.toWeb( + createReadStream(filePath), + ) as ReadableStream, + size, + }; + } catch { + return null; + } + }, + upload: async ({ body, key }: StorageUploadArgs) => { + const filePath = resolvePrivatePath(key); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, body); + + return { key }; + }, + }, static: { mountPath, root: "./public/uploads", diff --git a/packages/vitnode/src/api/lib/editor-uploads.ts b/packages/vitnode/src/api/lib/editor-uploads.ts index 11c5f3838d..08e062bdff 100644 --- a/packages/vitnode/src/api/lib/editor-uploads.ts +++ b/packages/vitnode/src/api/lib/editor-uploads.ts @@ -4,7 +4,13 @@ import { z } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; import type { EnvVitNode } from "@/api/middlewares/global.middleware"; +import type { AnyContentModel } from "@/content/server/model"; +import { + contentPrivateAdminFileUrl, + contentUploadVisibility, +} from "@/content/access/server/files"; +import { CONFIG } from "@/lib/config"; import { EDITOR_ATTACHMENT_MAX_BYTES, EDITOR_ATTACHMENT_MIME_TYPES, @@ -18,6 +24,11 @@ import { } from "@/lib/editor-uploads"; const zodEditorUploadForm = z.object({ + /** + * The content type the document belongs to. One with access rules (and an + * adapter with a private store) gets the file stored privately. + */ + contentType: z.string().max(100).optional(), file: z.instanceof(File).openapi({ format: "binary", type: "string" }), }); @@ -100,9 +111,19 @@ export const storeEditorUpload = async ( c: Context, { attachments, + contentType, file, + session = "admin", userId, - }: { attachments: boolean; file: File; userId: number }, + }: { + attachments: boolean; + /** The content type the document belongs to, when it is content. */ + contentType?: string; + file: File; + /** Which session uploads, for the URL a private file is shown from. */ + session?: "admin" | "member"; + userId: number; + }, ): Promise => { const isImage = isEditorImageMimeType(file.type); const isAttachment = @@ -125,6 +146,13 @@ export const storeEditorUpload = async ( } } + const model = contentType + ? ( + (c.get("core")?.contentModels ?? []) as { model: AnyContentModel }[] + ).find(entry => entry.model.definition.id === contentType)?.model + : undefined; + const visibility = model ? contentUploadVisibility(c, model) : "public"; + try { const stored = await c.get("storage").upload({ allowedMimeTypes: isImage @@ -133,8 +161,12 @@ export const storeEditorUpload = async ( file, folder: EDITOR_UPLOAD_FOLDER, maxBytes: isImage ? EDITOR_IMAGE_MAX_BYTES : EDITOR_ATTACHMENT_MAX_BYTES, - metadata: { source: "editor" }, + metadata: { + source: "editor", + ...(model ? { contentTypeId: model.definition.id } : {}), + }, userId, + visibility, }); const kind = isImage ? "image" : "attachment"; @@ -150,7 +182,15 @@ export const storeEditorUpload = async ( mimeType: stored.mimeType, name: stored.name, size: stored.size, - url: stored.url, + // A private file has no public URL: the uploader sees it through the + // download route of their own session until the document is saved, + // and readers through the record's authorized route. + url: + visibility === "private" + ? session === "admin" + ? contentPrivateAdminFileUrl(stored.id) + : `${CONFIG.api.origin.replace(/\/$/, "")}/api/@vitnode/core/users/files/${stored.id}/download?inline=true` + : stored.url, width: stored.dimensions?.width ?? null, }, status: 200, diff --git a/packages/vitnode/src/api/lib/file-download.test.ts b/packages/vitnode/src/api/lib/file-download.test.ts index a4851aec37..e3afa5d59f 100644 --- a/packages/vitnode/src/api/lib/file-download.test.ts +++ b/packages/vitnode/src/api/lib/file-download.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { EnvVitNode } from "../middlewares/global.middleware"; +import { StorageModel } from "../models/storage"; import { attachStoredFile } from "./file-download"; const STORAGE_URL = "https://storage.example/bucket/key-1"; @@ -13,16 +14,19 @@ const getUrl = vi.fn(() => STORAGE_URL); /** The parts of the request context this helper is allowed to touch. */ const context = () => { const headers = new Headers(); + const c = { + body: (body: BodyInit, status: number) => + new Response(body, { headers, status }), + get: (key: string) => { + if (key === "core") return { storage: { adapter: { getUrl } } }; + if (key === "storage") return new StorageModel(c); + + return undefined; + }, + header: (name: string, value: string) => headers.set(name, value), + } as unknown as Context; - return { - context: { - body: (body: BodyInit, status: number) => - new Response(body, { headers, status }), - get: (key: string) => (key === "storage" ? { getUrl } : undefined), - header: (name: string, value: string) => headers.set(name, value), - } as unknown as Context, - headers, - }; + return { context: c, headers }; }; const upstreamFetch = vi.fn<(url: string) => Promise>(); diff --git a/packages/vitnode/src/api/lib/file-download.ts b/packages/vitnode/src/api/lib/file-download.ts index 3b1ef3b5f3..4d870e7da7 100644 --- a/packages/vitnode/src/api/lib/file-download.ts +++ b/packages/vitnode/src/api/lib/file-download.ts @@ -7,39 +7,50 @@ export interface StoredFileForDownload { key: string; mimeType: null | string; name: string; + /** Where the object lives. Defaults to `public`. */ + visibility?: "private" | "public"; } /** - * One stored object, re-streamed as an attachment under its original name. + * One stored object, re-streamed under its original name: as an attachment by + * default, or `inline` for an image a page displays. * - * Adapter-agnostic: the object is fetched from its (server-reachable) public - * URL, so the browser saves it with the name the uploader gave it instead of - * opening it inline under a storage key. + * Adapter-agnostic: a public object is fetched from its (server-reachable) + * public URL, a private one from the adapter's private store - which is the + * only way a private file ever leaves storage. Every response is + * `private, no-store`, `nosniff` and sandboxed, so a file can never run as a + * page of the site. * - * `null` when the object could not be fetched at all, which each route answers - * with its own `404 { error: "File not found" }`. Who may download which file - * is decided before this is ever called - the lookup and the authorization stay - * in the routes, and this only knows how to send one. + * `null` when the object could not be read at all, which each route answers + * with its own 404. Who may download which file is decided before this is + * ever called - the lookup and the authorization stay in the routes, and this + * only knows how to send one. */ export const attachStoredFile = async ( c: Context, file: StoredFileForDownload, + { inline = false }: { inline?: boolean } = {}, ): Promise => { - const upstream = await fetch(c.get("storage").getUrl(file.key)); - if (!upstream.ok || !upstream.body) return null; + const object = await c + .get("storage") + .openFile({ key: file.key, visibility: file.visibility ?? "public" }); + if (!object) return null; + const isImage = file.mimeType?.startsWith("image/") === true; c.header("Content-Type", file.mimeType ?? "application/octet-stream"); // `filename*` with percent-encoding rather than a bare `filename`: a name can // carry a quote, a semicolon or anything non-ASCII, and any of those would // otherwise break the header or truncate the name. c.header( "Content-Disposition", - `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`, + `${inline && isImage ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(file.name)}`, ); - const length = upstream.headers.get("content-length"); - if (length) { - c.header("Content-Length", length); + c.header("Cache-Control", "private, no-store"); + c.header("X-Content-Type-Options", "nosniff"); + c.header("Content-Security-Policy", "sandbox; default-src 'none'"); + if (object.size !== undefined) { + c.header("Content-Length", String(object.size)); } - return c.body(upstream.body, 200); + return c.body(object.body, 200); }; diff --git a/packages/vitnode/src/api/lib/search-filters.test.ts b/packages/vitnode/src/api/lib/search-filters.test.ts new file mode 100644 index 0000000000..06c18360f9 --- /dev/null +++ b/packages/vitnode/src/api/lib/search-filters.test.ts @@ -0,0 +1,34 @@ +import type { Context } from "hono"; + +import { PgDialect } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; + +import { searchIndexFilters } from "./search-filters"; + +const c = { get: () => undefined } as unknown as Context; + +const render = (accessScopes?: null | string[]) => { + const condition = searchIndexFilters(c, { + accessScopes, + includePrivate: true, + }); + + return condition ? new PgDialect().sqlToQuery(condition) : undefined; +}; + +describe("searchIndexFilters access scopes", () => { + it.each([[["forum.forums:1", "blog.category:3"]], [[]]])( + "keeps a row only when every scope it is filed in is in %j", + allowed => { + expect(render(allowed)).toEqual({ + params: [allowed], + sql: '"core_search_index"."accessScopes" <@ $1::text[]', + typings: undefined, + }); + }, + ); + + it.each([undefined, null])("adds no access filter for %s", allowed => { + expect(render(allowed)).toBeUndefined(); + }); +}); diff --git a/packages/vitnode/src/api/lib/search-filters.ts b/packages/vitnode/src/api/lib/search-filters.ts index eb4e22a884..83bc260cb7 100644 --- a/packages/vitnode/src/api/lib/search-filters.ts +++ b/packages/vitnode/src/api/lib/search-filters.ts @@ -125,6 +125,13 @@ export const searchIndexFilters = ( } const visible = visibleCondition(core_search_index, params); if (visible) conditions.push(visible); + // Every scope the item is filed in has to be one the viewer may read. An + // empty list (nothing readable) still keeps items filed in no scope. + if (params.accessScopes) { + conditions.push( + sql`${core_search_index.accessScopes} <@ ${sql.param([...params.accessScopes])}::text[]`, + ); + } return conditions.length ? and(...conditions) : undefined; }; diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 40d54d76ce..65f06552e1 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -66,6 +66,15 @@ export interface VitNodeEvents { runId: number; userId: null | number; }; + "content.access.updated": { + /** The actions whose rule this scope now sets, inherits or changed. */ + changedActions: string[]; + /** The container collection or scope content type id. */ + family: string; + pluginId: string; + scopeId: string; + userId: null | number; + }; "content.approval.approved": ContentApprovalEventPayload & { reviewerId: number; }; diff --git a/packages/vitnode/src/api/models/search.test.ts b/packages/vitnode/src/api/models/search.test.ts index f078d04e23..3a6343a544 100644 --- a/packages/vitnode/src/api/models/search.test.ts +++ b/packages/vitnode/src/api/models/search.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest"; import type { RegisteredContentType } from "@/content/registry"; import { core_search_index } from "@/database/search"; +import { createAccessWorld, TOPIC, USER } from "@/tests/content-access"; import { testBigintEventContentType, testLocalizedPageContentType, @@ -535,3 +536,97 @@ describe("item ids under every strategy", () => { expect(searchItemIdFromKey(types, "forum.topic", "abc")).toBe("abc"); }); }); + +describe("SearchModel access rules", () => { + const hit = (itemId: number) => ({ + author: null, + authorId: null, + authorIds: [], + authors: [], + containerId: null, + containerType: null, + content: "", + createdAt: new Date("2026-01-01"), + id: itemId, + image: null, + imageId: null, + isHidden: false, + isPublic: true, + itemId: String(itemId), + itemType: "test.access-topic", + languageCode: "", + metadata: {}, + pluginId: "@vitnode/example", + score: null, + title: `Topic ${itemId}`, + url: null, + }); + + const searchAs = async ( + userId: null | number, + options: Parameters[0] = {}, + ) => { + const world = await createAccessWorld(options); + const provider = createProvider(); + const values = world.contextValues(userId); + const core = { + ...(values.core as object), + search: { adapter: provider }, + }; + const c = { + get: (key: string) => (key === "core" ? core : values[key]), + } as unknown as Context; + + return { model: new SearchModel(c), provider }; + }; + + it("adds no access filter while nothing is restricted", async () => { + const { model, provider } = await searchAs(null, { rules: {} }); + + await model.search({ term: "hello" }); + + expect(provider.search).toHaveBeenCalledWith(expect.anything(), { + term: "hello", + }); + }); + + it("limits a restricted viewer to the scopes they may read", async () => { + const { model, provider } = await searchAs(null); + + await model.search({ term: "hello" }); + + const [, params] = vi.mocked(provider.search).mock.calls[0]; + expect([...(params.accessScopes ?? [])].sort()).toEqual([ + "test.access-boards:1", + "test.access-boards:5", + ]); + }); + + it("lets a staff route skip the rules, and never trusts a caller's scopes", async () => { + const { model, provider } = await searchAs(null); + + await model.search({ accessScopes: ["anything:1"], skipAccess: true }); + + expect(provider.search).toHaveBeenCalledWith(expect.anything(), {}); + }); + + it("drops a hit the content tables no longer let the viewer read, and stops counting it", async () => { + const { model, provider } = await searchAs(USER.MEMBER); + vi.mocked(provider.search).mockResolvedValue({ + edges: [hit(TOPIC.OPEN), hit(TOPIC.VIP)], + pageInfo: { + count: 2, + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + totalCount: 10, + }, + }); + + const result = await model.search({ term: "topic" }); + + expect(result.edges.map(edge => edge.itemId)).toEqual([String(TOPIC.OPEN)]); + expect(result.pageInfo).toMatchObject({ count: 1, totalCount: 9 }); + }); +}); diff --git a/packages/vitnode/src/api/models/search.ts b/packages/vitnode/src/api/models/search.ts index f1a9f86c37..e93347d91d 100644 --- a/packages/vitnode/src/api/models/search.ts +++ b/packages/vitnode/src/api/models/search.ts @@ -27,6 +27,12 @@ import { canonicalSearchSuggestions, } from "@/api/lib/search-suggestions"; import { storageUrlOf } from "@/api/lib/storage-url"; +import { contentProtectedFileUrl } from "@/content/access/server/files"; +import { + contentSearchAllowedScopes, + verifyContentSearchHits, + withContentSearchAccessScopes, +} from "@/content/access/server/search"; import { contentIdFromKey, contentIdKey } from "@/content/ids"; import { contentLocalesMatch } from "@/content/locale"; import { core_files } from "@/database/files"; @@ -49,6 +55,12 @@ const BULK_UPSERT_CHUNK = 500; * it to {@link SearchModel}. */ export interface SearchDocument { + /** + * The access scopes the item is filed in, as `family:scopeId`. Filled in by + * {@link SearchModel} from the content tables for every access-controlled + * content type - whatever a builder passes is replaced. + */ + accessScopes?: readonly string[]; /** Users credited for the item, for author filters and author hits. */ authorIds?: readonly number[]; /** The forum, category or other container holding the item. */ @@ -89,6 +101,12 @@ export interface SearchDocument { /** A search query: filters, sorting and cursor pagination. */ export interface SearchQueryParams { + /** + * The scope tokens the viewer may read: a document is found only when every + * one of its `accessScopes` is listed. `null` or absent is no access filter. + * {@link SearchModel} fills it in for the request's viewer. + */ + accessScopes?: null | readonly string[]; authorId?: number; authorIds?: readonly number[]; containerId?: number; @@ -105,6 +123,11 @@ export interface SearchQueryParams { // `languageCode` (language-agnostic content) always match. languageCode?: string; languageFallbacks?: SearchLanguageFallback[]; + /** + * Skips the access rules of content scopes as well - only for staff routes + * that already checked an AdminCP permission. Defaults to `false`. + */ + skipAccess?: boolean; sort?: "newest" | "oldest" | "relevance"; term?: string; } @@ -391,6 +414,34 @@ export interface SearchProviderApiPlugin { search: (c: Context, params: SearchQueryParams) => Promise; } +/** The authorized URL of a private image a hit shows, or `null`. */ +const searchPrivateImageUrl = ( + c: Context, + edge: { itemId?: ContentId; itemType?: string }, + fileId: number, +): null | string => { + if (edge.itemId === undefined || edge.itemType === undefined) return null; + const entry = ( + (c.get("core")?.contentModels ?? []) as { + model: { + definition: { + id: string; + publicApi: { enabled: boolean; path: string }; + }; + }; + pluginId: string; + }[] + ).find(candidate => candidate.model.definition.id === edge.itemType); + if (!entry?.model.definition.publicApi.enabled) return null; + + return contentProtectedFileUrl({ + fileId, + itemId: edge.itemId, + pluginId: entry.pluginId, + publicPath: entry.model.definition.publicApi.path, + }); +}; + const toRow = (doc: SearchDocument) => ({ pluginId: doc.pluginId ?? "core", itemType: doc.itemType, @@ -401,6 +452,7 @@ const toRow = (doc: SearchDocument) => ({ content: doc.content, containerType: doc.containerType ?? null, containerId: doc.containerId ?? null, + accessScopes: [...new Set(doc.accessScopes ?? [])], url: doc.url ?? null, isPublic: doc.isPublic ?? true, isHidden: doc.isHidden ?? false, @@ -474,9 +526,9 @@ export class SearchModel { }); } - private async hydrateImages( - edges: T[], - ): Promise<(T & { image: null | SearchHitImage })[]> { + private async hydrateImages< + T extends { imageId: null | number; itemId?: ContentId; itemType?: string }, + >(edges: T[]): Promise<(T & { image: null | SearchHitImage })[]> { const wanted = [ ...new Set( edges.flatMap(edge => (edge.imageId === null ? [] : [edge.imageId])), @@ -493,36 +545,34 @@ export class SearchModel { key: core_files.key, metadata: core_files.metadata, mimeType: core_files.mimeType, + visibility: core_files.visibility, }) .from(core_files) .where(inArray(core_files.id, wanted)); - const byId = new Map( - rows.flatMap(row => { - const url = row.mimeType?.startsWith("image/") - ? storageUrlOf(this.c, row.key) - : null; - if (!url) return []; - const dimensions = parseImageDimensions(row.metadata); - - return [ - [ - row.id, - { - url, - ...(dimensions - ? { height: dimensions.height, width: dimensions.width } - : {}), - }, - ] as const, - ]; - }), - ); + const byId = new Map(rows.map(row => [row.id, row])); + // A private image is shown through its record's authorized route - the + // hit was already checked against the reader's access, and the route + // checks again for every request. + const imageOf = (edge: T): null | SearchHitImage => { + const row = edge.imageId === null ? undefined : byId.get(edge.imageId); + if (!row?.mimeType?.startsWith("image/")) return null; + const url = + row.visibility === "private" + ? searchPrivateImageUrl(this.c, edge, row.id) + : storageUrlOf(this.c, row.key); + if (!url) return null; + const dimensions = parseImageDimensions(row.metadata); + + return { + url, + ...(dimensions + ? { height: dimensions.height, width: dimensions.width } + : {}), + }; + }; - return edges.map(edge => ({ - ...edge, - image: edge.imageId === null ? null : (byId.get(edge.imageId) ?? null), - })); + return edges.map(edge => ({ ...edge, image: imageOf(edge) })); } private async newCommentParent( @@ -604,6 +654,7 @@ export class SearchModel { content: sql`excluded."content"`, containerType: sql`excluded."containerType"`, containerId: sql`excluded."containerId"`, + accessScopes: sql`excluded."accessScopes"`, url: sql`excluded."url"`, isPublic: sql`excluded."isPublic"`, isHidden: sql`excluded."isHidden"`, @@ -617,6 +668,36 @@ export class SearchModel { } } + /** The suggestions the viewer may read now; stale ones are re-stamped. */ + private async verified( + items: T[], + ): Promise { + const { dropped, kept } = await verifyContentSearchHits(this.c, items); + if (dropped.length > 0) { + const contentTypes = this.c.get("core")?.contentTypes ?? []; + await this.refreshAccessScopes( + dropped.map(item => ({ + itemId: searchItemIdFromKey(contentTypes, item.itemType, item.itemId), + itemType: item.itemType, + })), + ).catch(() => 0); + } + + return kept; + } + + /** The viewer's access filter, unless the caller is a staff route that skips it. */ + private async withAccess( + params: SearchQueryParams, + ): Promise { + // Only this model sets the filter: whatever a caller passed is dropped. + const { accessScopes: _ignored, skipAccess, ...rest } = params; + if (skipAccess) return rest; + const accessScopes = await contentSearchAllowedScopes(this.c); + + return accessScopes ? { ...rest, accessScopes } : rest; + } + private withFallbacks(params: SearchQueryParams): SearchQueryParams { const languageFallbacks = params.languageCode ? searchLanguageFallbacks( @@ -646,9 +727,15 @@ export class SearchModel { term?: string; }): Promise { const db = this.c.get("db"); + // Only authors credited on what this viewer may read: a member who only + // writes in a restricted scope is not listed for anybody outside it. + const allowedScopes = await contentSearchAllowedScopes(this.c); const listed = and( eq(core_search_index.isPublic, true), eq(core_search_index.isHidden, false), + allowedScopes + ? sql`${core_search_index.accessScopes} <@ ${sql.param(allowedScopes)}::text[]` + : undefined, ); const columns = { id: core_users.id, @@ -697,31 +784,36 @@ export class SearchModel { return rows.map(toAuthor); } - const topAuthorIds = await this.c - .get("cache") - .remember( - `search:top-authors:${limit}`, - SEARCH_TOP_AUTHORS_TTL_SECONDS, - async () => { - const activity = db - .select({ - authorId: sql`unnest(${core_search_index.authorIds})`.as( - "author_id", - ), - }) - .from(core_search_index) - .where(listed) - .as("activity"); - const top = await db - .select({ authorId: activity.authorId, total: count() }) - .from(activity) - .groupBy(activity.authorId) - .orderBy(desc(count()), asc(activity.authorId)) - .limit(limit); - - return top.map(row => row.authorId); - }, - ); + const loadTopAuthors = async () => { + const activity = db + .select({ + authorId: sql`unnest(${core_search_index.authorIds})`.as( + "author_id", + ), + }) + .from(core_search_index) + .where(listed) + .as("activity"); + const top = await db + .select({ authorId: activity.authorId, total: count() }) + .from(activity) + .groupBy(activity.authorId) + .orderBy(desc(count()), asc(activity.authorId)) + .limit(limit); + + return top.map(row => row.authorId); + }; + // The shared cache only holds the list everybody gets; a restricted + // viewer's own list is read fresh. + const topAuthorIds = allowedScopes + ? await loadTopAuthors() + : await this.c + .get("cache") + .remember( + `search:top-authors:${limit}`, + SEARCH_TOP_AUTHORS_TTL_SECONDS, + loadTopAuthors, + ); if (topAuthorIds.length === 0) return []; const rows = await db @@ -740,8 +832,11 @@ export class SearchModel { /** {@link SearchModel.index} for many documents at once. */ async bulkIndex(docs: SearchDocument[]): Promise { - const clean = docs.map(doc => - this.resolveOwner({ ...doc, content: stripHtml(doc.content) }), + const clean = await withContentSearchAccessScopes( + this.c, + docs.map(doc => + this.resolveOwner({ ...doc, content: stripHtml(doc.content) }), + ), ); await this.upsertRows(clean); @@ -764,7 +859,10 @@ export class SearchModel { const provider = this.provider(); if (!provider.countByType) return null; - return await provider.countByType(this.c, this.withFallbacks(params)); + return await provider.countByType( + this.c, + this.withFallbacks(await this.withAccess(params)), + ); } /** @@ -882,10 +980,12 @@ export class SearchModel { * `core_search_index`; the provider mirrors it. */ async index(doc: SearchDocument): Promise { - const clean = this.resolveOwner({ - ...doc, - content: stripHtml(doc.content), - }); + const [clean] = await withContentSearchAccessScopes(this.c, [ + this.resolveOwner({ + ...doc, + content: stripHtml(doc.content), + }), + ]); const commentParent = await this.newCommentParent(clean); @@ -945,12 +1045,16 @@ export class SearchModel { languageCode?: string; limit: number; }): Promise { - return await this.withSuggestionImages( - await canonicalPopularSearchItems(this.c, { - limit, - params: this.withFallbacks({ includePrivate: false, languageCode }), - }), - ); + const params = await this.withAccess({ + includePrivate: false, + languageCode, + }); + const items = await canonicalPopularSearchItems(this.c, { + limit, + params: this.withFallbacks(params), + }); + + return await this.withSuggestionImages(await this.verified(items)); } async recordPopularity({ @@ -965,24 +1069,117 @@ export class SearchModel { await recordSearchPopularity(this.c, { itemId, itemType, signal }); } + /** + * Re-stamps the access scopes of indexed items from the content tables, in + * the canonical table and the provider - after a search found an item whose + * stored scopes no longer match, or from the reconciliation job. + */ + async refreshAccessScopes( + items: readonly { itemId: ContentId; itemType: string }[], + ): Promise { + if (items.length === 0) return 0; + + const db = this.c.get("db"); + const byType = new Map(); + for (const item of items) { + byType.set(item.itemType, [ + ...(byType.get(item.itemType) ?? []), + contentIdKey(item.itemId), + ]); + } + + let changed = 0; + for (const [itemType, keys] of byType) { + const rows = await db + .select() + .from(core_search_index) + .where( + and( + eq(core_search_index.itemType, itemType), + inArray(core_search_index.itemId, [...new Set(keys)]), + ), + ); + if (rows.length === 0) continue; + + const contentTypes = this.c.get("core")?.contentTypes ?? []; + const docs: SearchDocument[] = rows.map(row => ({ + accessScopes: row.accessScopes, + authorIds: row.authorIds, + containerId: row.containerId ?? undefined, + containerType: row.containerType ?? undefined, + content: row.content, + createdAt: row.createdAt, + imageId: row.imageId, + isHidden: row.isHidden, + isPublic: row.isPublic, + itemId: searchItemIdFromKey(contentTypes, row.itemType, row.itemId), + itemType: row.itemType, + languageCode: row.languageCode, + metadata: row.metadata, + pluginId: row.pluginId, + title: row.title, + updatedAt: row.updatedAt ?? undefined, + url: row.url ?? undefined, + })); + const stamped = await withContentSearchAccessScopes(this.c, docs); + const stale = stamped.filter( + (doc, index) => + JSON.stringify([...(doc.accessScopes ?? [])].sort()) !== + JSON.stringify([...(rows[index]?.accessScopes ?? [])].sort()), + ); + if (stale.length === 0) continue; + + changed += stale.length; + await this.upsertRows(stale); + await this.provider().bulkIndex(this.c, stale); + } + + return changed; + } + /** Runs a query on the search adapter and attaches each hit's authors and image. */ async search(params: SearchQueryParams): Promise { + const scoped = await this.withAccess(params); const result = await this.provider().search( this.c, - this.withFallbacks(params), + this.withFallbacks(scoped), ); + // The index filtered by the scopes it remembers; the content tables decide. + // A hit that fails here was indexed before its record moved or a rule + // changed: it is never returned, its scopes are stamped again, and the + // total no longer counts it. Counts are therefore an upper bound only + // while such stale entries exist - they never include a hit this viewer + // was shown. + const { dropped, kept } = params.skipAccess + ? { dropped: [], kept: result.edges } + : await verifyContentSearchHits(this.c, result.edges); + if (dropped.length > 0) { + await this.refreshAccessScopes(dropped).catch(() => 0); + } + const pageInfo = + dropped.length > 0 + ? { + ...result.pageInfo, + count: kept.length, + totalCount: Math.max( + kept.length, + result.pageInfo.totalCount - dropped.length, + ), + } + : result.pageInfo; + const [withAuthors, withImages] = await Promise.all([ - this.hydrateAuthors(result.edges), - this.hydrateImages(result.edges), + this.hydrateAuthors(kept), + this.hydrateImages(kept), ]); return { - ...result, edges: withAuthors.map((edge, index) => ({ ...edge, image: withImages[index]?.image ?? null, })), + pageInfo, }; } @@ -994,12 +1191,12 @@ export class SearchModel { limit: number; term: string; }): Promise { - return await this.withSuggestionImages( - await canonicalSearchSuggestions(this.c, { - limit, - params: this.withFallbacks(params), - term, - }), - ); + const items = await canonicalSearchSuggestions(this.c, { + limit, + params: this.withFallbacks(await this.withAccess(params)), + term, + }); + + return await this.withSuggestionImages(await this.verified(items)); } } diff --git a/packages/vitnode/src/api/models/storage.ts b/packages/vitnode/src/api/models/storage.ts index c7d1467208..ea9ce6472f 100644 --- a/packages/vitnode/src/api/models/storage.ts +++ b/packages/vitnode/src/api/models/storage.ts @@ -105,6 +105,31 @@ export interface StorageStaticConfig { stripPrefix: string; } +/** One private object, as {@link StoragePrivateStore.read} streams it. */ +export interface StoragePrivateObject { + body: ReadableStream; + /** Bytes, when the store knows them up front. */ + size?: number; +} + +/** + * A place for objects no public URL reaches: a separate private bucket, or a + * folder outside every static mount. VitNode serves them only through routes + * that check who is asking - the protected attachments of content in a scope + * with access rules. + * + * An adapter that cannot guarantee privacy (a bucket whose policy makes every + * object public) must leave this out; attachment downloads then cannot be + * restricted, and the AdminCP says so. + */ +export interface StoragePrivateStore { + delete: (key: string) => Promise; + /** Streams one object, or `null` when it is missing. */ + read: (key: string) => Promise; + /** Writes one object under `args.key`. It has no URL. */ + upload: (args: StorageUploadArgs) => Promise<{ key: string }>; +} + /** * What a storage adapter implements (e.g. `@vitnode/s3`, * `@vitnode/supabase-storage`); set it as the API config's `storage.adapter`. @@ -114,6 +139,11 @@ export interface StorageApiPlugin { delete: (key: string) => Promise; /** The public URL of a stored object. */ getUrl: (key: string) => string; + /** + * Private objects, for protected attachments. Optional: without it every + * file has a public URL and downloads cannot be restricted. + */ + private?: StoragePrivateStore; /** * Reads an object's bytes, or `null` when missing. Optional: without it, * reads fetch `getUrl`. @@ -142,6 +172,12 @@ export interface StorageUploadOptions { /** The owner recorded on the file. Defaults to the signed-in admin or user; `null` for none. */ userId?: null | number; + /** + * `private` stores the object where no public URL reaches it - only with an + * adapter that has a `private` store; it is refused otherwise rather than + * stored publicly. Defaults to `public`. + */ + visibility?: "private" | "public"; } interface ProcessedImage { @@ -484,12 +520,12 @@ export class StorageModel { .where(eq(core_content_file_refs.fileId, id)) .returning({ id: core_content_file_refs.id }); - let row: undefined | { key: string }; + let row: undefined | { key: string; visibility: "private" | "public" }; try { - [row] = await tx - .delete(core_files) - .where(where) - .returning({ key: core_files.key }); + [row] = await tx.delete(core_files).where(where).returning({ + key: core_files.key, + visibility: core_files.visibility, + }); } catch (error) { if (!isPgReferenceViolation(error)) throw error; @@ -514,18 +550,108 @@ export class StorageModel { // nobody can remove. const provider = this.c.get("core").storage?.adapter; if (provider) { - await provider.delete(deleted.row.key).catch(() => undefined); + await ( + deleted.row.visibility === "private" + ? provider.private?.delete(deleted.row.key) + : provider.delete(deleted.row.key) + )?.catch(() => undefined); } await releaseUserImageHolders(this.c, deleted.holders); await releaseEditorFileReferences(this.c, id); } - /** The public URL of a stored object. */ + /** The public URL of a stored object. Never call it for a private file. */ getUrl(key: string): string { return this.requireProvider().getUrl(key); } + /** + * Moves one public file into the private store under the same key and + * deletes the public object, so the URL that was handed out stops working. + * Never the other way round: nothing here can publish a private file. + * + * @throws `HTTPException(409)` without a private store, `404` when the file or + * its bytes are missing. + */ + async makeFilePrivate(id: number): Promise<"moved" | "unchanged"> { + const provider = this.requireProvider(); + const store = provider.private; + if (!store) { + throw new HTTPException(409, { + message: "The storage adapter has no private store.", + }); + } + + const db = this.c.get("db"); + const [row] = await db + .select({ + key: core_files.key, + mimeType: core_files.mimeType, + size: core_files.size, + visibility: core_files.visibility, + }) + .from(core_files) + .where(eq(core_files.id, id)) + .limit(1); + if (!row) throw new HTTPException(404, { message: "File not found" }); + if (row.visibility === "private") return "unchanged"; + + const bytes = await this.readBytes( + row.key, + Math.max(row.size, 1) * 2 + 1024, + ); + if (!bytes) { + throw new HTTPException(404, { + message: "The stored object is missing.", + }); + } + + await store.upload({ + body: bytes, + contentType: row.mimeType ?? undefined, + key: row.key, + }); + await db + .update(core_files) + .set({ visibility: "private" }) + .where(eq(core_files.id, id)); + // Last: if this fails the file is private anyway and the public copy is + // an orphan a retry removes. + await provider.delete(row.key); + + return "moved"; + } + + /** + * Streams one stored file, public or private, for a route that already + * decided the visitor may have it. `null` when the object is missing. + */ + async openFile({ + key, + visibility, + }: { + key: string; + visibility: "private" | "public"; + }): Promise { + const provider = this.requireProvider(); + if (visibility === "private") { + return (await provider.private?.read(key)) ?? null; + } + + const response = await fetch(provider.getUrl(key)).catch(() => null); + if (!response?.ok || !response.body) return null; + const length = response.headers.get("content-length"); + const declared = length === null ? Number.NaN : Number(length); + + return { + body: response.body, + ...(Number.isInteger(declared) && declared >= 0 + ? { size: declared } + : {}), + }; + } + /** An object's bytes, or `null` when it is missing, unreadable or larger than `maxBytes`. */ async readBytes(key: string, maxBytes: number): Promise { const provider = this.requireProvider(); @@ -562,6 +688,11 @@ export class StorageModel { return Buffer.concat(chunks); } + /** Whether the adapter can keep files private (protected attachments). */ + supportsPrivateFiles(): boolean { + return this.c.get("core").storage?.adapter?.private !== undefined; + } + /** * Validates, stores and records one uploaded file, owned by the current * plugin and user. @@ -583,8 +714,16 @@ export class StorageModel { maxBytes, metadata, userId, + visibility = "public", }: StorageUploadOptions): Promise { const provider = this.requireProvider(); + const privateStore = visibility === "private" ? provider.private : null; + if (visibility === "private" && !privateStore) { + throw new HTTPException(409, { + message: + "This file has to be stored privately, and the storage adapter has no private store.", + }); + } if (maxBytes !== undefined && file.size > maxBytes) { throw new HTTPException(400, { @@ -616,11 +755,22 @@ export class StorageModel { ), }); - const result = await provider.upload({ + const stored = { key, body: processed.body, contentType: processed.mimeType || undefined, - }); + }; + // A private object has no URL: whoever serves it builds the route to it. + const result: StorageUploadResult = privateStore + ? { key: (await privateStore.upload(stored)).key, url: "" } + : await provider.upload(stored); + const removeStored = async () => { + await ( + privateStore + ? privateStore.delete(result.key) + : provider.delete(result.key) + ).catch(() => undefined); + }; const ownerId = userId !== undefined @@ -645,6 +795,7 @@ export class StorageModel { mimeType, size, userId: ownerId, + visibility, pluginId: this.c.get("plugin")?.id ?? null, fingerprint: createHash("sha256") .update(processed.body) @@ -661,12 +812,12 @@ export class StorageModel { }) .returning({ id: core_files.id }); } catch (error) { - await provider.delete(result.key).catch(() => undefined); + await removeStored(); throw error; } if (!created) { - await provider.delete(result.key).catch(() => undefined); + await removeStored(); throw new HTTPException(500, { message: "The file was stored but could not be recorded in the database, so it cannot be referenced. Nothing was kept - try again.", diff --git a/packages/vitnode/src/api/modules/admin/files/routes/download.route.ts b/packages/vitnode/src/api/modules/admin/files/routes/download.route.ts index b05dd3c2d2..ad1f4b4c79 100644 --- a/packages/vitnode/src/api/modules/admin/files/routes/download.route.ts +++ b/packages/vitnode/src/api/modules/admin/files/routes/download.route.ts @@ -16,6 +16,10 @@ export const downloadFileAdminRoute = buildRoute({ params: z.object({ id: z.string().openapi({ example: "1" }), }), + query: z.object({ + /** `true` shows an image in the page instead of downloading it. */ + inline: z.enum(["true", "false"]).optional(), + }), }, responses: { 200: { @@ -49,6 +53,7 @@ export const downloadFileAdminRoute = buildRoute({ name: core_files.name, key: core_files.key, mimeType: core_files.mimeType, + visibility: core_files.visibility, }) .from(core_files) .where(eq(core_files.id, fileId)) @@ -59,8 +64,9 @@ export const downloadFileAdminRoute = buildRoute({ } return ( - (await attachStoredFile(c, file)) ?? - c.json({ error: "File not found" }, 404) + (await attachStoredFile(c, file, { + inline: c.req.valid("query").inline === "true", + })) ?? c.json({ error: "File not found" }, 404) ); }, }); diff --git a/packages/vitnode/src/api/modules/admin/files/routes/editor-upload.route.ts b/packages/vitnode/src/api/modules/admin/files/routes/editor-upload.route.ts index 4fd01b24d7..0ab1081af5 100644 --- a/packages/vitnode/src/api/modules/admin/files/routes/editor-upload.route.ts +++ b/packages/vitnode/src/api/modules/admin/files/routes/editor-upload.route.ts @@ -21,8 +21,10 @@ export const uploadEditorFileAdminRoute = buildRoute({ const admin = c.get("admin"); if (!admin) throw new HTTPException(403); - const { file } = c.req.valid("form"); + const { contentType, file } = c.req.valid("form"); const outcome = await storeEditorUpload(c, { + contentType, + session: "admin", attachments: true, file, userId: admin.user.id, diff --git a/packages/vitnode/src/api/modules/admin/files/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/files/routes/list.route.ts index 7842867228..2faef2821d 100644 --- a/packages/vitnode/src/api/modules/admin/files/routes/list.route.ts +++ b/packages/vitnode/src/api/modules/admin/files/routes/list.route.ts @@ -8,6 +8,7 @@ import { zodPaginationPageInfo, zodPaginationQuery, } from "@/api/lib/with-pagination"; +import { contentPrivateAdminFileUrl } from "@/content/access/server/files"; import { core_files } from "@/database/files"; import { core_roles } from "@/database/roles"; import { core_users } from "@/database/users"; @@ -97,6 +98,7 @@ export const listFilesAdminRoute = buildRoute({ id: core_files.id, name: core_files.name, key: core_files.key, + visibility: core_files.visibility, folder: core_files.folder, mimeType: core_files.mimeType, size: core_files.size, @@ -137,6 +139,7 @@ export const listFilesAdminRoute = buildRoute({ edges: data.edges.map( ({ key, + visibility, userId, userName, userNameCode, @@ -146,7 +149,13 @@ export const listFilesAdminRoute = buildRoute({ ...file }) => ({ ...file, - url: hasAdapter ? storage.getUrl(key) : null, + // A private file has no public address: the list shows it through the + // session's own download route. + url: hasAdapter + ? visibility === "private" + ? contentPrivateAdminFileUrl(file.id) + : storage.getUrl(key) + : null, dimensions: parseImageDimensions(file.metadata), user: userId != null && userName != null diff --git a/packages/vitnode/src/api/modules/admin/users/routes/timeline.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/timeline.route.ts index fe35b94a50..cf84823c7f 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/timeline.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/timeline.route.ts @@ -34,6 +34,9 @@ export const timelineUserAdminRoute = buildRoute({ const result = await searchTimeline(c, { authorId: Number(c.req.valid("param").id), includePrivate: true, + // A staff route behind `users:can_view`: the AdminCP sees every item, + // whatever the scopes it is filed in allow on the public site. + skipAccess: true, query: c.req.valid("query"), }); diff --git a/packages/vitnode/src/api/modules/content/content.module.ts b/packages/vitnode/src/api/modules/content/content.module.ts index 7106959061..d351e2bbc6 100644 --- a/packages/vitnode/src/api/modules/content/content.module.ts +++ b/packages/vitnode/src/api/modules/content/content.module.ts @@ -3,6 +3,7 @@ import { contentLiveWebSocket } from "@/content/server/live/socket"; import { contentRefreshWebSocket } from "@/content/server/refresh/socket"; import { contentEditorialCleanupCron } from "./cron/content-editorial-cleanup.cron"; +import { contentSearchAccessCron } from "./cron/content-search-access.cron"; import { contentNotificationsQueueTask } from "./tasks/content-notifications.task"; import { contentScheduleEffectsQueueTask } from "./tasks/content-schedule-effects.task"; import { contentScheduleQueueTask } from "./tasks/content-schedule.task"; @@ -11,7 +12,7 @@ import { contentSearchDependentsQueueTask } from "./tasks/content-search-depende export const contentModule = buildModule({ name: "content", routes: [], - cronJobs: [contentEditorialCleanupCron], + cronJobs: [contentEditorialCleanupCron, contentSearchAccessCron], queueTasks: [ contentScheduleQueueTask, contentScheduleEffectsQueueTask, diff --git a/packages/vitnode/src/api/modules/content/cron/content-search-access.cron.ts b/packages/vitnode/src/api/modules/content/cron/content-search-access.cron.ts new file mode 100644 index 0000000000..7e32b1a686 --- /dev/null +++ b/packages/vitnode/src/api/modules/content/cron/content-search-access.cron.ts @@ -0,0 +1,87 @@ +import { and, asc, gt, inArray } from "drizzle-orm"; + +import type { AnyContentModel } from "@/content/server/model"; + +import { buildCron } from "@/api/lib/cron"; +import { searchItemIdFromKey } from "@/api/models/search"; +import { contentAccessSourceOf } from "@/content/access/server/source"; +import { core_search_index } from "@/database/search"; + +const BATCH = 500; +const MAX_BATCHES = 20; +const CURSOR_KEY = "content:search-access:cursor"; + +/** + * Walks the search index a slice at a time and re-stamps the access scopes of + * every document whose record moved since it was indexed - a write whose + * search sync failed, a dependent the live path did not reach. Search results + * are checked against the content tables on every query, so this never + * decides who sees what; it keeps the index's own filter, and its counts, + * close to the truth. + */ +export const contentSearchAccessCron = buildCron({ + name: "content-search-access", + description: + "Re-check the access scopes stored in the search index against the content they belong to, and correct the ones that drifted.", + // Hourly, off the hour. + schedule: "41 * * * *", + handler: async c => { + const models = ( + (c.get("core")?.contentModels ?? []) as { model: AnyContentModel }[] + ) + .map(entry => entry.model) + .filter(model => contentAccessSourceOf(c, model)); + if (models.length === 0) return; + + const itemTypes = models.map(model => model.definition.id); + const cache = c.get("cache"); + let cursor = Number((await cache.getSystem(CURSOR_KEY)) ?? 0) || 0; + let corrected = 0; + + for (let batch = 0; batch < MAX_BATCHES; batch++) { + const rows = await c + .get("db") + .select({ + id: core_search_index.id, + itemId: core_search_index.itemId, + itemType: core_search_index.itemType, + }) + .from(core_search_index) + .where( + and( + inArray(core_search_index.itemType, itemTypes), + gt(core_search_index.id, cursor), + ), + ) + .orderBy(asc(core_search_index.id)) + .limit(BATCH); + + if (rows.length === 0) { + cursor = 0; + break; + } + + const contentTypes = c.get("core")?.contentTypes ?? []; + corrected += await c.get("search").refreshAccessScopes( + rows.map(row => ({ + itemId: searchItemIdFromKey(contentTypes, row.itemType, row.itemId), + itemType: row.itemType, + })), + ); + cursor = rows.at(-1)?.id ?? cursor; + if (rows.length < BATCH) { + cursor = 0; + break; + } + } + + await cache.setSystem(CURSOR_KEY, String(cursor)); + if (corrected > 0) { + await c + .get("log") + .debug( + `[content-search-access] corrected the access scopes of ${corrected} search documents.`, + ); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/search/routes/timeline.route.ts b/packages/vitnode/src/api/modules/search/routes/timeline.route.ts index 1b6af0eae4..044627b163 100644 --- a/packages/vitnode/src/api/modules/search/routes/timeline.route.ts +++ b/packages/vitnode/src/api/modules/search/routes/timeline.route.ts @@ -26,11 +26,14 @@ export const searchTimeline = async ( hiddenAccess, includePrivate, query, + skipAccess, }: { authorId: number; hiddenAccess?: SearchHiddenAccess; includePrivate: boolean; query: z.infer; + /** Only for staff routes - see `SearchQueryParams.skipAccess`. */ + skipAccess?: boolean; }, ) => await c.get("search").search({ @@ -40,6 +43,7 @@ export const searchTimeline = async ( hiddenAccess, includePrivate, languageCode: query.lang, + skipAccess, sort: "newest", }); diff --git a/packages/vitnode/src/api/modules/users/files/routes/download.route.ts b/packages/vitnode/src/api/modules/users/files/routes/download.route.ts index c832a6fa7a..2527726b65 100644 --- a/packages/vitnode/src/api/modules/users/files/routes/download.route.ts +++ b/packages/vitnode/src/api/modules/users/files/routes/download.route.ts @@ -16,6 +16,10 @@ export const downloadUserFileRoute = buildRoute({ params: z.object({ id: z.string().openapi({ example: "1" }), }), + query: z.object({ + /** `true` shows an image in the page instead of downloading it. */ + inline: z.enum(["true", "false"]).optional(), + }), }, responses: { 200: { @@ -57,6 +61,7 @@ export const downloadUserFileRoute = buildRoute({ name: core_files.name, key: core_files.key, mimeType: core_files.mimeType, + visibility: core_files.visibility, }) .from(core_files) .where(and(eq(core_files.id, fileId), eq(core_files.userId, user.id))) @@ -67,8 +72,9 @@ export const downloadUserFileRoute = buildRoute({ } return ( - (await attachStoredFile(c, file)) ?? - c.json({ error: "File not found" }, 404) + (await attachStoredFile(c, file, { + inline: c.req.valid("query").inline === "true", + })) ?? c.json({ error: "File not found" }, 404) ); }, }); diff --git a/packages/vitnode/src/api/modules/users/files/routes/editor-upload.route.ts b/packages/vitnode/src/api/modules/users/files/routes/editor-upload.route.ts index 0bd3bdbd31..2db174ff69 100644 --- a/packages/vitnode/src/api/modules/users/files/routes/editor-upload.route.ts +++ b/packages/vitnode/src/api/modules/users/files/routes/editor-upload.route.ts @@ -25,8 +25,10 @@ export const uploadEditorFileRoute = buildRoute({ throw new HTTPException(401, { message: "Unauthorized" }); } - const { file } = c.req.valid("form"); + const { contentType, file } = c.req.valid("form"); const outcome = await storeEditorUpload(c, { + contentType, + session: "member", attachments: false, file, userId: user.id, diff --git a/packages/vitnode/src/api/modules/users/files/routes/list.route.ts b/packages/vitnode/src/api/modules/users/files/routes/list.route.ts index eff90524bc..6470220d92 100644 --- a/packages/vitnode/src/api/modules/users/files/routes/list.route.ts +++ b/packages/vitnode/src/api/modules/users/files/routes/list.route.ts @@ -11,6 +11,7 @@ import { } from "@/api/lib/with-pagination"; import { core_files } from "@/database/files"; import { parseImageDimensions } from "@/lib/api/upload"; +import { CONFIG } from "@/lib/config"; export const listUserFilesRoute = buildRoute({ route: { @@ -82,6 +83,7 @@ export const listUserFilesRoute = buildRoute({ id: core_files.id, name: core_files.name, key: core_files.key, + visibility: core_files.visibility, folder: core_files.folder, mimeType: core_files.mimeType, size: core_files.size, @@ -104,9 +106,15 @@ export const listUserFilesRoute = buildRoute({ return c.json({ ...data, - edges: data.edges.map(({ key, ...file }) => ({ + edges: data.edges.map(({ key, visibility, ...file }) => ({ ...file, - url: hasAdapter ? storage.getUrl(key) : null, + // A private file has no public address: the list shows it through the + // session's own download route. + url: hasAdapter + ? visibility === "private" + ? `${CONFIG.api.origin.replace(/\/$/, "")}/api/@vitnode/core/users/files/${file.id}/download?inline=true` + : storage.getUrl(key) + : null, dimensions: parseImageDimensions(file.metadata), })), }); diff --git a/packages/vitnode/src/components/rich-text/deleted-upload.tsx b/packages/vitnode/src/components/rich-text/deleted-upload.tsx index bac02359ca..2b04f4662d 100644 --- a/packages/vitnode/src/components/rich-text/deleted-upload.tsx +++ b/packages/vitnode/src/components/rich-text/deleted-upload.tsx @@ -1,4 +1,4 @@ -import { FileXIcon, ImageOffIcon } from "lucide-react"; +import { FileLockIcon, FileXIcon, ImageOffIcon } from "lucide-react"; import { useTranslations } from "use-intl"; /** Whether a rich-text node is an image or attachment whose file was deleted. */ @@ -43,3 +43,35 @@ export const DeletedUpload = ({ ); }; + +/** + * Whether a rich-text node is an attachment the reader may not download. The + * server strips its name, size and link before the document leaves the API. + */ +export const isRestrictedUploadNode = ({ + attrs, + type, +}: { + attrs?: Record; + type: string; +}): boolean => type === "attachment" && attrs?.restricted === true; + +/** + * Shown instead of an attachment the reader may not download: that one is + * there, and nothing about it - no name, no size, no link. + */ +export const RestrictedUpload = () => { + const t = useTranslations("core.global.editor.uploads"); + + return ( + + + + {t("restricted_title")} + {t("restricted_desc")} + + + ); +}; diff --git a/packages/vitnode/src/components/rich-text/rich-text-content.tsx b/packages/vitnode/src/components/rich-text/rich-text-content.tsx index b805c776d0..d1cd7d02b1 100644 --- a/packages/vitnode/src/components/rich-text/rich-text-content.tsx +++ b/packages/vitnode/src/components/rich-text/rich-text-content.tsx @@ -16,7 +16,12 @@ import { } from "@/content/rich-text/elements"; import { richTextToHtml } from "@/content/rich-text/html"; -import { DeletedUpload, isDeletedUploadNode } from "./deleted-upload"; +import { + DeletedUpload, + isDeletedUploadNode, + isRestrictedUploadNode, + RestrictedUpload, +} from "./deleted-upload"; export interface RichTextNodeRendererProps { children?: React.ReactNode; @@ -166,6 +171,8 @@ const renderNode = ( return ; } + if (isRestrictedUploadNode(node)) return ; + const element = richTextNodeElement(node); return element ? ( diff --git a/packages/vitnode/src/components/tiptap/tiptap-editor.tsx b/packages/vitnode/src/components/tiptap/tiptap-editor.tsx index 4c5724b686..c5fa9b6ef4 100644 --- a/packages/vitnode/src/components/tiptap/tiptap-editor.tsx +++ b/packages/vitnode/src/components/tiptap/tiptap-editor.tsx @@ -42,7 +42,7 @@ import { uploadEditorFile } from "./uploads/upload"; * AdminCP session, needs the staff permission `files:can_upload`, and can * also accept `files`. */ -export type EditorUploadsOptions = +export type EditorUploadsOptions = ( | { /** * Also accepts PDF, Word, PowerPoint and ZIP files as downloadable @@ -51,7 +51,15 @@ export type EditorUploadsOptions = files?: boolean; session: "admin"; } - | { session?: "member" }; + | { session?: "member" } +) & { + /** + * The content type the document belongs to, e.g. `blog.post`. One with + * access rules has its uploads stored privately and served only to readers + * its rules let in. Leave it out for documents that are not content. + */ + contentType?: string; +}; export type TipTapEditorBaseProps = Omit< React.ComponentProps<"div">, @@ -144,6 +152,7 @@ export const TipTapEditor = (props: TipTapEditorProps) => { const uploadOptions: EditorUploadsOptions = typeof uploads === "object" ? uploads : {}; const uploadSession = uploads ? (uploadOptions.session ?? "member") : null; + const uploadContentType = uploadOptions.contentType; const allowsAttachments = uploadOptions.session === "admin" && uploadOptions.files === true; const allowsRemoteImages = images?.remote === true; @@ -172,9 +181,18 @@ export const TipTapEditor = (props: TipTapEditorProps) => { }); }, remote: allowsRemoteImages, - upload: async file => await uploadEditorFile(uploadSession, file), + upload: async file => + await uploadEditorFile(uploadSession, file, { + contentType: uploadContentType, + }), }, - [allowsAttachments, allowsRemoteImages, t, uploadSession], + [ + allowsAttachments, + allowsRemoteImages, + t, + uploadContentType, + uploadSession, + ], ); // Stable options keep `useEditor` from calling `setOptions` on every render. diff --git a/packages/vitnode/src/components/tiptap/uploads/upload.ts b/packages/vitnode/src/components/tiptap/uploads/upload.ts index 9304f1e510..84a53a3ced 100644 --- a/packages/vitnode/src/components/tiptap/uploads/upload.ts +++ b/packages/vitnode/src/components/tiptap/uploads/upload.ts @@ -29,9 +29,11 @@ const requestUpload = async ( export const uploadEditorFile = async ( session: "admin" | "member", file: File, + { contentType }: { contentType?: string } = {}, ): Promise => { const formData = new FormData(); formData.append("file", file); + if (contentType) formData.append("contentType", contentType); const response = await requestUpload(session, formData); if (!response.ok) { diff --git a/packages/vitnode/src/content/access/const.ts b/packages/vitnode/src/content/access/const.ts new file mode 100644 index 0000000000..b9eb2036df --- /dev/null +++ b/packages/vitnode/src/content/access/const.ts @@ -0,0 +1,42 @@ +/** + * What an access policy decides, in prerequisite order: each action needs the + * one before it (`read` needs `view`, `comment` and `downloadAttachments` need + * `read`). + */ +export const CONTENT_ACCESS_ACTIONS = [ + "view", + "read", + "comment", + "downloadAttachments", +] as const; + +export const CONTENT_ACCESS_MODES = ["everyone", "members", "roles"] as const; + +/** The action each action needs first, or `null` for `view`. */ +export const CONTENT_ACCESS_PREREQUISITES = { + view: null, + read: "view", + comment: "read", + downloadAttachments: "read", +} as const; + +/** The most roles one audience may list. */ +export const CONTENT_ACCESS_MAX_ROLES = 100; + +/** The most scopes one effective-access check takes. */ +export const CONTENT_ACCESS_MAX_EFFECTIVE_SCOPES = 50; + +/** The JSON error codes the access admin routes answer with. */ +export const CONTENT_ACCESS_ERROR_CODES = [ + "invalidAccess", + "notFound", + "unknownRole", + "unsupportedStorage", +] as const; + +/** + * The `code` of the 403 body a public detail route answers when a record + * exists in a scope the viewer may see but not read. The body is the limited + * `{ accessRequired, code }` shape, never the record. + */ +export const CONTENT_ACCESS_REQUIRED_CODE = "CONTENT_ACCESS_REQUIRED"; diff --git a/packages/vitnode/src/content/access/policy.test.ts b/packages/vitnode/src/content/access/policy.test.ts new file mode 100644 index 0000000000..598cb7fa8a --- /dev/null +++ b/packages/vitnode/src/content/access/policy.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; + +import type { + ContentAccessActor, + ContentAccessAudience, + ContentAccessDefaultsInput, + ContentAccessScopeLayer, +} from "./policy"; + +import { + CONTENT_ACCESS_DENY_ALL, + contentAccessRequirements, + decideContentAccess, + readContentAccessOverrides, + resolveContentAccessRules, + resolveContentAccessScopeInput, +} from "./policy"; + +const GUEST: ContentAccessActor = { kind: "guest" }; +const member = (roleIds: number[], root = false): ContentAccessActor => ({ + kind: "member", + roleIds, + root, + userId: 1, +}); + +const MEMBERS: ContentAccessAudience = { mode: "members" }; +const ROLES_4_5: ContentAccessAudience = { mode: "roles", roleIds: [4, 5] }; + +const allows = ( + actor: ContentAccessActor, + chain: ContentAccessScopeLayer[], + action: Parameters[0]["action"], + defaults: ContentAccessDefaultsInput = {}, +) => + decideContentAccess( + actor, + contentAccessRequirements({ action, chain, defaults, family: "f" }), + ).allowed; + +describe("audiences", () => { + it.each<[string, ContentAccessAudience, ContentAccessActor, boolean]>([ + ["everyone lets a guest in", { mode: "everyone" }, GUEST, true], + ["members keeps a guest out", MEMBERS, GUEST, false], + ["members lets any member in", MEMBERS, member([2]), true], + ["roles keeps a guest out", ROLES_4_5, GUEST, false], + [ + "roles keeps a member without the roles out", + ROLES_4_5, + member([2]), + false, + ], + // Primary and secondary roles are one list: any one of them is enough. + [ + "roles lets in a member with any listed role", + ROLES_4_5, + member([2, 5]), + true, + ], + [ + "roles with no role left lets nobody in", + CONTENT_ACCESS_DENY_ALL, + member([4]), + false, + ], + ])("%s", (_label, audience, actor, expected) => { + expect( + allows(actor, [{ id: "1", overrides: { view: audience } }], "view"), + ).toBe(expected); + }); + + it("lets a root role past every rule, deny-all included", () => { + const chain = [ + { + id: "1", + overrides: { + downloadAttachments: CONTENT_ACCESS_DENY_ALL, + view: CONTENT_ACCESS_DENY_ALL, + }, + }, + ]; + + expect( + decideContentAccess( + member([], true), + contentAccessRequirements({ + action: "downloadAttachments", + chain, + defaults: {}, + family: "f", + }), + ), + ).toEqual({ allowed: true, reason: "root" }); + }); +}); + +describe("prerequisites", () => { + // Every action open except the one named: the action and everything that + // needs it are closed to a guest, the rest stay open. + it.each([ + [ + "view", + { comment: false, downloadAttachments: false, read: false, view: false }, + ], + [ + "read", + { comment: false, downloadAttachments: false, read: false, view: true }, + ], + [ + "comment", + { comment: false, downloadAttachments: true, read: true, view: true }, + ], + [ + "downloadAttachments", + { comment: true, downloadAttachments: false, read: true, view: true }, + ], + ] as const)( + "closing %s to guests closes what needs it", + (closed, expected) => { + const chain = [{ id: "1", overrides: { [closed]: MEMBERS } }]; + + expect({ + comment: allows(GUEST, chain, "comment"), + downloadAttachments: allows(GUEST, chain, "downloadAttachments"), + read: allows(GUEST, chain, "read"), + view: allows(GUEST, chain, "view"), + }).toEqual(expected); + }, + ); + + it("keeps out a role allowed to comment but not to read", () => { + const chain = [ + { + id: "1", + overrides: { + comment: ROLES_4_5, + read: { mode: "roles", roleIds: [9] }, + }, + }, + ] satisfies ContentAccessScopeLayer[]; + + expect(allows(member([4]), chain, "comment")).toBe(false); + }); +}); + +describe("readContentAccessOverrides", () => { + it("fails closed on a stored rule that no longer parses", () => { + expect( + readContentAccessOverrides({ + comment: { mode: "vip" }, + read: { mode: "roles" }, + view: { mode: "members" }, + }), + ).toEqual({ + comment: CONTENT_ACCESS_DENY_ALL, + read: CONTENT_ACCESS_DENY_ALL, + view: { mode: "members" }, + }); + }); + + it.each([null, "members", ["view"], 7])( + "reads %j as no rules at all", + value => { + expect(readContentAccessOverrides(value)).toEqual({}); + }, + ); + + it("leaves an action without a rule to inherit", () => { + expect( + readContentAccessOverrides({ read: null, view: { mode: "everyone" } }), + ).toEqual({ view: { mode: "everyone" } }); + }); +}); + +describe("resolveContentAccessRules", () => { + it("takes each action from the scope, the nearest ancestor, the defaults, then everyone", () => { + const { policy, sources } = resolveContentAccessRules({ + chain: [ + { id: "child", overrides: { view: ROLES_4_5 } }, + { id: "parent", overrides: { read: MEMBERS, view: MEMBERS } }, + { id: "root", overrides: { read: CONTENT_ACCESS_DENY_ALL } }, + ], + defaults: { comment: { mode: "members" } }, + }); + + expect(policy).toEqual({ + comment: MEMBERS, + downloadAttachments: { mode: "everyone" }, + read: MEMBERS, + view: ROLES_4_5, + }); + expect(sources).toEqual({ + comment: { kind: "default" }, + downloadAttachments: { kind: "fallback" }, + read: { kind: "scope", scopeId: "parent" }, + view: { kind: "scope", scopeId: "child" }, + }); + }); +}); + +describe("resolveContentAccessScopeInput", () => { + it.each([ + [undefined, { defaults: {}, enabled: false }], + [false, { defaults: {}, enabled: false }], + [true, { defaults: {}, enabled: true }], + [ + { defaults: { comment: { mode: "members" } } }, + { defaults: { comment: { mode: "members" } }, enabled: true }, + ], + ] as const)("resolves %j", (input, expected) => { + expect(resolveContentAccessScopeInput(input)).toEqual(expected); + }); + + it("refuses a roles default, which only an installation can name", () => { + expect(() => + resolveContentAccessScopeInput({ + defaults: { + read: { mode: "roles", roleIds: [1] }, + } as unknown as ContentAccessDefaultsInput, + }), + ).toThrow(/roles are chosen in the AdminCP/); + }); +}); diff --git a/packages/vitnode/src/content/access/policy.ts b/packages/vitnode/src/content/access/policy.ts new file mode 100644 index 0000000000..73631cfd7e --- /dev/null +++ b/packages/vitnode/src/content/access/policy.ts @@ -0,0 +1,463 @@ +import { z } from "zod"; + +import type { CONTENT_ACCESS_MODES } from "./const"; + +import { + CONTENT_ACCESS_ACTIONS, + CONTENT_ACCESS_MAX_ROLES, + CONTENT_ACCESS_PREREQUISITES, +} from "./const"; + +export type ContentAccessAction = (typeof CONTENT_ACCESS_ACTIONS)[number]; + +export type ContentAccessMode = (typeof CONTENT_ACCESS_MODES)[number]; + +/** + * Who one access rule lets in: everyone (guests too), signed-in members, or + * members holding at least one of `roleIds` as a primary or secondary role. + * A `roles` audience with no role left in it lets nobody in. + */ +export type ContentAccessAudience = + | { mode: "everyone" } + | { mode: "members" } + | { mode: "roles"; roleIds: number[] }; + +/** One scope's own rules. A missing action inherits. */ +export type ContentAccessOverrides = Partial< + Record +>; + +/** A rule for every action, after inheritance. */ +export type ContentAccessPolicy = Record< + ContentAccessAction, + ContentAccessAudience +>; + +/** + * What a definition may default to. Role ids belong to one installation, so a + * code default is `everyone` or `members`; roles are picked in the AdminCP. + */ +export type ContentAccessDefaultAudience = + | { mode: "everyone" } + | { mode: "members" }; + +export type ContentAccessDefaultsInput = Partial< + Record +>; + +/** The `access` option of a container collection or a scope content type. */ +export interface ContentAccessScopeInput { + /** + * What every scope starts with before an administrator overrides it. + * Omitted actions are open to everyone, so turning access on changes + * nothing until somebody restricts a scope. + */ + defaults?: ContentAccessDefaultsInput; +} + +export interface ResolvedContentAccessScope { + defaults: ContentAccessDefaultsInput; + enabled: boolean; +} + +/** Who is asking, resolved on the server from the session and the database. */ +export type ContentAccessActor = + | { kind: "guest" } + | { + kind: "member"; + /** Primary and secondary roles, read live for this request. */ + roleIds: readonly number[]; + /** Holds a root role: passes every access rule. */ + root: boolean; + userId: number; + }; + +/** Where an inherited rule came from. */ +export type ContentAccessRuleSource = + | { kind: "default" } + | { kind: "fallback" } + | { kind: "scope"; scopeId: string }; + +/** One scope of a chain: the scope, then its ancestors, nearest first. */ +export interface ContentAccessScopeLayer { + id: string; + overrides: ContentAccessOverrides; +} + +/** One condition an actor must meet for an action. */ +export interface ContentAccessRequirement { + /** The rule's own action, e.g. `view` when an ancestor hides a scope. */ + action: ContentAccessAction; + audience: ContentAccessAudience; + /** The scope family the rule belongs to. */ + family: string; + /** The scope whose effective rule this is. */ + scopeId: string; + /** Where that scope's rule was inherited from. */ + source: ContentAccessRuleSource; + /** `ancestor` when a parent of the scope sets it. */ + via: "ancestor" | "self"; +} + +export type ContentAccessDecision = + | { allowed: false; requirement: ContentAccessRequirement } + | { allowed: true; reason: "granted" | "root" }; + +export const DISABLED_CONTENT_ACCESS_SCOPE: ResolvedContentAccessScope = { + defaults: {}, + enabled: false, +}; + +const EVERYONE: ContentAccessAudience = { mode: "everyone" }; + +/** What a stored rule that no longer parses becomes: nobody. */ +export const CONTENT_ACCESS_DENY_ALL: ContentAccessAudience = { + mode: "roles", + roleIds: [], +}; + +const zodRoleIds = z + .array(z.number().int().positive()) + .min(1) + .max(CONTENT_ACCESS_MAX_ROLES) + .transform(ids => [...new Set(ids)].sort((a, b) => a - b)); + +/** An audience as an administrator saves it: a `roles` audience names a role. */ +export const zodContentAccessAudience = z.discriminatedUnion("mode", [ + z.strictObject({ mode: z.literal("everyone") }), + z.strictObject({ mode: z.literal("members") }), + z.strictObject({ mode: z.literal("roles"), roleIds: zodRoleIds }), +]); + +/** What a scope saves: one audience per action it overrides. */ +export const zodContentAccessOverrides = z.strictObject( + Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [ + action, + zodContentAccessAudience.optional(), + ]), + ) as Record< + ContentAccessAction, + z.ZodOptional + >, +); + +/** An audience as a response carries it, deny-all included. */ +export const zodContentAccessAudienceRead = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("everyone") }), + z.object({ mode: z.literal("members") }), + z.object({ + mode: z.literal("roles"), + roleIds: z.array(z.number().int()), + }), +]); + +const zodDefaultAudience = z.discriminatedUnion("mode", [ + z.strictObject({ mode: z.literal("everyone") }), + z.strictObject({ mode: z.literal("members") }), +]); + +const zodScopeInput = z.strictObject({ + defaults: z + .strictObject( + Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [ + action, + zodDefaultAudience.optional(), + ]), + ) as Record< + ContentAccessAction, + z.ZodOptional + >, + ) + .optional(), +}); + +const compactOverrides = (value: T): T => + Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined), + ) as T; + +/** + * Validates the `access` option of a definition. `true` turns access on with + * every action open to everyone; `false` or nothing leaves it off. + * + * @throws {Error} The message the caller wraps in its own definition error. + */ +export const resolveContentAccessScopeInput = ( + input: boolean | ContentAccessScopeInput | undefined, +): ResolvedContentAccessScope => { + if (input === undefined || input === false) { + return DISABLED_CONTENT_ACCESS_SCOPE; + } + if (input === true) return { defaults: {}, enabled: true }; + + const parsed = zodScopeInput.safeParse(input); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const path = issue?.path.join(".") ?? ""; + throw new Error( + issue?.code === "unrecognized_keys" + ? `access${path ? `.${path}` : ""}.${issue.keys[0]} is not an option. access accepts \`defaults\` with ${CONTENT_ACCESS_ACTIONS.join(", ")}, each { mode: "everyone" } or { mode: "members" }.` + : `access${path ? `.${path}` : ""} is not valid: ${issue?.message ?? "unknown value"}. A code default is { mode: "everyone" } or { mode: "members" } - roles are chosen in the AdminCP.`, + ); + } + + return { + defaults: compactOverrides(parsed.data.defaults ?? {}), + enabled: true, + }; +}; + +/** + * Reads stored overrides defensively. A rule that no longer parses is not + * dropped - that would widen it to whatever the parent allows - but becomes + * {@link CONTENT_ACCESS_DENY_ALL}, so a corrupt row fails closed. + */ +export const readContentAccessOverrides = ( + value: unknown, +): ContentAccessOverrides => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + + const overrides: ContentAccessOverrides = {}; + for (const action of CONTENT_ACCESS_ACTIONS) { + const entry = (value as Record)[action]; + if (entry === undefined || entry === null) continue; + + const parsed = zodContentAccessAudienceRead.safeParse(entry); + overrides[action] = parsed.success + ? parsed.data.mode === "roles" + ? { mode: "roles", roleIds: [...parsed.data.roleIds] } + : { mode: parsed.data.mode } + : CONTENT_ACCESS_DENY_ALL; + } + + return overrides; +}; + +/** Whether `actor` belongs to `audience`. Root is decided by the caller. */ +export const contentAccessAudienceMatches = ( + audience: ContentAccessAudience, + actor: ContentAccessActor, +): boolean => { + switch (audience.mode) { + case "everyone": + return true; + case "members": + return actor.kind === "member"; + case "roles": + return ( + actor.kind === "member" && + actor.roleIds.some(roleId => audience.roleIds.includes(roleId)) + ); + } +}; + +export const isContentAccessAudienceOpen = ( + audience: ContentAccessAudience, +): boolean => audience.mode === "everyone"; + +/** + * The rules one scope applies, after inheritance: for each action the nearest + * scope in `chain` (the scope itself, then its ancestors) that sets it, then + * the definition's default, then everyone. + */ +export const resolveContentAccessRules = ({ + chain, + defaults, +}: { + chain: readonly ContentAccessScopeLayer[]; + defaults: ContentAccessDefaultsInput; +}): { + policy: ContentAccessPolicy; + sources: Record; +} => { + const policy = {} as ContentAccessPolicy; + const sources = {} as Record; + + for (const action of CONTENT_ACCESS_ACTIONS) { + const layer = chain.find( + candidate => candidate.overrides[action] !== undefined, + ); + const own = layer?.overrides[action]; + if (layer && own) { + policy[action] = own; + sources[action] = { kind: "scope", scopeId: layer.id }; + continue; + } + + const fallback = defaults[action]; + policy[action] = fallback ?? EVERYONE; + sources[action] = fallback ? { kind: "default" } : { kind: "fallback" }; + } + + return { policy, sources }; +}; + +/** The actions `action` needs, itself last: `comment` is view, read, comment. */ +export const contentAccessActionChain = ( + action: ContentAccessAction, +): ContentAccessAction[] => { + const actions: ContentAccessAction[] = []; + let next: ContentAccessAction | null = action; + while (next) { + actions.unshift(next); + next = CONTENT_ACCESS_PREREQUISITES[next]; + } + + return actions; +}; + +/** + * Everything an actor must satisfy to perform `action` in the scope at the + * head of `chain`, as one flat list: + * + * - its own effective rule for the action and for every prerequisite; + * - the `view` rule of every ancestor - a scope inside a hidden one is hidden; + * - for `read` and what needs it, the `read` rule of every ancestor - a child + * can be stricter than its parent, never more open. + * + * Rules open to everyone are left out, so an empty list means "anyone may". + */ +export const contentAccessRequirements = ({ + action, + chain, + defaults, + family, +}: { + action: ContentAccessAction; + chain: readonly ContentAccessScopeLayer[]; + defaults: ContentAccessDefaultsInput; + family: string; +}): ContentAccessRequirement[] => { + const [self] = chain; + if (!self) return []; + + const needed = contentAccessActionChain(action); + const requirements: ContentAccessRequirement[] = []; + const add = ( + layerIndex: number, + ruleAction: ContentAccessAction, + via: ContentAccessRequirement["via"], + ) => { + const layer = chain[layerIndex]; + const { policy, sources } = resolveContentAccessRules({ + chain: chain.slice(layerIndex), + defaults, + }); + const audience = policy[ruleAction]; + if (isContentAccessAudienceOpen(audience)) return; + + requirements.push({ + action: ruleAction, + audience, + family, + scopeId: layer.id, + source: sources[ruleAction], + via, + }); + }; + + // Ancestors first, from the root down, so the first unmet requirement names + // the outermost scope that keeps the actor out. + for (let index = chain.length - 1; index >= 1; index--) { + add(index, "view", "ancestor"); + if (needed.includes("read")) add(index, "read", "ancestor"); + } + for (const ruleAction of needed) add(0, ruleAction, "self"); + + return requirements; +}; + +/** Whether `actor` meets every requirement. A root role meets all of them. */ +export const decideContentAccess = ( + actor: ContentAccessActor, + requirements: readonly ContentAccessRequirement[], +): ContentAccessDecision => { + if (actor.kind === "member" && actor.root) { + return { allowed: true, reason: "root" }; + } + + const unmet = requirements.find( + requirement => !contentAccessAudienceMatches(requirement.audience, actor), + ); + + return unmet + ? { allowed: false, requirement: unmet } + : { allowed: true, reason: "granted" }; +}; + +/** The four yes/no answers a frontend reads for one scope or record. */ +export interface ContentAccessFlags { + canComment: boolean; + canDownloadAttachments: boolean; + canRead: boolean; + canView: boolean; +} + +export const contentAccessFlagKey = { + comment: "canComment", + downloadAttachments: "canDownloadAttachments", + read: "canRead", + view: "canView", +} as const satisfies Record; + +export const CONTENT_ACCESS_OPEN_FLAGS: ContentAccessFlags = { + canComment: true, + canDownloadAttachments: true, + canRead: true, + canView: true, +}; + +export const zodContentAccessFlags = z.object({ + canComment: z.boolean(), + canDownloadAttachments: z.boolean(), + canRead: z.boolean(), + canView: z.boolean(), +}); + +/** Every action's requirements for one chain, keyed by action. */ +export const contentAccessRequirementsByAction = (args: { + chain: readonly ContentAccessScopeLayer[]; + defaults: ContentAccessDefaultsInput; + family: string; +}): Record => + Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [ + action, + contentAccessRequirements({ ...args, action }), + ]), + ) as Record; + +export const contentAccessFlagsOf = ( + actor: ContentAccessActor, + requirements: Record< + ContentAccessAction, + readonly ContentAccessRequirement[] + >, +): ContentAccessFlags => + Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [ + contentAccessFlagKey[action], + decideContentAccess(actor, requirements[action]).allowed, + ]), + ) as unknown as ContentAccessFlags; + +/** Two audiences that let in exactly the same members. */ +export const sameContentAccessAudience = ( + a: ContentAccessAudience, + b: ContentAccessAudience, +): boolean => { + if (a.mode !== b.mode) return false; + if (a.mode !== "roles" || b.mode !== "roles") return true; + + const left = [...new Set(a.roleIds)].sort((x, y) => x - y); + const right = [...new Set(b.roleIds)].sort((x, y) => x - y); + + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +}; diff --git a/packages/vitnode/src/content/access/schemas.ts b/packages/vitnode/src/content/access/schemas.ts new file mode 100644 index 0000000000..c9feec14b2 --- /dev/null +++ b/packages/vitnode/src/content/access/schemas.ts @@ -0,0 +1,137 @@ +import { z } from "zod"; + +import type { ContentAccessAction } from "./policy"; + +import { CONTENT_ACCESS_ACTIONS, CONTENT_ACCESS_ERROR_CODES } from "./const"; +import { zodContentAccessAudienceRead } from "./policy"; + +const zodRuleSource = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("default") }), + z.object({ kind: z.literal("fallback") }), + z.object({ kind: z.literal("scope"), scopeId: z.string() }), +]); + +export const zodContentAccessRequirement = z.object({ + action: z.enum(CONTENT_ACCESS_ACTIONS), + audience: zodContentAccessAudienceRead, + family: z.string(), + scopeId: z.string(), + source: zodRuleSource, + via: z.enum(["ancestor", "self"]), +}); + +const zodActionReport = z.object({ + effective: zodContentAccessAudienceRead, + inherited: zodContentAccessAudienceRead, + inheritedSource: zodRuleSource, + prerequisite: z.enum(CONTENT_ACCESS_ACTIONS).nullable(), + requirements: z.array(zodContentAccessRequirement), + source: zodRuleSource, +}); + +/** What the AdminCP access editor shows for one scope. */ +export const zodContentAccessScopeReport = z.object({ + actions: z.object( + Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [action, zodActionReport]), + ) as Record, + ), + /** Scopes below this one, whose access changes with it. */ + descendants: z.number().int(), + family: z.string(), + overrides: z.record(z.string(), zodContentAccessAudienceRead), + scopeId: z.string(), + /** Actions nobody but a root role can perform here. */ + warnings: z.array( + z.object({ + action: z.enum(CONTENT_ACCESS_ACTIONS), + kind: z.enum(["nobody"]), + scopeId: z.string(), + }), + ), +}); + +export type ContentAccessScopeReport = z.infer< + typeof zodContentAccessScopeReport +>; + +export const zodContentAccessError = z.object({ + code: z.enum(CONTENT_ACCESS_ERROR_CODES), + message: z.string(), +}); + +export const zodContentAccessAttachmentSupport = z.object({ + /** Whether the storage adapter can keep files private. */ + privateStorage: z.boolean(), +}); + +export const zodContentAccessAdminRole = z.object({ + color: z.string().nullable(), + id: z.number(), + name: z.array(z.object({ languageCode: z.string(), name: z.string() })), +}); + +export const zodContentAccessAdminResponse = z.object({ + attachments: zodContentAccessAttachmentSupport, + report: zodContentAccessScopeReport, + /** Every role the report names that still exists, for labels. */ + roles: z.array(zodContentAccessAdminRole), +}); + +export type ContentAccessAdminRole = z.infer; + +export type ContentAccessAdminResponse = z.infer< + typeof zodContentAccessAdminResponse +>; + +/** + * What a record filed under several scopes would require, as the editor of + * an `access.from` content type asks: every scope's requirements together. + */ +export const zodContentAccessEffectiveResponse = z.object({ + actions: z.object( + Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [ + action, + z.object({ requirements: z.array(zodContentAccessRequirement) }), + ]), + ) as Record< + ContentAccessAction, + z.ZodObject<{ + requirements: z.ZodArray; + }> + >, + ), + /** Scope ids that do not exist; a record filed there is readable by nobody. */ + missing: z.array(z.string()), + roles: z.array(zodContentAccessAdminRole), +}); + +export type ContentAccessEffectiveResponse = z.infer< + typeof zodContentAccessEffectiveResponse +>; + +/** Where a family's files are stored: what is protected and what is not. */ +export const zodContentAccessFileStatus = z.object({ + /** Whether the storage adapter can keep files private at all. */ + privateStorage: z.boolean(), + private: z.number().int(), + /** Still reachable by anybody holding the URL. */ + public: z.number().int(), +}); + +export type ContentAccessFileStatusResponse = z.infer< + typeof zodContentAccessFileStatus +>; + +export const zodContentAccessProtectResult = z.object({ + moved: z.number().int(), + /** Public files left for the next batch. */ + remaining: z.number().int(), + /** Public files other content uses too, which stay public. */ + shared: z.number().int(), +}); + +export type ContentAccessProtectResult = z.infer< + typeof zodContentAccessProtectResult +>; diff --git a/packages/vitnode/src/content/access/server/actor.ts b/packages/vitnode/src/content/access/server/actor.ts new file mode 100644 index 0000000000..b2c0a5b1ac --- /dev/null +++ b/packages/vitnode/src/content/access/server/actor.ts @@ -0,0 +1,110 @@ +import type { Context } from "hono"; + +import { and, eq, inArray } from "drizzle-orm"; + +import type { ContentAccessActor } from "../policy"; + +import { core_roles } from "../../../database/roles"; +import { + core_users, + core_users_secondary_roles, +} from "../../../database/users"; + +const GUEST: ContentAccessActor = { kind: "guest" }; + +const actors = new WeakMap>(); + +/** + * Members' current roles and root status, read from the database - never from + * the session cache or anything a client sent - so a role removed a moment ago + * stops counting on the next request. A user id that no longer exists is a + * guest. + */ +export const loadContentAccessActors = async ( + c: Context, + userIds: readonly number[], +): Promise> => { + const ids = [...new Set(userIds)].filter(id => Number.isSafeInteger(id)); + const actorsById = new Map(); + if (ids.length === 0) return actorsById; + + const db = c.get("db"); + const rows = await db + .select({ + id: core_users.id, + roleId: core_users.roleId, + secondaryRoleId: core_users_secondary_roles.roleId, + }) + .from(core_users) + .leftJoin( + core_users_secondary_roles, + eq(core_users_secondary_roles.userId, core_users.id), + ) + .where(inArray(core_users.id, ids)); + + const roles = new Map>(); + for (const row of rows) { + const set = roles.get(row.id) ?? new Set([row.roleId]); + if (typeof row.secondaryRoleId === "number") set.add(row.secondaryRoleId); + roles.set(row.id, set); + } + + const allRoleIds = [...new Set([...roles.values()].flatMap(set => [...set]))]; + const rootRoles = new Set( + allRoleIds.length === 0 + ? [] + : ( + await db + .select({ id: core_roles.id }) + .from(core_roles) + .where( + and( + inArray(core_roles.id, allRoleIds), + eq(core_roles.root, true), + ), + ) + ).map(row => row.id), + ); + + for (const [userId, set] of roles) { + const roleIds = [...set].sort((a, b) => a - b); + actorsById.set(userId, { + kind: "member", + roleIds, + root: roleIds.some(roleId => rootRoles.has(roleId)), + userId, + }); + } + + return actorsById; +}; + +/** + * Who the current request is for, as access rules see them: a guest, or a + * member with their primary and secondary roles and whether one of them is a + * root role. Read once per request. Server-only. + */ +export const resolveContentAccessActor = async ( + c: Context, +): Promise => { + const remembered = actors.get(c); + if (remembered) return await remembered; + + const user = c.get("user") as null | undefined | { id: number }; + const load = async (): Promise => { + if (!user) return GUEST; + + return (await loadContentAccessActors(c, [user.id])).get(user.id) ?? GUEST; + }; + const promise = load(); + actors.set(c, promise); + + return await promise; +}; + +/** Forgets the memoised actor, after a write changed the user's roles. */ +export const forgetContentAccessActor = (c: Context): void => { + actors.delete(c); +}; + +export const CONTENT_ACCESS_GUEST: ContentAccessActor = GUEST; diff --git a/packages/vitnode/src/content/access/server/admin-routes.test.ts b/packages/vitnode/src/content/access/server/admin-routes.test.ts new file mode 100644 index 0000000000..3a8cb733ab --- /dev/null +++ b/packages/vitnode/src/content/access/server/admin-routes.test.ts @@ -0,0 +1,248 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; +import { describe, expect, it } from "vitest"; + +import type { AccessWorld, AccessWorldOptions } from "@/tests/content-access"; + +import { core_audit_logs } from "@/database/audit"; +import { core_content_access_rules } from "@/database/content-access"; +import { core_languages_words } from "@/database/languages"; +import { + ACCESS_PLUGIN_ID, + BOARD, + CATEGORY, + createAccessWorld, + ROLE, + testAccessBoards, + testAccessCategoryContentType, + USER, +} from "@/tests/content-access"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { CONTENT_PERMISSIONS } from "../../const"; +import { buildContentAccessAdminRoutes } from "./admin-routes"; + +const ADMIN = USER.ROOT; + +const FAMILIES = { + boards: { + id: testAccessBoards.id, + permissionModule: testAccessBoards.admin.permissionModule, + }, + categories: { + id: testAccessCategoryContentType.id, + permissionModule: testAccessCategoryContentType.permissionModule, + }, +}; + +const harness = async ( + options?: AccessWorldOptions, + family: (typeof FAMILIES)[keyof typeof FAMILIES] = FAMILIES.boards, +) => { + const world: AccessWorld = await createAccessWorld(options); + await grantStaffPermissions(world.cache, { + permissions: [CONTENT_PERMISSIONS.view, CONTENT_PERMISSIONS.edit].map( + permission => ({ + module: family.permissionModule, + permission, + plugin: ACCESS_PLUGIN_ID, + }), + ), + userId: ADMIN, + }); + await world.memory.db.insert(core_languages_words).values( + [ + [ROLE.MEMBER, "Member"], + [ROLE.VIP, "VIP"], + ].map(([itemId, value]) => ({ + itemId: itemId as number, + languageCode: "en", + pluginCode: "core", + tableName: "core_roles", + value: value as string, + variable: "name", + })), + ); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + for (const [key, value] of Object.entries(world.contextValues(ADMIN))) { + c.set(key as never, value as never); + } + c.set("admin" as never, { user: { id: ADMIN } } as never); + await next(); + }); + app.onError((error, c) => + error instanceof HTTPException + ? error.getResponse() + : c.json({ error: String(error) }, 500), + ); + for (const { handler, hook, route } of buildContentAccessAdminRoutes({ + familyId: family.id, + permissionModule: family.permissionModule, + pluginId: ACCESS_PLUGIN_ID, + })) { + app.openapi(route, handler, hook as never); + } + + const save = async (scopeId: number, overrides: unknown) => + await app.request(`/${scopeId}/access`, { + body: JSON.stringify({ overrides }), + headers: { "content-type": "application/json" }, + method: "PUT", + }); + + return { + entries: () => world.memory.rows(core_audit_logs), + save, + world, + }; +}; + +describe("PUT /{id}/access", () => { + it.each([ + [ + "an unknown role", + BOARD.OPEN, + { read: { mode: "roles", roleIds: [999] } }, + 400, + "unknownRole", + ], + [ + "the guest role", + BOARD.OPEN, + { read: { mode: "roles", roleIds: [ROLE.GUEST] } }, + 400, + "unknownRole", + ], + [ + "a roles audience naming no role", + BOARD.OPEN, + { read: { mode: "roles", roleIds: [] } }, + 400, + "invalidAccess", + ], + [ + "a scope that does not exist", + 99, + { view: { mode: "members" } }, + 404, + "notFound", + ], + [ + "restricted downloads without a private store", + BOARD.OPEN, + { downloadAttachments: { mode: "members" } }, + 400, + "unsupportedStorage", + ], + ])("refuses %s", async (_label, scopeId, overrides, status, code) => { + const { entries, save, world } = await harness({ privateStorage: false }); + const rules = world.memory.rows(core_content_access_rules); + + const response = await save(scopeId, overrides); + + expect(response.status).toBe(status); + await expect(response.json()).resolves.toMatchObject({ code }); + expect(world.emitted).toEqual([]); + expect(entries()).toEqual([]); + expect(world.memory.rows(core_content_access_rules)).toEqual(rules); + }); + + it("saves the rules, announces what changed, and applies them on the next request", async () => { + const { save, world } = await harness(); + + const response = await save(BOARD.OPEN, { + read: { mode: "everyone" }, + view: { mode: "members" }, + }); + + expect(response.status).toBe(200); + expect(world.emitted).toEqual([ + { + name: "content.access.updated", + payload: { + changedActions: ["view", "read"], + family: testAccessBoards.id, + pluginId: ACCESS_PLUGIN_ID, + scopeId: String(BOARD.OPEN), + userId: ADMIN, + }, + }, + ]); + + // The guest loses the board and the child under it. + const boards = (await (await world.send("GET", "/boards")).json()) as { + items: { id: number }[]; + }; + expect(boards.items.map(item => item.id)).toEqual([BOARD.READERS]); + }); + + it.each([ + [ + "a board", + FAMILIES.boards, + BOARD.VIP, + { + read: { mode: "members" }, + view: { mode: "roles", roleIds: [ROLE.MEMBER, ROLE.VIP] }, + }, + { + changes: { + access_read: { from: "inherit", to: "members" }, + access_view: { from: "roles: VIP", to: "roles: Member, VIP" }, + }, + contentTypeId: null, + targetLabel: `Board ${BOARD.VIP}`, + targetType: testAccessBoards.id, + }, + ], + [ + "a category", + FAMILIES.categories, + CATEGORY.MEMBERS, + { view: { mode: "everyone" } }, + { + changes: { + access_read: { from: "members", to: "inherit" }, + access_view: { from: "inherit", to: "everyone" }, + }, + contentTypeId: testAccessCategoryContentType.id, + targetLabel: `Category ${CATEGORY.MEMBERS}`, + targetType: testAccessCategoryContentType.id, + }, + ], + ])( + "records one content.access_updated entry for %s", + async (_label, family, scopeId, overrides, expected) => { + const { entries, save } = await harness(undefined, family); + + const response = await save(scopeId, overrides); + + expect(response.status).toBe(200); + expect(entries()).toEqual([ + expect.objectContaining({ + ...expected, + action: "content.access_updated", + actorId: ADMIN, + actorType: "staff", + area: "admin", + pluginId: ACCESS_PLUGIN_ID, + targetId: String(scopeId), + }), + ]); + }, + ); + + it("announces and records nothing when the saved rules equal the stored ones", async () => { + const { entries, save, world } = await harness(); + + const response = await save(BOARD.VIP, { + view: { mode: "roles", roleIds: [ROLE.VIP] }, + }); + + expect(response.status).toBe(200); + expect(world.emitted).toEqual([]); + expect(entries()).toEqual([]); + }); +}); diff --git a/packages/vitnode/src/content/access/server/admin-routes.ts b/packages/vitnode/src/content/access/server/admin-routes.ts new file mode 100644 index 0000000000..c98b18107c --- /dev/null +++ b/packages/vitnode/src/content/access/server/admin-routes.ts @@ -0,0 +1,507 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { inArray } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import type { RouteValidationHook } from "../../../api/lib/route"; +import type { AnyContentModel } from "../../server/model"; +import type { ContentAccessOverrides } from "../policy"; +import type { + ContentAccessAdminRole, + ContentAccessEffectiveResponse, + ContentAccessScopeReport, +} from "../schemas"; +import type { ContentAccessFamily } from "./families"; + +import { resolveRoleNames } from "../../../api/lib/resolve-role-names"; +import { buildRoute } from "../../../api/lib/route"; +import { core_roles } from "../../../database/roles"; +import { CONTENT_PERMISSIONS } from "../../const"; +import { + CONTENT_ACCESS_ACTIONS, + type CONTENT_ACCESS_ERROR_CODES, + CONTENT_ACCESS_MAX_EFFECTIVE_SCOPES, +} from "../const"; +import { + sameContentAccessAudience, + zodContentAccessOverrides, +} from "../policy"; +import { + zodContentAccessAdminResponse as zodAccessResponse, + zodContentAccessEffectiveResponse, + zodContentAccessError, + zodContentAccessFileStatus, + zodContentAccessProtectResult, +} from "../schemas"; +import { contentAccessAttachmentSupport } from "./attachments"; +import { + recordContentAccessFilesProtected, + recordContentAccessUpdate, +} from "./audit"; +import { contentAccessRequirementsForScopes } from "./decide"; +import { + findContentAccessFamily, + loadContentAccessFamilyState, +} from "./families"; +import { + CONTENT_ACCESS_PROTECT_BATCH, + contentAccessFileStatus, + protectContentAccessFiles, +} from "./file-protection"; +import { + expireContentAccessFamily, + forgetContentAccessState, +} from "./invalidate"; +import { contentAccessScopeReport } from "./report"; +import { ContentAccessRoleError, replaceContentAccessRules } from "./rules"; +import { contentAccessSourceOf } from "./source"; + +/** The roles a report names, with their translated names. Missing ones are left out. */ +const rolesOf = async ( + c: Context, + report: { + actions: ContentAccessEffectiveResponse["actions"]; + overrides?: ContentAccessScopeReport["overrides"]; + }, +): Promise => { + const ids = [ + ...new Set( + CONTENT_ACCESS_ACTIONS.flatMap(action => + report.actions[action].requirements.flatMap(requirement => + requirement.audience.mode === "roles" + ? requirement.audience.roleIds + : [], + ), + ).concat( + Object.values(report.overrides ?? {}).flatMap(audience => + audience.mode === "roles" ? audience.roleIds : [], + ), + ), + ), + ]; + if (ids.length === 0) return []; + + const [rows, names] = await Promise.all([ + c + .get("db") + .select({ color: core_roles.color, id: core_roles.id }) + .from(core_roles) + .where(inArray(core_roles.id, ids)), + resolveRoleNames(c, ids), + ]); + + return rows.map(row => ({ ...row, name: names.get(row.id) ?? [] })); +}; + +const refuse = ( + status: 400 | 404 | 409, + code: (typeof CONTENT_ACCESS_ERROR_CODES)[number], + message: string, +): never => { + throw new HTTPException(status, { + res: Response.json({ code, message }, { status }), + }); +}; + +const validationHook: RouteValidationHook = (result, c) => { + if (result.success) return undefined; + + return c.json( + { + code: "invalidAccess", + message: result.error.issues + .map(issue => `${issue.path.join(".")}: ${issue.message}`) + .join("; "), + }, + 400, + ); +}; + +const errorResponse = (description: string) => ({ + content: { "application/json": { schema: zodContentAccessError } }, + description, +}); + +const idParams = z.object({ + id: z.string().regex(/^[A-Za-z0-9-]{1,64}$/), +}); + +const familyOf = (c: Context, familyId: string): ContentAccessFamily => + findContentAccessFamily(c, familyId) ?? + refuse(404, "notFound", "This collection has no access rules."); + +const reportOf = async ( + c: Context, + family: ContentAccessFamily, + scopeId: string, + preview?: ContentAccessOverrides, +) => { + const report = + (await contentAccessScopeReport(c, family, scopeId, preview)) ?? + refuse(404, "notFound", "The scope does not exist."); + + return { + attachments: contentAccessAttachmentSupport(c), + report, + roles: await rolesOf(c, report), + }; +}; + +/** + * The AdminCP access routes of one scope family, mounted under the family's + * admin module (`/{id}/access`). Read needs `can_view`, writes `can_edit` of + * the module. Plugins do not call this directly. + */ +export const buildContentAccessAdminRoutes =

({ + familyId, + permissionModule, + pluginId, +}: { + familyId: string; + permissionModule: string; + pluginId: P; +}) => { + const module = permissionModule; + + const read = buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}/access", + description: `Read one scope's access rules in ${familyId}: its own rules, what it inherits and every requirement its ancestors add (Admin only)`, + request: { params: idParams }, + responses: { + 200: { + content: { "application/json": { schema: zodAccessResponse } }, + description: "The scope's access report", + }, + 403: { description: "Access Denied" }, + 404: errorResponse("No such scope"), + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + + return c.json(await reportOf(c, familyOf(c, familyId), id), 200, { + "Cache-Control": "private, no-store", + }); + }, + }); + + const preview = buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "post", + path: "/{id}/access/preview", + description: `Preview what one scope's access would be with other rules, without saving them, in ${familyId} (Admin only)`, + request: { + params: idParams, + body: { + required: true, + content: { + "application/json": { + schema: z.object({ overrides: zodContentAccessOverrides }), + }, + }, + }, + }, + responses: { + 200: { + content: { "application/json": { schema: zodAccessResponse } }, + description: "The scope's access report with the previewed rules", + }, + 400: errorResponse("An invalid rule"), + 403: { description: "Access Denied" }, + 404: errorResponse("No such scope"), + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const { overrides } = c.req.valid("json"); + + return c.json( + await reportOf(c, familyOf(c, familyId), id, overrides), + 200, + { "Cache-Control": "private, no-store" }, + ); + }, + }); + + const update = buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + route: { + method: "put", + path: "/{id}/access", + description: `Replace one scope's access rules in ${familyId}. An action left out inherits from the parent and the definition (Admin only)`, + request: { + params: idParams, + body: { + required: true, + content: { + "application/json": { + schema: z.object({ overrides: zodContentAccessOverrides }), + }, + }, + }, + }, + responses: { + 200: { + content: { "application/json": { schema: zodAccessResponse } }, + description: "Rules saved", + }, + 400: errorResponse( + "An invalid rule, an unknown role, or protected downloads without private storage", + ), + 403: { description: "Access Denied" }, + 404: errorResponse("No such scope"), + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const { overrides } = c.req.valid("json"); + const family = familyOf(c, familyId); + const state = await loadContentAccessFamilyState(c, family); + if (!state.parents.has(id)) { + return refuse(404, "notFound", "The scope does not exist."); + } + + // A download rule that keeps anybody out is a promise about bytes, which + // only private storage can keep: refuse it rather than claim it. + const download = overrides.downloadAttachments; + if ( + download && + download.mode !== "everyone" && + !contentAccessAttachmentSupport(c).privateStorage + ) { + return refuse( + 400, + "unsupportedStorage", + "The storage adapter cannot keep files private, so attachment downloads cannot be restricted.", + ); + } + + const before = state.rules.get(id) ?? {}; + const changedActions = CONTENT_ACCESS_ACTIONS.filter(action => { + const old = before[action]; + const next = overrides[action]; + if (!old || !next) return old !== next; + + return !sameContentAccessAudience(old, next); + }); + try { + await replaceContentAccessRules(c.get("db"), { + family: family.id, + overrides, + scopeId: id, + within: async tx => { + await recordContentAccessUpdate(c, tx, { + after: overrides, + before, + changedActions, + family, + scopeId: id, + }); + }, + }); + } catch (error) { + if (error instanceof ContentAccessRoleError) { + return refuse( + 400, + "unknownRole", + `Role ${error.roleIds.join(", ")} does not exist or cannot be chosen.`, + ); + } + throw error; + } + + forgetContentAccessState(c); + if (changedActions.length > 0) { + await expireContentAccessFamily(c, family); + await c.get("events").emit("content.access.updated", { + changedActions, + family: family.id, + pluginId: family.pluginId, + scopeId: id, + userId: c.get("admin")?.user.id ?? c.get("user")?.id ?? null, + }); + } + + return c.json(await reportOf(c, family, id), 200, { + "Cache-Control": "private, no-store", + }); + }, + }); + + const filesStatus = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/access/files", + description: `How many files the content of ${familyId} references, private and still public (Admin only)`, + responses: { + 200: { + content: { + "application/json": { schema: zodContentAccessFileStatus }, + }, + description: "File counts by storage", + }, + 403: { description: "Access Denied" }, + }, + }, + handler: async c => + c.json( + { + ...(await contentAccessFileStatus(c, familyId)), + privateStorage: contentAccessAttachmentSupport(c).privateStorage, + }, + 200, + { "Cache-Control": "private, no-store" }, + ), + }); + + const protectFiles = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + route: { + method: "post", + path: "/access/files/protect", + description: `Move up to ${CONTENT_ACCESS_PROTECT_BATCH} public files of ${familyId} into private storage. Files other content also uses stay public (Admin only)`, + responses: { + 200: { + content: { + "application/json": { schema: zodContentAccessProtectResult }, + }, + description: "What moved, what is left and what is shared", + }, + 400: errorResponse("The storage adapter has no private store"), + 403: { description: "Access Denied" }, + }, + }, + handler: async c => { + if (!contentAccessAttachmentSupport(c).privateStorage) { + return refuse( + 400, + "unsupportedStorage", + "The storage adapter cannot keep files private.", + ); + } + + const family = familyOf(c, familyId); + const result = await protectContentAccessFiles(c, familyId); + if (result.moved > 0) await expireContentAccessFamily(c, family); + await recordContentAccessFilesProtected(c, { family, result }); + + return c.json(result, 200); + }, + }); + + return [read, preview, update, filesStatus, protectFiles]; +}; + +/** + * `POST /access/effective` on the admin module of a content type filed under + * scopes (`access.from`): what a record in the given scopes would require, + * every scope together - the article editor shows it before saving. Needs + * `can_view` of the content type. Plugins do not call this directly. + */ +export const buildContentAccessEffectiveRoute =

({ + contentTypeId, + permissionModule, + pluginId, +}: { + contentTypeId: string; + permissionModule: string; + pluginId: P; +}) => + buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { + module: permissionModule, + permission: CONTENT_PERMISSIONS.view, + }, + route: { + method: "post", + path: "/access/effective", + description: `What a ${contentTypeId} record filed in these scopes would require of a reader, every scope together (Admin only)`, + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + scopeIds: z + .array(z.union([z.string().max(64), z.number().int()])) + .max(CONTENT_ACCESS_MAX_EFFECTIVE_SCOPES), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: zodContentAccessEffectiveResponse }, + }, + description: "Requirements per action", + }, + 403: { description: "Access Denied" }, + 404: errorResponse("The content type is not filed under scopes"), + }, + }, + handler: async c => { + const model = ( + (c.get("core")?.contentModels ?? []) as { + model: AnyContentModel; + }[] + ).find(entry => entry.model.definition.id === contentTypeId)?.model; + const source = model ? contentAccessSourceOf(c, model) : null; + if (source?.kind !== "scopes") { + return refuse(404, "notFound", "This content type has no scopes."); + } + + const ids = [ + ...new Set(c.req.valid("json").scopeIds.map(id => String(id))), + ]; + const state = await loadContentAccessFamilyState(c, source.family); + const scopes = ids.map(scopeId => ({ + family: source.family.id, + scopeId, + })); + const actions = Object.fromEntries( + await Promise.all( + CONTENT_ACCESS_ACTIONS.map( + async action => + [ + action, + { + requirements: await contentAccessRequirementsForScopes( + c, + scopes, + action, + ), + }, + ] as const, + ), + ), + ) as ContentAccessEffectiveResponse["actions"]; + + return c.json( + { + actions, + missing: ids.filter(id => !state.parents.has(id)), + roles: await rolesOf(c, { actions }), + }, + 200, + { "Cache-Control": "private, no-store" }, + ); + }, + }); diff --git a/packages/vitnode/src/content/access/server/attachments.ts b/packages/vitnode/src/content/access/server/attachments.ts new file mode 100644 index 0000000000..99526034eb --- /dev/null +++ b/packages/vitnode/src/content/access/server/attachments.ts @@ -0,0 +1,16 @@ +import type { Context } from "hono"; + +/** + * Whether the configured storage adapter can keep files private. Without it a + * file is reachable by anyone holding its URL, so attachment downloads cannot + * be restricted. Server-only. + */ +export const contentAccessAttachmentSupport = ( + c: Context, +): { privateStorage: boolean } => { + const storage = c.get("storage") as + | undefined + | { supportsPrivateFiles?: () => boolean }; + + return { privateStorage: storage?.supportsPrivateFiles?.() === true }; +}; diff --git a/packages/vitnode/src/content/access/server/audit.test.ts b/packages/vitnode/src/content/access/server/audit.test.ts new file mode 100644 index 0000000000..b8b3941da1 --- /dev/null +++ b/packages/vitnode/src/content/access/server/audit.test.ts @@ -0,0 +1,115 @@ +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; + +import type { ContentDatabase } from "@/content/server/service"; + +import { defineContentType } from "@/content/define"; +import { field } from "@/content/fields"; +import { createContentModel } from "@/content/server/model"; +import { core_audit_logs } from "@/database/audit"; +import { core_languages } from "@/database/languages"; +import { + ACCESS_PLUGIN_ID, + createAccessWorld, + testAccessBoards, + USER, +} from "@/tests/content-access"; +import { createMemoryDb } from "@/tests/memory-db"; + +import { + recordContentAccessFilesProtected, + recordContentAccessUpdate, +} from "./audit"; +import { findContentAccessFamily } from "./families"; + +const ADMIN = { id: USER.ROOT, name: "Admin" }; + +const localizedCategories = createContentModel( + defineContentType({ + id: "test.access-localized-category", + tableName: "test_access_localized_categories", + localization: { defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true, maxLength: 100 }), + }, + access: { scope: true }, + }), +); + +const contextOf = (values: Record): Context => + ({ + get: (key: string) => ({ ...values, admin: { user: ADMIN } })[key], + }) as unknown as Context; + +describe("recordContentAccessUpdate", () => { + it("labels a localized scope with its default translation's title", async () => { + const translations = localizedCategories.translationTable; + if (!translations) throw new Error("The category is not localized."); + const memory = createMemoryDb([ + [core_languages, [{ code: "en", default: true, id: 1 }]], + [localizedCategories.table, [{ id: 7 }]], + [translations, [{ itemId: 7, languageId: 1, title: "News" }]], + ]); + const c = contextOf({ + core: { + contentModels: [ + { model: localizedCategories, pluginId: ACCESS_PLUGIN_ID }, + ], + }, + db: memory.db, + }); + const family = findContentAccessFamily( + c, + localizedCategories.definition.id, + ); + if (!family) throw new Error("The localized family is not registered."); + + await recordContentAccessUpdate( + c, + memory.db as unknown as ContentDatabase, + { + after: { view: { mode: "members" } }, + before: {}, + changedActions: ["view"], + family, + scopeId: "7", + }, + ); + + expect(memory.rows(core_audit_logs)).toEqual([ + expect.objectContaining({ targetId: "7", targetLabel: "News" }), + ]); + }); +}); + +describe("recordContentAccessFilesProtected", () => { + it.each([ + ["files moved", { moved: 3, remaining: 120, shared: 0 }, 1], + ["shared files left public", { moved: 0, remaining: 0, shared: 2 }, 1], + ["nothing", { moved: 0, remaining: 0, shared: 0 }, 0], + ])("records %s", async (_label, result, count) => { + const world = await createAccessWorld(); + const c = contextOf(world.contextValues(ADMIN.id)); + const family = findContentAccessFamily(c, testAccessBoards.id); + if (!family) throw new Error("The boards family is not registered."); + + await recordContentAccessFilesProtected(c, { family, result }); + + expect(world.memory.rows(core_audit_logs)).toEqual( + Array.from({ length: count }, () => + expect.objectContaining({ + action: "content.access_files_protected", + actorId: ADMIN.id, + actorType: "staff", + area: "admin", + contentTypeId: null, + metadata: result, + pluginId: ACCESS_PLUGIN_ID, + targetId: null, + targetType: testAccessBoards.id, + }), + ), + ); + }); +}); diff --git a/packages/vitnode/src/content/access/server/audit.ts b/packages/vitnode/src/content/access/server/audit.ts new file mode 100644 index 0000000000..9c9dc2d168 --- /dev/null +++ b/packages/vitnode/src/content/access/server/audit.ts @@ -0,0 +1,193 @@ +import type { PgColumn } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, eq } from "drizzle-orm"; + +import type { ContentDatabase } from "../../server/service"; +import type { + ContentAccessAction, + ContentAccessAudience, + ContentAccessOverrides, +} from "../policy"; +import type { ContentAccessFamily } from "./families"; + +import { roleAuditLabel } from "../../../api/lib/audit/labels"; +import { writeAuditLog } from "../../../api/lib/audit/write"; +import { core_content_containers } from "../../../database/content-containers"; +import { auditDiff } from "../../../lib/audit/diff"; +import { contentIdFromKey } from "../../ids"; +import { resolveContentAuditLabel } from "../../server/audit"; +import { contentDefinitionOf } from "../../server/model"; + +/** The audit action of a scope whose access rules changed. */ +export const CONTENT_ACCESS_UPDATED_AUDIT_ACTION = "content.access_updated"; + +/** The audit action of public files moved into private storage. */ +export const CONTENT_ACCESS_FILES_PROTECTED_AUDIT_ACTION = + "content.access_files_protected"; + +/** The changes key of one action's rule, labelled by `core.activity_logs.fields`. */ +const fieldOf = (action: ContentAccessAction): string => `access_${action}`; + +/** + * One rule as a stored, language-neutral value: `inherit`, `everyone`, + * `members`, or `roles: VIP, Staff` with the role names of today, so the entry + * still reads after a role is renamed or deleted. + */ +const audienceValue = async ( + c: Context, + audience: ContentAccessAudience | undefined, +): Promise => { + if (!audience) return "inherit"; + if (audience.mode !== "roles") return audience.mode; + if (audience.roleIds.length === 0) return "roles: -"; + const names = await Promise.all( + audience.roleIds.map( + async roleId => (await roleAuditLabel(c, roleId)) ?? `#${roleId}`, + ), + ); + + return `roles: ${names.join(", ")}`; +}; + +/** The scope's name - a container's, or the scope record's title - or `null`. */ +const scopeLabel = async ( + c: Context, + tx: ContentDatabase, + family: ContentAccessFamily, + scopeId: string, +): Promise => { + if (family.kind === "container") { + const id = Number(scopeId); + if (!Number.isSafeInteger(id)) return null; + const [row] = await tx + .select({ name: core_content_containers.name }) + .from(core_content_containers) + .where( + and( + eq(core_content_containers.id, id), + eq(core_content_containers.definitionId, family.id), + ), + ) + .limit(1); + + return row?.name ?? null; + } + + const model = family.model; + if (!model) return null; + const definition = contentDefinitionOf(model); + const itemId = contentIdFromKey(definition.idStrategy, scopeId); + if (itemId === null) return null; + const columns = model.columns as Record; + const [row] = (await tx + .select() + .from(model.table) + .where(eq(columns.id, itemId)) + .limit(1)) as Record[]; + const translations = model.translationService; + + return await resolveContentAuditLabel( + definition, + row ?? null, + translations + ? async () => + await translations(c).findByLocale( + itemId, + definition.localization.defaultLocale, + { tx }, + ) + : undefined, + ); +}; + +/** + * Records an access-rule change of one scope in the Activity Logs, inside the + * transaction that saved it: one entry per save, with the before and after + * rule of every action that changed. Server-only. + */ +export const recordContentAccessUpdate = async ( + c: Context, + tx: ContentDatabase, + { + after, + before, + changedActions, + family, + scopeId, + }: { + after: ContentAccessOverrides; + before: ContentAccessOverrides; + changedActions: readonly ContentAccessAction[]; + family: ContentAccessFamily; + scopeId: string; + }, +): Promise => { + if (changedActions.length === 0) return; + + const entries = await Promise.all( + changedActions.map( + async action => + [ + fieldOf(action), + { + after: await audienceValue(c, after[action]), + before: await audienceValue(c, before[action]), + }, + ] as const, + ), + ); + + await writeAuditLog( + c, + { + action: CONTENT_ACCESS_UPDATED_AUDIT_ACTION, + changes: auditDiff({ + after: Object.fromEntries( + entries.map(([key, { after }]) => [key, after]), + ), + before: Object.fromEntries( + entries.map(([key, { before }]) => [key, before]), + ), + }), + contentTypeId: family.kind === "contentType" ? family.id : null, + pluginId: family.pluginId, + target: { + id: scopeId, + label: await scopeLabel(c, tx, family, scopeId), + type: family.id, + }, + }, + tx, + ); +}; + +/** + * Records a "Protect public files" run that moved or skipped anything. The + * files have already moved - storage is not transactional - so this is written + * after them. + */ +export const recordContentAccessFilesProtected = async ( + c: Context, + { + family, + result, + }: { + family: ContentAccessFamily; + result: { moved: number; remaining: number; shared: number }; + }, +): Promise => { + if (result.moved === 0 && result.shared === 0) return; + + await writeAuditLog(c, { + action: CONTENT_ACCESS_FILES_PROTECTED_AUDIT_ACTION, + contentTypeId: family.kind === "contentType" ? family.id : null, + metadata: { + moved: result.moved, + remaining: result.remaining, + shared: result.shared, + }, + pluginId: family.pluginId, + target: { id: null, label: null, type: family.id }, + }); +}; diff --git a/packages/vitnode/src/content/access/server/decide.test.ts b/packages/vitnode/src/content/access/server/decide.test.ts new file mode 100644 index 0000000000..049b5dd9fb --- /dev/null +++ b/packages/vitnode/src/content/access/server/decide.test.ts @@ -0,0 +1,115 @@ +import { and, eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; + +import { core_content_containers } from "@/database/content-containers"; +import { core_users_secondary_roles } from "@/database/users"; +import { + accessTopics, + BOARD, + CATEGORY, + createAccessWorld, + ROLE, + testAccessBoards, + testAccessCategoryContentType, + TOPIC, + USER, +} from "@/tests/content-access"; + +import { createContentAccess, decideContentAccessForScopes } from "./decide"; + +const FAMILY = testAccessCategoryContentType.id; + +describe("a record in several scopes", () => { + const scopes = (...ids: number[]) => + ids.map(id => ({ family: FAMILY, scopeId: String(id) })); + + it.each([ + [null, [CATEGORY.MEMBERS], false], + [USER.MEMBER, [CATEGORY.MEMBERS], true], + [USER.MEMBER, [CATEGORY.VIP], false], + // Allowed in one of its scopes is not enough: it needs every one. + [USER.MEMBER, [CATEGORY.MEMBERS, CATEGORY.VIP], false], + [USER.VIP, [CATEGORY.MEMBERS, CATEGORY.VIP], true], + ])("user %s reading scopes %j: %s", async (userId, ids, expected) => { + const world = await createAccessWorld(); + + const decision = await decideContentAccessForScopes( + world.contextFor(userId), + { action: "read", scopes: scopes(...ids) }, + ); + + expect(decision.allowed).toBe(expected); + }); + + it("lets nobody into a scope of a family the app does not have", async () => { + const world = await createAccessWorld(); + + const decision = await decideContentAccessForScopes( + world.contextFor(USER.VIP), + { action: "read", scopes: [{ family: "gone.family", scopeId: "1" }] }, + ); + + expect(decision.allowed).toBe(false); + }); + + it("lets a root role read what no rule lets anybody else read", async () => { + const world = await createAccessWorld({ + rules: { + [FAMILY]: { + [CATEGORY.MEMBERS]: { read: { mode: "roles", roleIds: [] } }, + }, + }, + }); + + const decision = await decideContentAccessForScopes( + world.contextFor(USER.ROOT), + { action: "read", scopes: scopes(CATEGORY.MEMBERS) }, + ); + + expect(decision).toEqual({ allowed: true, reason: "root" }); + }); +}); + +describe("changes apply on the next request", () => { + it("stops counting a secondary role the moment it is taken away", async () => { + const world = await createAccessWorld(); + const canSeeVip = async () => + await createContentAccess(world.contextFor(USER.VIP)).canScope( + testAccessBoards.id, + BOARD.VIP, + "view", + ); + + await expect(canSeeVip()).resolves.toBe(true); + + await world.memory.db + .delete(core_users_secondary_roles) + .where( + and( + eq(core_users_secondary_roles.userId, USER.VIP), + eq(core_users_secondary_roles.roleId, ROLE.VIP), + ), + ); + + await expect(canSeeVip()).resolves.toBe(false); + }); + + it("closes a container moved under a restricted parent", async () => { + const world = await createAccessWorld(); + const canRead = async () => + await createContentAccess(world.contextFor(USER.MEMBER)).canRecord( + accessTopics, + TOPIC.OPEN_CHILD, + "read", + ); + + await expect(canRead()).resolves.toBe(true); + + await world.memory.db + .update(core_content_containers) + .set({ parentId: BOARD.VIP }) + .where(eq(core_content_containers.id, BOARD.OPEN_CHILD)); + + await expect(canRead()).resolves.toBe(false); + }); +}); diff --git a/packages/vitnode/src/content/access/server/decide.ts b/packages/vitnode/src/content/access/server/decide.ts new file mode 100644 index 0000000000..5c2cbffab3 --- /dev/null +++ b/packages/vitnode/src/content/access/server/decide.ts @@ -0,0 +1,248 @@ +import type { SQL } from "drizzle-orm"; +import type { Context } from "hono"; + +import type { ContentId } from "../../ids"; +import type { AnyContentModel } from "../../server/model"; +import type { + ContentAccessAction, + ContentAccessActor, + ContentAccessDecision, + ContentAccessFlags, + ContentAccessRequirement, +} from "../policy"; +import type { ContentAccessAllowedScopes } from "./families"; +import type { ContentAccessScopeRef } from "./source"; + +import { contentIdKey } from "../../ids"; +import { CONTENT_ACCESS_ACTIONS } from "../const"; +import { + CONTENT_ACCESS_DENY_ALL, + contentAccessFlagKey, + decideContentAccess, +} from "../policy"; +import { resolveContentAccessActor } from "./actor"; +import { + contentAccessAllowedScopes, + contentAccessScopeRequirements, + findContentAccessFamily, + loadContentAccessFamilyState, +} from "./families"; +import { + contentAccessCondition, + contentAccessScopesForValues, + contentAccessSourceOf, + contentRecordAccessScopes, + isContentAccessOpen, +} from "./source"; + +/** + * Everything `scopes` ask of an actor for `action`, together. A record in two + * scopes needs both: this is an intersection, even though one scope's `roles` + * audience lets in any of its roles. + */ +export const contentAccessRequirementsForScopes = async ( + c: Context, + scopes: readonly ContentAccessScopeRef[], + action: ContentAccessAction, +): Promise => { + const requirements: ContentAccessRequirement[] = []; + for (const scope of scopes) { + const family = findContentAccessFamily(c, scope.family); + if (!family) { + // A scope of a family that is gone, or a parent that no longer exists. + requirements.push({ + action, + audience: CONTENT_ACCESS_DENY_ALL, + family: scope.family, + scopeId: scope.scopeId, + source: { kind: "fallback" }, + via: "self", + }); + continue; + } + + const state = await loadContentAccessFamilyState(c, family); + requirements.push( + ...contentAccessScopeRequirements(state, scope.scopeId, action), + ); + } + + return requirements; +}; + +/** One decision for `action` over every scope in `scopes`. */ +export const decideContentAccessForScopes = async ( + c: Context, + { + action, + actor, + scopes, + }: { + action: ContentAccessAction; + actor?: ContentAccessActor; + scopes: readonly ContentAccessScopeRef[]; + }, +): Promise => + decideContentAccess( + actor ?? (await resolveContentAccessActor(c)), + await contentAccessRequirementsForScopes(c, scopes, action), + ); + +/** The four flags for one set of scopes. */ +export const contentAccessFlagsForScopes = async ( + c: Context, + { + actor, + scopes, + }: { actor?: ContentAccessActor; scopes: readonly ContentAccessScopeRef[] }, +): Promise< + ContentAccessFlags & { + denied: Partial>; + } +> => { + const who = actor ?? (await resolveContentAccessActor(c)); + const flags: Record = {}; + const denied: Partial> = + {}; + for (const action of CONTENT_ACCESS_ACTIONS) { + const decision = decideContentAccess( + who, + await contentAccessRequirementsForScopes(c, scopes, action), + ); + flags[contentAccessFlagKey[action]] = decision.allowed; + if (!decision.allowed) denied[action] = decision.requirement; + } + + return { + ...(flags as unknown as ContentAccessFlags), + denied, + }; +}; + +/** + * Each record's flags, from the scopes it belongs to right now. A record + * missing from the map does not exist. Server-only. + */ +export const contentRecordAccessFlags = async ( + c: Context, + model: AnyContentModel, + ids: readonly ContentId[], + actor?: ContentAccessActor, +): Promise> => { + const scopes = await contentRecordAccessScopes(c, model, ids); + const who = actor ?? (await resolveContentAccessActor(c)); + const result = new Map(); + for (const [key, refs] of scopes) { + const { denied: _denied, ...flags } = await contentAccessFlagsForScopes(c, { + actor: who, + scopes: refs, + }); + result.set(key, flags); + } + + return result; +}; + +/** Whether `actor` may perform `action` on one record. `false` when it does not exist. */ +export const canAccessContentRecord = async ( + c: Context, + { + action, + actor, + id, + model, + }: { + action: ContentAccessAction; + actor?: ContentAccessActor; + id: ContentId; + model: AnyContentModel; + }, +): Promise => { + if (!contentAccessSourceOf(c, model)) return true; + const scopes = (await contentRecordAccessScopes(c, model, [id])).get( + contentIdKey(id), + ); + if (!scopes) return false; + + return (await decideContentAccessForScopes(c, { action, actor, scopes })) + .allowed; +}; + +/** + * The server-side access API for one request: who is asking, what they may + * do with a record or a scope, and the SQL that limits a custom query to the + * records they may read. Every decision reads the current roles, rules and + * scope tree; nothing a client sent is trusted. Server-only. + * + * Root roles pass every rule. Staff permissions grant nothing here: a + * moderator reads a restricted scope on the public site only when an access + * rule lets one of their roles in. + * + * @example + * ```ts + * import { createContentAccess } from "@vitnode/core/content/server"; + * + * const access = createContentAccess(c); + * const where = await access.condition(topicContent, "read"); + * const rows = await c.get("db").select().from(topics).where(where); + * ``` + */ +export const createContentAccess = (c: Context) => ({ + /** The current viewer, resolved once per request. */ + actor: async (): Promise => + await resolveContentAccessActor(c), + /** The scopes of one family that allow `action`. */ + allowedScopes: async ( + familyId: string, + action: ContentAccessAction, + ): Promise => { + const family = findContentAccessFamily(c, familyId); + if (!family) return { all: true, ids: [] }; + + return await contentAccessAllowedScopes(c, { + action, + actor: await resolveContentAccessActor(c), + family, + }); + }, + /** Whether the viewer may perform `action` on one record. */ + canRecord: async ( + model: AnyContentModel, + id: ContentId, + action: ContentAccessAction, + ): Promise => await canAccessContentRecord(c, { action, id, model }), + /** Whether the viewer may perform `action` in one scope of a family. */ + canScope: async ( + familyId: string, + scopeId: number | string, + action: ContentAccessAction, + ): Promise => + ( + await decideContentAccessForScopes(c, { + action, + scopes: [{ family: familyId, scopeId: String(scopeId) }], + }) + ).allowed, + /** A `WHERE` condition limiting `model`'s rows to what the viewer may act on, or `undefined`. */ + condition: async ( + model: AnyContentModel, + action: ContentAccessAction, + ): Promise => await contentAccessCondition(c, model, action), + /** Flags per record id key. */ + flags: async ( + model: AnyContentModel, + ids: readonly ContentId[], + ): Promise> => + await contentRecordAccessFlags(c, model, ids), + /** Whether no rule can change what anybody reads of `model`: safe to share a cache. */ + isOpen: async (model: AnyContentModel): Promise => + await isContentAccessOpen(c, model), + /** The scopes a record would belong to with these values. */ + scopesFor: async ( + model: AnyContentModel, + values: Record, + ): Promise => + await contentAccessScopesForValues(c, model, values), +}); + +export type ContentAccess = ReturnType; diff --git a/packages/vitnode/src/content/access/server/download-route.test.ts b/packages/vitnode/src/content/access/server/download-route.test.ts new file mode 100644 index 0000000000..3d6c5c66a1 --- /dev/null +++ b/packages/vitnode/src/content/access/server/download-route.test.ts @@ -0,0 +1,61 @@ +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; + +import { + accessTopics, + createAccessWorld, + FILE, + TOPIC, + USER, +} from "@/tests/content-access"; + +import { CONTENT_ACCESS_REQUIRED_CODE } from "../const"; + +const download = (fileId: number) => + `/topic-files/${TOPIC.READERS}/files/${fileId}`; + +describe("GET /{id}/files/{fileId}", () => { + // The topic is in a board members read and only VIP downloads from. + it.each([ + ["a guest, who cannot read the topic", undefined, FILE.PDF, 404], + ["a reader without the download rule", USER.MEMBER, FILE.PDF, 403], + ["a reader with it", USER.VIP, FILE.PDF, 200], + ["a file the topic does not hold", USER.VIP, FILE.ELSEWHERE, 404], + ])("answers %s with %i", async (_label, user, fileId, status) => { + const world = await createAccessWorld(); + + const response = await world.send("GET", download(fileId), { user }); + + expect(response.status).toBe(status); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + it("says only that access is required, and never opens the file", async () => { + const world = await createAccessWorld(); + + const response = await world.send("GET", download(FILE.PDF), { + user: USER.MEMBER, + }); + + await expect(response.json()).resolves.toEqual({ + accessRequired: true, + code: CONTENT_ACCESS_REQUIRED_CODE, + }); + expect(world.opened).toEqual([]); + }); + + it("serves an image to every reader, download rule or not", async () => { + const world = await createAccessWorld(); + await world.memory.db + .update(accessTopics.table) + .set({ attachment: FILE.IMAGE }) + .where(eq(accessTopics.table.id, TOPIC.READERS)); + + const response = await world.send("GET", download(FILE.IMAGE), { + user: USER.MEMBER, + }); + + expect(response.status).toBe(200); + expect(world.opened).toEqual(["b.png"]); + }); +}); diff --git a/packages/vitnode/src/content/access/server/download-route.ts b/packages/vitnode/src/content/access/server/download-route.ts new file mode 100644 index 0000000000..49fb12c991 --- /dev/null +++ b/packages/vitnode/src/content/access/server/download-route.ts @@ -0,0 +1,162 @@ +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; + +import { z } from "@hono/zod-openapi"; +import { and, eq } from "drizzle-orm"; + +import type { AnyContentModel, ContentModel } from "../../server/model"; +import type { AnyContentTypeDefinition } from "../../types"; + +import { attachStoredFile } from "../../../api/lib/file-download"; +import { buildRoute } from "../../../api/lib/route"; +import { core_files } from "../../../database/files"; +import { contentTypeName } from "../../admin/labels"; +import { parseContentId } from "../../ids"; +import { canViewHiddenContentRecord } from "../../server/hidden-access"; +import { + contentPublicReadCondition, + publicationColumns, +} from "../../server/publication"; +import { CONTENT_ACCESS_REQUIRED_CODE } from "../const"; +import { canAccessContentRecord } from "./decide"; +import { contentRecordHasFile } from "./files"; +import { + CONTENT_ACCESS_PRIVATE_HEADERS, + zodContentAccessRequired, +} from "./http"; +import { contentAccessCondition } from "./source"; + +/** + * `GET /{publicApi.path}/{id}/files/{fileId}`: one file of one record, for a + * reader the record's access rules let in - images with `read`, anything else + * with `downloadAttachments` as well. The file has to belong to the record + * (a file field, or a rich-text document of it), whatever id the URL names. + * Private files leave storage only through here. Plugins do not call this + * directly; `buildContentPublicModule` mounts it. + */ +export const contentFileDownloadRoute = < + TDefinition extends AnyContentTypeDefinition, + P extends string, +>( + model: ContentModel, + { pluginId }: { pluginId: P }, +) => { + const { definition } = model; + const name = contentTypeName(definition.id); + const accessModel = model as unknown as AnyContentModel; + + return buildRoute({ + pluginId, + route: { + method: "get", + path: "/{id}/files/{fileId}", + description: `Download one file of a ${name}, when the reader may read it - and, for anything but an image, download its attachments`, + request: { + params: z.object({ + fileId: z.coerce.number().int().positive(), + id: z.string().min(1).max(64), + }), + }, + responses: { + 200: { + content: { + "application/octet-stream": { + schema: z.string().openapi({ format: "binary", type: "string" }), + }, + }, + description: "The file", + }, + 403: { + content: { + "application/json": { schema: zodContentAccessRequired }, + }, + description: + "The reader may read the record but not download its attachments", + }, + 404: { + description: + "No such record, no such file, or nothing the reader may read", + }, + }, + }, + handler: async c => { + const { fileId, id } = c.req.valid("param"); + const itemId = parseContentId(definition.idStrategy, id); + const missing = () => + c.json( + { message: "File not found." }, + 404, + CONTENT_ACCESS_PRIVATE_HEADERS, + ); + if (itemId === null) return missing(); + + // Published and readable under the rules of its scopes right now - any + // language, so a file of a record without a default translation still + // resolves. A hidden record only for those who may see it. + const columns = model.columns as Record; + const table: PgTable = model.table; + const published = publicationColumns(definition, columns); + const access = await contentAccessCondition(c, accessModel, "read"); + const [visible] = await c + .get("db") + .select({ id: columns.id }) + .from(table) + .where( + and( + eq(columns.id, itemId), + contentPublicReadCondition(published, {}), + access, + ), + ) + .limit(1); + const readable = + visible !== undefined || + ((await canViewHiddenContentRecord(c, { + contentTypeId: definition.id, + itemId, + })) && + (await canAccessContentRecord(c, { + action: "read", + id: itemId, + model: accessModel, + }))); + if (!readable) return missing(); + + if (!(await contentRecordHasFile(c, accessModel, itemId, fileId))) { + return missing(); + } + + const [file] = await c + .get("db") + .select({ + key: core_files.key, + mimeType: core_files.mimeType, + name: core_files.name, + visibility: core_files.visibility, + }) + .from(core_files) + .where(eq(core_files.id, fileId)) + .limit(1); + if (!file || !c.get("core").storage?.adapter) return missing(); + + const isImage = file.mimeType?.startsWith("image/") === true; + if ( + !isImage && + !(await canAccessContentRecord(c, { + action: "downloadAttachments", + id: itemId, + model: accessModel, + })) + ) { + return c.json( + { accessRequired: true as const, code: CONTENT_ACCESS_REQUIRED_CODE }, + 403, + CONTENT_ACCESS_PRIVATE_HEADERS, + ); + } + + return ( + (await attachStoredFile(c, file, { inline: isImage })) ?? missing() + ); + }, + }); +}; diff --git a/packages/vitnode/src/content/access/server/families.test.ts b/packages/vitnode/src/content/access/server/families.test.ts new file mode 100644 index 0000000000..f08569683b --- /dev/null +++ b/packages/vitnode/src/content/access/server/families.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import type { + ContentAccessAction, + ContentAccessActor, + ContentAccessOverrides, +} from "../policy"; +import type { ContentAccessFamilyState } from "./families"; + +import { decideContentAccess } from "../policy"; +import { contentAccessScopeRequirements } from "./families"; + +const VIP = 4; +const GUEST: ContentAccessActor = { kind: "guest" }; +const MEMBER: ContentAccessActor = { + kind: "member", + roleIds: [2], + root: false, + userId: 1, +}; +const VIP_MEMBER: ContentAccessActor = { ...MEMBER, roleIds: [2, VIP] }; + +/** Members (view: members) > VIP (view: VIP role) and Open (no rules). */ +const state = ( + rules: Record, +): ContentAccessFamilyState => ({ + family: { defaults: {}, id: "test.boards", kind: "container", pluginId: "p" }, + parents: new Map([ + ["1", null], + ["2", "1"], + ["3", "2"], + ["4", "1"], + ]), + rules: new Map(Object.entries(rules)), +}); + +const allows = ( + family: ContentAccessFamilyState, + actor: ContentAccessActor, + scopeId: string, + action: ContentAccessAction, +) => + decideContentAccess( + actor, + contentAccessScopeRequirements(family, scopeId, action), + ).allowed; + +describe("contentAccessScopeRequirements", () => { + const hiddenRoot = state({ + "1": { view: { mode: "members" } }, + "2": { view: { mode: "roles", roleIds: [VIP] } }, + }); + + // The documented table: Members (1), General (4) inheriting beside it, and + // VIP (2) with its child (3) below Members. + it.each([ + [GUEST, { 1: false, 2: false, 3: false, 4: false }], + [MEMBER, { 1: true, 2: false, 3: false, 4: true }], + [VIP_MEMBER, { 1: true, 2: true, 3: true, 4: true }], + ])("applies every ancestor's view rule to %j", (actor, expected) => { + expect( + Object.fromEntries( + ["1", "2", "3", "4"].map(id => [ + id, + allows(hiddenRoot, actor, id, "view"), + ]), + ), + ).toEqual(expected); + }); + + it("applies an ancestor's read rule to read, comment and downloads, not to view", () => { + // The child opens read to everyone; the parent's read rule still holds. + const family = state({ + "1": { read: { mode: "members" } }, + "2": { read: { mode: "everyone" } }, + }); + + expect( + Object.fromEntries( + (["view", "read", "comment", "downloadAttachments"] as const).map( + action => [action, allows(family, GUEST, "2", action)], + ), + ), + ).toEqual({ + comment: false, + downloadAttachments: false, + read: false, + view: true, + }); + }); + + it("names the outermost scope that keeps the actor out", () => { + const [first] = contentAccessScopeRequirements(hiddenRoot, "3", "read"); + + expect(first).toMatchObject({ + action: "view", + scopeId: "1", + via: "ancestor", + }); + }); + + it("lets nobody into a scope that does not exist", () => { + expect(allows(state({}), VIP_MEMBER, "99", "view")).toBe(false); + }); +}); diff --git a/packages/vitnode/src/content/access/server/families.ts b/packages/vitnode/src/content/access/server/families.ts new file mode 100644 index 0000000000..282309e733 --- /dev/null +++ b/packages/vitnode/src/content/access/server/families.ts @@ -0,0 +1,298 @@ +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { eq } from "drizzle-orm"; + +import type { AnyContentContainerDefinition } from "../../containers/types"; +import type { AnyContentModel } from "../../server/model"; +import type { + ContentAccessAction, + ContentAccessActor, + ContentAccessDefaultsInput, + ContentAccessRequirement, + ContentAccessScopeLayer, +} from "../policy"; +import type { ContentAccessRuleMap } from "./rules"; + +import { core_content_containers } from "../../../database/content-containers"; +import { CONTENT_ACCESS_ACTIONS } from "../const"; +import { + CONTENT_ACCESS_DENY_ALL, + contentAccessRequirements, + decideContentAccess, + isContentAccessAudienceOpen, +} from "../policy"; +import { loadContentAccessRules } from "./rules"; + +/** + * A set of access scopes that share one rule store: a container collection + * (`kind: "container"`, nested) or a content type declared with + * `access: { scope }` (`kind: "contentType"`, flat). + */ +export interface ContentAccessFamily { + container?: AnyContentContainerDefinition; + defaults: ContentAccessDefaultsInput; + id: string; + kind: "container" | "contentType"; + model?: AnyContentModel; + pluginId: string; +} + +/** Every access-enabled scope family of the running app. */ +export const listContentAccessFamilies = ( + c: Context, +): ContentAccessFamily[] => { + const core = c.get("core") as + | undefined + | { + contentContainers?: { + definition: AnyContentContainerDefinition; + pluginId: string; + }[]; + contentModels?: { model: AnyContentModel; pluginId: string }[]; + }; + + return [ + ...(core?.contentContainers ?? []).flatMap(({ definition, pluginId }) => + definition.access?.enabled + ? [ + { + container: definition, + defaults: definition.access.defaults, + id: definition.id, + kind: "container" as const, + pluginId, + }, + ] + : [], + ), + ...(core?.contentModels ?? []).flatMap(({ model, pluginId }) => + model.definition.access?.scope.enabled + ? [ + { + defaults: model.definition.access.scope.defaults, + id: model.definition.id, + kind: "contentType" as const, + model, + pluginId, + }, + ] + : [], + ), + ]; +}; + +export const findContentAccessFamily = ( + c: Context, + id: string, +): ContentAccessFamily | undefined => + listContentAccessFamilies(c).find(family => family.id === id); + +/** One family's structure and rules, as one request sees them. */ +export interface ContentAccessFamilyState { + family: ContentAccessFamily; + /** Scope id -> parent scope id (`null` at the root). */ + parents: Map; + rules: ContentAccessRuleMap; +} + +const states = new WeakMap< + Context, + Map> +>(); + +const loadScopes = async ( + c: Context, + family: ContentAccessFamily, +): Promise> => { + // Read live, not from the cached container tree: a move has to change who + // may read a subtree on the very next request. + if (family.kind === "container") { + const rows = await c + .get("db") + .select({ + id: core_content_containers.id, + parentId: core_content_containers.parentId, + }) + .from(core_content_containers) + .where(eq(core_content_containers.definitionId, family.id)); + + return new Map( + rows.map(row => [ + String(row.id), + row.parentId === null ? null : String(row.parentId), + ]), + ); + } + + const model = family.model; + if (!model) return new Map(); + const columns = model.columns as Record; + const table: PgTable = model.table; + const rows = await c.get("db").select({ id: columns.id }).from(table); + + return new Map(rows.map(row => [String(row.id), null])); +}; + +export const loadContentAccessFamilyState = async ( + c: Context, + family: ContentAccessFamily, +): Promise => { + let memo = states.get(c); + if (!memo) { + memo = new Map(); + states.set(c, memo); + } + + const remembered = memo.get(family.id); + if (remembered) return await remembered; + + const promise = (async () => { + const [parents, rules] = await Promise.all([ + loadScopes(c, family), + loadContentAccessRules(c, family.id), + ]); + + return { family, parents, rules }; + })(); + memo.set(family.id, promise); + + return await promise; +}; + +/** Drops every state this request read, after it changed rules or scopes. */ +export const forgetContentAccessFamilyStates = (c: Context): void => { + states.delete(c); +}; + +/** The scope then its ancestors, nearest first, or `null` for an unknown scope. */ +export const contentAccessChainOf = ( + state: ContentAccessFamilyState, + scopeId: string, +): ContentAccessScopeLayer[] | null => { + if (!state.parents.has(scopeId)) return null; + + const chain: ContentAccessScopeLayer[] = []; + const seen = new Set(); + let next: null | string = scopeId; + while (next !== null && !seen.has(next) && state.parents.has(next)) { + seen.add(next); + chain.push({ id: next, overrides: state.rules.get(next) ?? {} }); + next = state.parents.get(next) ?? null; + } + + return chain; +}; + +/** + * What one scope asks of an actor for `action`. A scope that does not exist + * (deleted, or an id nobody resolved) asks for something nobody has, so it + * never reads as open. + */ +export const contentAccessScopeRequirements = ( + state: ContentAccessFamilyState, + scopeId: string, + action: ContentAccessAction, +): ContentAccessRequirement[] => { + const chain = contentAccessChainOf(state, scopeId); + if (!chain) { + return [ + { + action, + audience: CONTENT_ACCESS_DENY_ALL, + family: state.family.id, + scopeId, + source: { kind: "fallback" }, + via: "self", + }, + ]; + } + + return contentAccessRequirements({ + action, + chain, + defaults: state.family.defaults, + family: state.family.id, + }); +}; + +/** Which scopes of a family allow `action` for `actor`. */ +export interface ContentAccessAllowedScopes { + /** Every existing scope allows it. */ + all: boolean; + ids: string[]; +} + +const allowedMemos = new WeakMap< + Context, + Map +>(); + +const actorKey = (actor: ContentAccessActor): string => + actor.kind === "guest" + ? "guest" + : `${actor.userId}:${actor.root ? "root" : actor.roleIds.join(",")}`; + +export const contentAccessAllowedScopes = async ( + c: Context, + { + action, + actor, + family, + }: { + action: ContentAccessAction; + actor: ContentAccessActor; + family: ContentAccessFamily; + }, +): Promise => { + let memo = allowedMemos.get(c); + if (!memo) { + memo = new Map(); + allowedMemos.set(c, memo); + } + const key = `${family.id}|${action}|${actorKey(actor)}`; + const remembered = memo.get(key); + if (remembered) return remembered; + + const state = await loadContentAccessFamilyState(c, family); + const ids = [...state.parents.keys()].filter( + id => + decideContentAccess( + actor, + contentAccessScopeRequirements(state, id, action), + ).allowed, + ); + const result = { all: ids.length === state.parents.size, ids }; + memo.set(key, result); + + return result; +}; + +/** + * Whether no rule of the family keeps anybody out of anything: every default + * and every stored rule is open to everyone. Only then is a response built + * from the family the same for every viewer, and safe to cache for everybody. + */ +export const isContentAccessFamilyOpen = async ( + c: Context, + family: ContentAccessFamily, +): Promise => { + const defaultsOpen = CONTENT_ACCESS_ACTIONS.every(action => { + const audience = family.defaults[action]; + + return !audience || isContentAccessAudienceOpen(audience); + }); + if (!defaultsOpen) return false; + + const state = await loadContentAccessFamilyState(c, family); + + return [...state.rules.values()].every(overrides => + Object.values(overrides).every( + audience => !audience || isContentAccessAudienceOpen(audience), + ), + ); +}; + +export const forgetContentAccessAllowed = (c: Context): void => { + allowedMemos.delete(c); +}; diff --git a/packages/vitnode/src/content/access/server/file-protection.ts b/packages/vitnode/src/content/access/server/file-protection.ts new file mode 100644 index 0000000000..80f70fb284 --- /dev/null +++ b/packages/vitnode/src/content/access/server/file-protection.ts @@ -0,0 +1,229 @@ +import type { Context } from "hono"; + +import { getColumns, inArray, sql } from "drizzle-orm"; +import { type PgColumn, PgTable } from "drizzle-orm/pg-core"; + +import type { AnyContentModel } from "../../server/model"; +import type { ContentFieldMap } from "../../types"; + +import { core_files } from "../../../database/files"; +import { contentPrivateAdminFileUrl } from "./files"; +import { contentModelsInAccessFamily } from "./invalidate"; + +/** How many files one protect request moves, so a big install takes several. */ +export const CONTENT_ACCESS_PROTECT_BATCH = 100; + +const jsonbColumnsOf = (table: PgTable): PgColumn[] => + Object.values(getColumns(table)).filter( + column => column.getSQLType() === "jsonb", + ); + +const tablesOf = (model: AnyContentModel): PgTable[] => [ + model.table, + ...(model.translationTable instanceof PgTable + ? [model.translationTable] + : []), + ...Object.values(model.advancedTables.repeatables).flatMap(table => + table instanceof PgTable ? [table] : [], + ), +]; + +/** Every file id the records of `model` reference: file fields and rich text. */ +const fileIdsOfModel = async ( + c: Context, + model: AnyContentModel, +): Promise> => { + const db = c.get("db"); + const ids = new Set(); + const add = (value: unknown) => { + const id = Number(value); + if (Number.isInteger(id) && id > 0) ids.add(id); + }; + const columns = model.columns as Record; + + for (const [name, field] of Object.entries( + model.definition.fields as ContentFieldMap, + )) { + if (field.kind !== "file") continue; + if (field.multiple) { + const junction = ( + model.advancedTables.junctions as unknown as Record< + string, + Record | undefined + > + )[name]; + if (!junction) continue; + const rows = await db + .selectDistinct({ id: junction.relatedItemId }) + .from(junction as unknown as PgTable); + rows.forEach(row => { + add(row.id); + }); + continue; + } + const column = columns[name]; + if (!column) continue; + const rows = await db.selectDistinct({ id: column }).from(model.table); + rows.forEach(row => { + add(row.id); + }); + } + + for (const table of tablesOf(model)) { + for (const column of jsonbColumnsOf(table)) { + const rows = await db + .selectDistinct({ + id: sql`jsonb_path_query(${column}, '$.**.attrs.fileId')`, + }) + .from(table); + rows.forEach(row => { + add(row.id); + }); + } + } + + return ids; +}; + +const FILE_REFERENCE_PATH = "$.** ? (@.attrs.fileId == $fileId)"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const withFileSrc = (value: unknown, fileId: number, src: string): unknown => { + if (Array.isArray(value)) + return value.map(item => withFileSrc(item, fileId, src)); + if (!isRecord(value)) return value; + + const next: Record = {}; + for (const [key, child] of Object.entries(value)) { + next[key] = withFileSrc(child, fileId, src); + } + if (isRecord(value.attrs) && value.attrs.fileId === fileId) { + next.attrs = { ...value.attrs, src }; + } + + return next; +}; + +/** Points every stored rich-text node of `fileId` in `model` at `src`. */ +const rewriteStoredFileSrc = async ( + c: Context, + model: AnyContentModel, + fileId: number, + src: string, +): Promise => { + const db = c.get("db"); + for (const table of tablesOf(model)) { + for (const column of jsonbColumnsOf(table)) { + const rows = await db + .select({ ctid: sql`ctid::text`, document: column }) + .from(table) + .where( + sql`jsonb_path_exists(${column}, ${FILE_REFERENCE_PATH}::jsonpath, jsonb_build_object('fileId', ${fileId}::integer))`, + ); + for (const row of rows) { + await db.execute( + sql`UPDATE ${table} SET ${sql.identifier(column.name)} = ${JSON.stringify( + withFileSrc(row.document, fileId, src), + )}::jsonb WHERE ctid = ${row.ctid}::tid`, + ); + } + } + } +}; + +export interface ContentAccessFileStatus { + /** Files already in the private store, served only through access checks. */ + private: number; + /** Files that still have a public URL anybody holding it can open. */ + public: number; +} + +/** + * How many files the content of one access family references, by where they + * are stored. A scope is only as private as its files: a public one stays + * reachable by URL whatever the rules say. Server-only. + */ +export const contentAccessFileStatus = async ( + c: Context, + familyId: string, +): Promise => { + const ids = new Set(); + for (const model of contentModelsInAccessFamily(c, familyId)) { + for (const id of await fileIdsOfModel(c, model)) ids.add(id); + } + if (ids.size === 0) return { private: 0, public: 0 }; + + const rows = await c + .get("db") + .select({ visibility: core_files.visibility }) + .from(core_files) + .where(inArray(core_files.id, [...ids])); + + return { + private: rows.filter(row => row.visibility === "private").length, + public: rows.filter(row => row.visibility === "public").length, + }; +}; + +/** + * Moves the family's public files into the private store, a batch at a time. + * A file is moved only when nothing outside the family references it: one + * that public content elsewhere also shows cannot become confidential, so it + * is left alone and counted in `shared` - re-upload it where it should be + * private. Nothing private is ever made public. Server-only. + */ +export const protectContentAccessFiles = async ( + c: Context, + familyId: string, +): Promise<{ moved: number; remaining: number; shared: number }> => { + const inside = contentModelsInAccessFamily(c, familyId); + const outside = ( + (c.get("core")?.contentModels ?? []) as { model: AnyContentModel }[] + ) + .map(entry => entry.model) + .filter(model => !inside.includes(model)); + + const ids = new Set(); + for (const model of inside) { + for (const id of await fileIdsOfModel(c, model)) ids.add(id); + } + const elsewhere = new Set(); + for (const model of outside) { + for (const id of await fileIdsOfModel(c, model)) elsewhere.add(id); + } + + const candidates = + ids.size === 0 + ? [] + : await c + .get("db") + .select({ id: core_files.id, visibility: core_files.visibility }) + .from(core_files) + .where(inArray(core_files.id, [...ids])); + const publicIds = candidates + .filter(row => row.visibility === "public") + .map(row => row.id); + const shared = publicIds.filter(id => elsewhere.has(id)); + const movable = publicIds.filter(id => !elsewhere.has(id)); + + const storage = c.get("storage"); + let moved = 0; + for (const id of movable.slice(0, CONTENT_ACCESS_PROTECT_BATCH)) { + if ((await storage.makeFilePrivate(id)) !== "moved") continue; + moved++; + // The documents still carry the public URL that just stopped working: the + // AdminCP editor reads it as it is stored, so it points at the staff + // route now. Public reads replace it with the authorized route anyway. + for (const model of inside) { + await rewriteStoredFileSrc(c, model, id, contentPrivateAdminFileUrl(id)); + } + } + + return { + moved, + remaining: Math.max(movable.length - CONTENT_ACCESS_PROTECT_BATCH, 0), + shared: shared.length, + }; +}; diff --git a/packages/vitnode/src/content/access/server/files.test.ts b/packages/vitnode/src/content/access/server/files.test.ts new file mode 100644 index 0000000000..9909cc9530 --- /dev/null +++ b/packages/vitnode/src/content/access/server/files.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { + ARTICLE, + createAccessWorld, + FILE, + testAccessArticleContentType, + USER, +} from "@/tests/content-access"; + +import { protectContentPublicRowFiles } from "./files"; + +const route = (fileId: number) => + expect.stringMatching( + new RegExp( + `/api/@vitnode/example/content/access-articles/${ARTICLE}/files/${fileId}$`, + ), + ); + +const row = () => ({ + attachment: { + id: FILE.PDF, + mimeType: "application/pdf", + name: "a.pdf", + size: 3, + url: "https://storage.example/a.pdf", + }, + body: { + content: [ + { + attrs: { fileId: FILE.PDF, name: "a.pdf", size: 3, src: "a.pdf" }, + type: "attachment", + }, + { attrs: { fileId: FILE.IMAGE, src: "b.png" }, type: "image" }, + ], + type: "doc", + }, + id: ARTICLE, +}); + +const protect = async (userId: number) => { + const world = await createAccessWorld(); + const [protectedRow] = await protectContentPublicRowFiles( + world.contextFor(userId), + testAccessArticleContentType, + [row()], + ); + + return protectedRow as ReturnType; +}; + +describe("protectContentPublicRowFiles", () => { + it("strips the attachments a reader may not download, in fields and rich text", async () => { + const { attachment, body } = await protect(USER.MEMBER); + + expect(attachment).toEqual({ + id: FILE.PDF, + mimeType: null, + name: "", + restricted: true, + size: 0, + url: "", + }); + expect(body.content[0]).toEqual({ + attrs: { + fileId: null, + mimeType: null, + name: null, + restricted: true, + size: null, + src: null, + }, + type: "attachment", + }); + }); + + it("shows images to every reader, through the authorized route", async () => { + const { body } = await protect(USER.MEMBER); + + expect(body.content[1]).toEqual({ + attrs: { fileId: FILE.IMAGE, src: route(FILE.IMAGE) }, + type: "image", + }); + }); + + it("points a reader who may download at the authorized route", async () => { + const { attachment, body } = await protect(USER.VIP); + + expect(attachment).toMatchObject({ name: "a.pdf", url: route(FILE.PDF) }); + expect(body.content[0].attrs).toMatchObject({ + name: "a.pdf", + src: route(FILE.PDF), + }); + }); +}); diff --git a/packages/vitnode/src/content/access/server/files.ts b/packages/vitnode/src/content/access/server/files.ts new file mode 100644 index 0000000000..6a6d93ccbe --- /dev/null +++ b/packages/vitnode/src/content/access/server/files.ts @@ -0,0 +1,361 @@ +import type { Context } from "hono"; + +import { and, eq, getColumns, inArray, sql } from "drizzle-orm"; +import { type PgColumn, PgTable } from "drizzle-orm/pg-core"; + +import type { ContentFileDescriptor } from "../../files"; +import type { ContentId } from "../../ids"; +import type { AnyContentModel } from "../../server/model"; +import type { AnyContentTypeDefinition, ContentFieldMap } from "../../types"; +import type { ContentAccessFlags } from "../policy"; + +import { core_files } from "../../../database/files"; +import { CONFIG } from "../../../lib/config"; +import { contentIdKey, parseContentId } from "../../ids"; +import { CONTENT_ACCESS_OPEN_FLAGS } from "../policy"; +import { contentRecordAccessFlags } from "./decide"; +import { contentAccessSourceOf } from "./source"; + +const UPLOAD_NODE_TYPES: ReadonlySet = new Set([ + "attachment", + "audio", + "image", +]); + +const FILE_REFERENCE_PATH = "$.** ? (@.attrs.fileId == $fileId)"; + +interface FileFacts { + isImage: boolean; + visibility: "private" | "public"; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const positiveId = (value: unknown): null | number => + typeof value === "number" && Number.isInteger(value) && value > 0 + ? value + : null; + +/** Every `attrs.fileId` a rich-text document names. */ +const richTextFileIds = (value: unknown, into: Set): void => { + if (Array.isArray(value)) { + for (const item of value) richTextFileIds(item, into); + + return; + } + if (!isRecord(value)) return; + if ( + typeof value.type === "string" && + UPLOAD_NODE_TYPES.has(value.type) && + isRecord(value.attrs) + ) { + const id = positiveId(value.attrs.fileId); + if (id !== null) into.add(id); + } + if (Array.isArray(value.content)) richTextFileIds(value.content, into); +}; + +const registeredOf = ( + c: Context, + contentTypeId: string, +): undefined | { model: AnyContentModel; pluginId: string } => + ( + (c.get("core")?.contentModels ?? []) as { + model: AnyContentModel; + pluginId: string; + }[] + ).find(entry => entry.model.definition.id === contentTypeId); + +/** + * The URL a protected file is served from: the content type's own download + * route, which checks the reader's access to the record every time. The same + * for every reader, so a response carrying it may still be cached where the + * rest of it allows. + */ +export const contentProtectedFileUrl = ({ + fileId, + itemId, + pluginId, + publicPath, +}: { + fileId: number; + itemId: ContentId; + pluginId: string; + publicPath: string; +}): string => + `${CONFIG.api.origin.replace(/\/$/, "")}/api/${pluginId}/content/${publicPath}/${encodeURIComponent(contentIdKey(itemId))}/files/${fileId}`; + +const readFileFacts = async ( + c: Context, + ids: readonly number[], +): Promise> => { + if (ids.length === 0) return new Map(); + const rows = await c + .get("db") + .select({ + id: core_files.id, + mimeType: core_files.mimeType, + visibility: core_files.visibility, + }) + .from(core_files) + .where(inArray(core_files.id, [...ids])); + + return new Map( + rows.map(row => [ + row.id, + { + isImage: row.mimeType?.startsWith("image/") === true, + visibility: row.visibility, + }, + ]), + ); +}; + +/** + * Rewrites the files of public rows for the reader: a private file's URL + * becomes the authorized download route, and an attachment (anything but an + * image) the reader may not download loses its name, size and link - in file + * fields and in rich-text documents alike. Images follow `read`; a reader + * who can read the record sees them, and can save what they see. + * + * Runs on every public read after the file descriptors are resolved. + * Server-only. + */ +export const protectContentPublicRowFiles = async ( + c: Context, + definition: AnyContentTypeDefinition, + rows: Record[], +): Promise[]> => { + if (!definition.publicApi.enabled || rows.length === 0) return rows; + const registered = registeredOf(c, definition.id); + if (!registered) return rows; + + const exposed = new Set(definition.publicApi.fields); + const fileFields = Object.entries(definition.fields) + .filter(([name, field]) => field.kind === "file" && exposed.has(name)) + .map(([name]) => name); + const richFields = Object.entries(definition.fields) + .filter(([name, field]) => field.kind === "richText" && exposed.has(name)) + .map(([name]) => name); + if (fileFields.length === 0 && richFields.length === 0) return rows; + + const ids = new Set(); + for (const row of rows) { + for (const name of fileFields) { + const value = row[name]; + for (const item of Array.isArray(value) ? value : [value]) { + const id = positiveId(isRecord(item) ? item.id : item); + if (id !== null) ids.add(id); + } + } + for (const name of richFields) richTextFileIds(row[name], ids); + } + if (ids.size === 0) return rows; + + const facts = await readFileFacts(c, [...ids]); + const hasAccess = contentAccessSourceOf(c, registered.model) !== null; + const rowIds = rows.flatMap(row => { + const id = parseContentId(definition.idStrategy, row.id); + + return id === null ? [] : [id]; + }); + const flags: Map = hasAccess + ? await contentRecordAccessFlags(c, registered.model, rowIds) + : new Map(); + + return rows.map(row => { + const itemId = parseContentId(definition.idStrategy, row.id); + const rowFlags = + itemId === null + ? CONTENT_ACCESS_OPEN_FLAGS + : (flags.get(contentIdKey(itemId)) ?? CONTENT_ACCESS_OPEN_FLAGS); + const urlOf = (fileId: number): string => + itemId === null + ? "" + : contentProtectedFileUrl({ + fileId, + itemId, + pluginId: registered.pluginId, + publicPath: definition.publicApi.path, + }); + + const fixDescriptor = (descriptor: unknown): unknown => { + if (!isRecord(descriptor)) return descriptor; + const id = positiveId(descriptor.id); + const fact = id === null ? undefined : facts.get(id); + if (!fact || id === null) return descriptor; + + if (!fact.isImage && !rowFlags.canDownloadAttachments) { + return { + id, + mimeType: null, + name: "", + restricted: true, + size: 0, + url: "", + } satisfies ContentFileDescriptor; + } + + return fact.visibility === "private" + ? { ...descriptor, url: urlOf(id) } + : descriptor; + }; + + const fixNode = (node: unknown): unknown => { + if (Array.isArray(node)) return node.map(fixNode); + if (!isRecord(node)) return node; + + const next: Record = { ...node }; + if (Array.isArray(node.content)) next.content = node.content.map(fixNode); + + if ( + typeof node.type !== "string" || + !UPLOAD_NODE_TYPES.has(node.type) || + !isRecord(node.attrs) + ) { + return next; + } + const id = positiveId(node.attrs.fileId); + const fact = id === null ? undefined : facts.get(id); + if (!fact || id === null) return next; + + if (node.type !== "image" && !rowFlags.canDownloadAttachments) { + next.attrs = { + fileId: null, + mimeType: null, + name: null, + restricted: true, + size: null, + src: null, + }; + next.type = "attachment"; + + return next; + } + if (fact.visibility === "private") { + next.attrs = { ...node.attrs, src: urlOf(id) }; + } + + return next; + }; + + const patched: Record = { ...row }; + for (const name of fileFields) { + const value = row[name]; + patched[name] = Array.isArray(value) + ? value.map(fixDescriptor) + : fixDescriptor(value); + } + for (const name of richFields) patched[name] = fixNode(row[name]); + + return patched; + }); +}; + +const jsonbColumnsOf = (table: PgTable): PgColumn[] => + Object.values(getColumns(table)).filter( + column => column.getSQLType() === "jsonb", + ); + +/** + * Whether `fileId` belongs to the record: one of its file fields holds it, or + * one of its rich-text documents (any language, any repeatable row) names it. + * A file id a client sends never proves anything on its own. + */ +export const contentRecordHasFile = async ( + c: Context, + model: AnyContentModel, + itemId: ContentId, + fileId: number, +): Promise => { + const db = c.get("db"); + const { definition } = model; + const columns = model.columns as Record; + const table: PgTable = model.table; + + for (const [name, field] of Object.entries( + definition.fields as ContentFieldMap, + )) { + if (field.kind !== "file") continue; + if (field.multiple) { + const junction = ( + model.advancedTables.junctions as unknown as Record< + string, + Record | undefined + > + )[name]; + if (!junction) continue; + const [row] = await db + .select({ one: sql`1` }) + .from(junction as unknown as PgTable) + .where( + and(eq(junction.itemId, itemId), eq(junction.relatedItemId, fileId)), + ) + .limit(1); + if (row) return true; + continue; + } + + const column = columns[name]; + if (!column) continue; + const [row] = await db + .select({ one: sql`1` }) + .from(table) + .where(and(eq(columns.id, itemId), eq(column, fileId))) + .limit(1); + if (row) return true; + } + + const references = (column: PgColumn) => + sql`jsonb_path_exists(${column}, ${FILE_REFERENCE_PATH}::jsonpath, jsonb_build_object('fileId', ${fileId}::integer))`; + const tables: { idColumn: PgColumn; table: PgTable }[] = [ + { idColumn: columns.id, table }, + ...(model.translationTable instanceof PgTable + ? [ + { + idColumn: getColumns(model.translationTable).itemId, + table: model.translationTable, + }, + ] + : []), + ...Object.values(model.advancedTables.repeatables).flatMap(child => + child instanceof PgTable + ? [{ idColumn: getColumns(child).itemId, table: child }] + : [], + ), + ]; + for (const { idColumn, table: source } of tables) { + if (!idColumn) continue; + for (const column of jsonbColumnsOf(source)) { + const [row] = await db + .select({ one: sql`1` }) + .from(source) + .where(and(eq(idColumn, itemId), references(column))) + .limit(1); + if (row) return true; + } + } + + return false; +}; + +/** + * Whether new files of this content type are stored privately: it has access + * rules and the storage adapter can keep files private. Existing files stay + * where they are until an administrator protects them. + */ +export const contentUploadVisibility = ( + c: Context, + model: AnyContentModel, +): "private" | "public" => + contentAccessSourceOf(c, model) !== null && + ( + c.get("storage") as { supportsPrivateFiles?: () => boolean } + ).supportsPrivateFiles?.() === true + ? "private" + : "public"; + +/** The staff-only URL a private file is shown from in the AdminCP. */ +export const contentPrivateAdminFileUrl = (fileId: number): string => + `${CONFIG.api.origin.replace(/\/$/, "")}/api/@vitnode/core/admin/files/${fileId}/download?inline=true`; diff --git a/packages/vitnode/src/content/access/server/http.ts b/packages/vitnode/src/content/access/server/http.ts new file mode 100644 index 0000000000..c791b30be6 --- /dev/null +++ b/packages/vitnode/src/content/access/server/http.ts @@ -0,0 +1,80 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; + +import type { ContentId } from "../../ids"; +import type { AnyContentModel } from "../../server/model"; +import type { ContentAccessActor } from "../policy"; + +import { contentAccessTag } from "../../cache"; +import { contentIdKey } from "../../ids"; +import { CONTENT_ACCESS_REQUIRED_CODE } from "../const"; +import { contentRecordAccessFlags } from "./decide"; +import { contentAccessFamilyOf, isContentAccessOpen } from "./source"; + +/** The headers of a response that depends on who is asking. */ +export const CONTENT_ACCESS_PRIVATE_HEADERS = { + "Cache-Control": "private, no-store", +} as const; + +/** + * A role, synthetic and internal, that passes every access rule. Only for + * existence checks whose answer never reaches a client as content. + */ +export const CONTENT_ACCESS_SYSTEM_ACTOR: ContentAccessActor = { + kind: "member", + roleIds: [], + root: true, + userId: 0, +}; + +/** The body of a 403 for a scope the viewer may see but not read. */ +export const zodContentAccessRequired = z.object({ + accessRequired: z.literal(true), + code: z.literal(CONTENT_ACCESS_REQUIRED_CODE), +}); + +export const contentAccessRequiredResponse = (c: Context) => + c.json( + { accessRequired: true as const, code: CONTENT_ACCESS_REQUIRED_CODE }, + 403, + CONTENT_ACCESS_PRIVATE_HEADERS, + ); + +/** + * Whether a public response for `model` may be shared by every viewer: no + * access rule can change it. Otherwise it must be `private, no-store` and stay + * out of every shared cache (the API's public-read cache and the page cache). + */ +export const isContentAccessShareable = async ( + c: Context, + model: AnyContentModel, +): Promise => await isContentAccessOpen(c, model); + +/** The extra page-cache tag of a shareable response from an access family. */ +export const contentAccessPageCacheTags = ( + c: Context, + model: AnyContentModel, +): string[] => { + const family = contentAccessFamilyOf(c, model); + + return family ? [contentAccessTag(family.id)] : []; +}; + +/** + * For a record the viewer cannot read: `"accessRequired"` when it exists and + * the viewer may see every scope it is in (so a page can say "sign in" or + * "you need access"), `"notFound"` otherwise - a hidden scope never confirms + * that anything is filed in it. + */ +export const contentAccessMissReason = async ( + c: Context, + model: AnyContentModel, + id: ContentId, +): Promise<"accessRequired" | "notFound"> => { + const flags = (await contentRecordAccessFlags(c, model, [id])).get( + contentIdKey(id), + ); + + return flags?.canView && !flags.canRead ? "accessRequired" : "notFound"; +}; diff --git a/packages/vitnode/src/content/access/server/invalidate.ts b/packages/vitnode/src/content/access/server/invalidate.ts new file mode 100644 index 0000000000..eda98df360 --- /dev/null +++ b/packages/vitnode/src/content/access/server/invalidate.ts @@ -0,0 +1,80 @@ +import type { Context } from "hono"; + +import type { AnyContentModel } from "../../server/model"; +import type { ContentAccessFamily } from "./families"; + +import { invalidatePageCacheTags } from "../../../page-cache/store.server"; +import { + contentAccessTag, + contentContainerTag, + contentDeliverySitemapTag, + contentPublicListTag, +} from "../../cache"; +import { contentDefinitionOf } from "../../server/model"; +import { + bumpContentContainerGeneration, + bumpContentPublicGeneration, +} from "../../server/public-cache"; +import { forgetContentAccessActor } from "./actor"; +import { + forgetContentAccessAllowed, + forgetContentAccessFamilyStates, +} from "./families"; +import { forgetContentAccessRules } from "./rules"; +import { contentAccessFamilyOf } from "./source"; + +/** Content models whose records live in `family`, directly or through a parent. */ +export const contentModelsInAccessFamily = ( + c: Context, + familyId: string, +): AnyContentModel[] => + ((c.get("core")?.contentModels ?? []) as { model: AnyContentModel }[]) + .map(entry => entry.model) + .filter(model => contentAccessFamilyOf(c, model)?.id === familyId); + +/** Drops this request's memoised access state after it changed some of it. */ +export const forgetContentAccessState = (c: Context): void => { + forgetContentAccessActor(c); + forgetContentAccessRules(c); + forgetContentAccessFamilyStates(c); + forgetContentAccessAllowed(c); +}; + +/** + * Expires everything a family's rules or tree decide, after the change + * committed: shared page-cache entries (hard, so a now-private page is never + * served again), the cached public reads of every content type filed in it, + * its container tree, and its sitemaps. Search needs nothing: results are + * checked against the current rules on every query. + */ +export const expireContentAccessFamily = async ( + c: Context, + family: Pick, +): Promise => { + forgetContentAccessState(c); + const models = contentModelsInAccessFamily(c, family.id); + + await Promise.all([ + invalidatePageCacheTags( + [ + contentAccessTag(family.id), + ...(family.kind === "container" + ? [contentContainerTag(family.id)] + : [contentPublicListTag(family.id)]), + ...models.flatMap(model => [ + contentPublicListTag(contentDefinitionOf(model).id), + contentDeliverySitemapTag(contentDefinitionOf(model).id), + ]), + ], + { mode: "hard" }, + ).catch(() => undefined), + family.kind === "container" + ? bumpContentContainerGeneration(c, family.id) + : bumpContentPublicGeneration(c, family.id).catch(() => undefined), + ...models.map(async model => { + await bumpContentPublicGeneration(c, contentDefinitionOf(model).id).catch( + () => undefined, + ); + }), + ]); +}; diff --git a/packages/vitnode/src/content/access/server/report.ts b/packages/vitnode/src/content/access/server/report.ts new file mode 100644 index 0000000000..74e7fb5d3d --- /dev/null +++ b/packages/vitnode/src/content/access/server/report.ts @@ -0,0 +1,108 @@ +import type { Context } from "hono"; + +import type { + ContentAccessAction, + ContentAccessAudience, + ContentAccessOverrides, + ContentAccessRequirement, + ContentAccessRuleSource, +} from "../policy"; +import type { ContentAccessScopeReport } from "../schemas"; +import type { ContentAccessFamily } from "./families"; + +import { CONTENT_ACCESS_ACTIONS, CONTENT_ACCESS_PREREQUISITES } from "../const"; +import { resolveContentAccessRules } from "../policy"; +import { + contentAccessChainOf, + contentAccessScopeRequirements, + loadContentAccessFamilyState, +} from "./families"; + +interface ActionReport { + effective: ContentAccessAudience; + inherited: ContentAccessAudience; + inheritedSource: ContentAccessRuleSource; + prerequisite: ContentAccessAction | null; + requirements: ContentAccessRequirement[]; + source: ContentAccessRuleSource; +} + +/** + * One scope's own rules, what it inherits, and every requirement that applies + * once its ancestors are counted - for the AdminCP, which explains *why* a + * scope is restricted rather than only listing roles. `null` for a scope that + * does not exist. Server-only. + */ +export type { ContentAccessScopeReport } from "../schemas"; + +export const contentAccessScopeReport = async ( + c: Context, + family: ContentAccessFamily, + scopeId: string, + /** Rules to preview instead of the stored ones, before saving them. */ + preview?: ContentAccessOverrides, +): Promise => { + const loaded = await loadContentAccessFamilyState(c, family); + const state = preview + ? { + ...loaded, + rules: new Map(loaded.rules).set(scopeId, preview), + } + : loaded; + const chain = contentAccessChainOf(state, scopeId); + if (!chain) return null; + + const own = resolveContentAccessRules({ chain, defaults: family.defaults }); + const inherited = resolveContentAccessRules({ + chain: chain.slice(1), + defaults: family.defaults, + }); + + const actions = Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [ + action, + { + effective: own.policy[action], + inherited: inherited.policy[action], + inheritedSource: inherited.sources[action], + prerequisite: CONTENT_ACCESS_PREREQUISITES[action], + requirements: contentAccessScopeRequirements(state, scopeId, action), + source: own.sources[action], + } satisfies ActionReport, + ]), + ) as Record; + + const children = new Map(); + for (const [id, parent] of state.parents) { + if (parent === null) continue; + children.set(parent, [...(children.get(parent) ?? []), id]); + } + let descendants = 0; + const stack = [...(children.get(scopeId) ?? [])]; + const seen = new Set(); + while (stack.length > 0) { + const next = stack.pop(); + if (next === undefined || seen.has(next)) continue; + seen.add(next); + descendants++; + stack.push(...(children.get(next) ?? [])); + } + + const warnings = CONTENT_ACCESS_ACTIONS.flatMap(action => + actions[action].requirements.flatMap(requirement => + requirement.audience.mode === "roles" && + requirement.audience.roleIds.length === 0 + ? [{ action, kind: "nobody" as const, scopeId: requirement.scopeId }] + : [], + ), + ); + + return { + actions, + descendants, + family: family.id, + overrides: state.rules.get(scopeId) ?? {}, + scopeId, + warnings, + }; +}; diff --git a/packages/vitnode/src/content/access/server/rules.test.ts b/packages/vitnode/src/content/access/server/rules.test.ts new file mode 100644 index 0000000000..bb00fc66cf --- /dev/null +++ b/packages/vitnode/src/content/access/server/rules.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import type { ContentDatabase } from "@/content/server/service"; + +import { writeAuditLog } from "@/api/lib/audit/write"; +import { core_audit_logs } from "@/database/audit"; +import { core_content_access_rules } from "@/database/content-access"; +import { + BOARD, + createAccessWorld, + testAccessBoards, +} from "@/tests/content-access"; + +import { replaceContentAccessRules } from "./rules"; + +describe("replaceContentAccessRules", () => { + it("runs `within` after the rules in their transaction, so a failure there rolls both back", async () => { + const world = await createAccessWorld(); + const rules = world.memory.rows(core_content_access_rules); + const seen: unknown[] = []; + + await expect( + replaceContentAccessRules(world.memory.db as unknown as ContentDatabase, { + family: testAccessBoards.id, + overrides: { view: { mode: "members" } }, + scopeId: String(BOARD.OPEN), + within: async tx => { + seen.push( + ...(await tx + .select({ mode: core_content_access_rules.mode }) + .from(core_content_access_rules)), + ); + await writeAuditLog( + world.contextFor(), + { action: "content.access_updated", target: { type: "board" } }, + tx, + ); + throw new Error("the audit entry failed"); + }, + }), + ).rejects.toThrow("the audit entry failed"); + + expect(seen).toHaveLength(rules.length + 1); + expect(world.memory.rows(core_content_access_rules)).toEqual(rules); + expect(world.memory.rows(core_audit_logs)).toEqual([]); + }); +}); diff --git a/packages/vitnode/src/content/access/server/rules.ts b/packages/vitnode/src/content/access/server/rules.ts new file mode 100644 index 0000000000..26a6f58c0d --- /dev/null +++ b/packages/vitnode/src/content/access/server/rules.ts @@ -0,0 +1,240 @@ +import type { Context } from "hono"; + +import { and, eq, inArray } from "drizzle-orm"; + +import type { ContentDatabase } from "../../server/service"; +import type { ContentAccessAudience, ContentAccessOverrides } from "../policy"; + +import { + core_content_access_rule_roles, + core_content_access_rules, +} from "../../../database/content-access"; +import { core_roles } from "../../../database/roles"; +import { CONTENT_ACCESS_ACTIONS } from "../const"; +import { readContentAccessOverrides } from "../policy"; + +export type ContentAccessRuleMap = Map; + +const memos = new WeakMap< + Context, + Map> +>(); + +const readRules = async ( + db: ContentDatabase, + family: string, + scopeIds?: readonly string[], +): Promise => { + if (scopeIds?.length === 0) return new Map(); + + const rules = await db + .select({ + action: core_content_access_rules.action, + id: core_content_access_rules.id, + mode: core_content_access_rules.mode, + scopeId: core_content_access_rules.scopeId, + }) + .from(core_content_access_rules) + .where( + scopeIds + ? and( + eq(core_content_access_rules.family, family), + inArray(core_content_access_rules.scopeId, [...scopeIds]), + ) + : eq(core_content_access_rules.family, family), + ); + if (rules.length === 0) return new Map(); + + const roleRows = await db + .select({ + roleId: core_content_access_rule_roles.roleId, + ruleId: core_content_access_rule_roles.ruleId, + }) + .from(core_content_access_rule_roles) + .where( + inArray( + core_content_access_rule_roles.ruleId, + rules.map(rule => rule.id), + ), + ); + const rolesOf = new Map(); + for (const row of roleRows) { + rolesOf.set(row.ruleId, [...(rolesOf.get(row.ruleId) ?? []), row.roleId]); + } + + const raw = new Map>(); + for (const rule of rules) { + const entry = raw.get(rule.scopeId) ?? {}; + entry[rule.action] = + rule.mode === "roles" + ? { mode: "roles", roleIds: rolesOf.get(rule.id) ?? [] } + : { mode: rule.mode }; + raw.set(rule.scopeId, entry); + } + + // Through the defensive reader: an unknown mode becomes deny-all rather + // than nothing, and an unknown action is ignored. + return new Map( + [...raw].map(([scopeId, value]) => [ + scopeId, + readContentAccessOverrides(value), + ]), + ); +}; + +/** + * Every stored rule of one scope family, keyed by scope id. Read live once per + * request, so a rule saved a moment ago applies on the next request. Server-only. + */ +export const loadContentAccessRules = async ( + c: Context, + family: string, +): Promise => { + let memo = memos.get(c); + if (!memo) { + memo = new Map(); + memos.set(c, memo); + } + + const remembered = memo.get(family); + if (remembered) return await remembered; + + const promise = readRules(c.get("db"), family); + memo.set(family, promise); + + return await promise; +}; + +export const forgetContentAccessRules = (c: Context): void => { + memos.delete(c); +}; + +export class ContentAccessRoleError extends Error { + constructor(roleIds: number[]) { + super(`Unknown or unusable role ids: ${roleIds.join(", ")}.`); + this.name = "ContentAccessRoleError"; + this.roleIds = roleIds; + } + + readonly roleIds: number[]; +} + +/** + * The role ids among `ids` that cannot be used in an audience: missing ones and + * the guest role (an audience of roles is for signed-in members). + */ +export const unusableContentAccessRoleIds = async ( + db: ContentDatabase, + ids: readonly number[], +): Promise => { + if (ids.length === 0) return []; + const rows = await db + .select({ guest: core_roles.guest, id: core_roles.id }) + .from(core_roles) + .where(inArray(core_roles.id, [...ids])); + const usable = new Set(rows.filter(row => !row.guest).map(row => row.id)); + + return ids.filter(id => !usable.has(id)); +}; + +/** + * Replaces one scope's rules with `overrides` in one transaction. An action + * left out inherits. Validated by the caller with `zodContentAccessOverrides`. + * `within` runs last inside the same transaction - the audit entry, which + * then commits or rolls back with the rules. + * + * @throws {ContentAccessRoleError} When an audience names a role that does not + * exist or is the guest role. + */ +export const replaceContentAccessRules = async ( + db: ContentDatabase, + { + family, + overrides, + scopeId, + within, + }: { + family: string; + overrides: ContentAccessOverrides; + scopeId: string; + within?: (tx: ContentDatabase) => Promise; + }, +): Promise => { + const roleIds = [ + ...new Set( + Object.values(overrides).flatMap(audience => + audience?.mode === "roles" ? audience.roleIds : [], + ), + ), + ]; + + await db.transaction(async tx => { + const unusable = await unusableContentAccessRoleIds(tx, roleIds); + if (unusable.length > 0) throw new ContentAccessRoleError(unusable); + + await tx + .delete(core_content_access_rules) + .where( + and( + eq(core_content_access_rules.family, family), + eq(core_content_access_rules.scopeId, scopeId), + ), + ); + + const entries = CONTENT_ACCESS_ACTIONS.flatMap(action => { + const audience = overrides[action]; + + return audience ? [[action, audience] as const] : []; + }); + if (entries.length > 0) { + const inserted = await tx + .insert(core_content_access_rules) + .values( + entries.map(([action, audience]) => ({ + action, + family, + mode: audience.mode, + scopeId, + })), + ) + .returning({ + action: core_content_access_rules.action, + id: core_content_access_rules.id, + }); + + const audienceOf = new Map(entries); + const links = inserted.flatMap(rule => { + const audience = audienceOf.get(rule.action); + + return audience?.mode === "roles" + ? audience.roleIds.map(roleId => ({ roleId, ruleId: rule.id })) + : []; + }); + if (links.length > 0) { + await tx.insert(core_content_access_rule_roles).values(links); + } + } + + await within?.(tx); + }); +}; + +/** Removes every rule of the given scopes, when the scopes themselves go. */ +export const deleteContentAccessRules = async ( + db: ContentDatabase, + family: string, + scopeIds: readonly string[], +): Promise => { + if (scopeIds.length === 0) return; + await db + .delete(core_content_access_rules) + .where( + and( + eq(core_content_access_rules.family, family), + inArray(core_content_access_rules.scopeId, [...scopeIds]), + ), + ); +}; + +/** Whether any rule of the family exists at all, for diagnostics. */ +export const readContentAccessRulesOf = readRules; diff --git a/packages/vitnode/src/content/access/server/search.ts b/packages/vitnode/src/content/access/server/search.ts new file mode 100644 index 0000000000..f364591f23 --- /dev/null +++ b/packages/vitnode/src/content/access/server/search.ts @@ -0,0 +1,189 @@ +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, inArray } from "drizzle-orm"; + +import type { ContentId } from "../../ids"; +import type { AnyContentModel } from "../../server/model"; +import type { ContentAccessActor } from "../policy"; +import type { ContentAccessScopeRef } from "./source"; + +import { contentIdKey, parseContentId } from "../../ids"; +import { contentDefinitionOf } from "../../server/model"; +import { resolveContentAccessActor } from "./actor"; +import { + contentAccessAllowedScopes, + listContentAccessFamilies, + loadContentAccessFamilyState, +} from "./families"; +import { + contentAccessCondition, + contentAccessSourceOf, + contentRecordAccessScopes, +} from "./source"; + +/** How a scope is written into the search index: `family:scopeId`. */ +export const contentSearchAccessToken = ({ + family, + scopeId, +}: ContentAccessScopeRef): string => `${family}:${scopeId}`; + +const modelOf = (c: Context, itemType: string): AnyContentModel | undefined => + ((c.get("core")?.contentModels ?? []) as { model: AnyContentModel }[]).find( + entry => entry.model.definition.id === itemType, + )?.model; + +/** + * Stamps each document of an access-controlled content type with the scopes + * its record is filed in right now, read from the database. Every index write + * goes through here, whatever built the document. Server-only. + */ +export const withContentSearchAccessScopes = async < + TDoc extends { + accessScopes?: readonly string[]; + itemId: ContentId; + itemType: string; + }, +>( + c: Context, + docs: readonly TDoc[], +): Promise => { + const byType = new Map(); + for (const doc of docs) { + byType.set(doc.itemType, [...(byType.get(doc.itemType) ?? []), doc]); + } + + const scopesByKey = new Map(); + for (const [itemType, group] of byType) { + const model = modelOf(c, itemType); + if (!model || !contentAccessSourceOf(c, model)) continue; + + const scopes = await contentRecordAccessScopes( + c, + model, + group.map(doc => doc.itemId), + ); + for (const doc of group) { + const refs = scopes.get(contentIdKey(doc.itemId)); + // A record the database no longer has belongs to no scope anybody may + // read: the token names nothing. + scopesByKey.set( + `${itemType}|${contentIdKey(doc.itemId)}`, + refs + ? refs.map(contentSearchAccessToken) + : [`:missing:${contentIdKey(doc.itemId)}`], + ); + } + } + + return docs.map(doc => { + const tokens = scopesByKey.get( + `${doc.itemType}|${contentIdKey(doc.itemId)}`, + ); + + return tokens ? { ...doc, accessScopes: tokens } : doc; + }); +}; + +/** + * The scope tokens the viewer may read, for a search query: a document is + * kept only when every token it carries is in the list. `null` when nothing + * restricts the viewer (no access families, or a root role) - no filter at + * all. Server-only. + */ +export const contentSearchAllowedScopes = async ( + c: Context, + actor?: ContentAccessActor, +): Promise => { + const who = actor ?? (await resolveContentAccessActor(c)); + if (who.kind === "member" && who.root) return null; + + const families = listContentAccessFamilies(c); + if (families.length === 0) return null; + + const tokens: string[] = []; + let restricted = false; + for (const family of families) { + const allowed = await contentAccessAllowedScopes(c, { + action: "read", + actor: who, + family, + }); + if (!allowed.all) restricted = true; + // Every scope of a fully open family too: the filter is "all of a + // document's scopes are allowed", so an open family's scopes must be in it. + const ids = allowed.all + ? [...(await loadContentAccessFamilyState(c, family)).parents.keys()] + : allowed.ids; + for (const id of ids) tokens.push(`${family.id}:${id}`); + } + + return restricted ? tokens : null; +}; + +/** + * Which of `hits` the viewer may read, checked against the content tables now + * - not against what the index remembers. A document indexed before its + * record moved into a restricted scope, or before a role was taken away, is + * dropped here. Hits of item types without access rules pass. Server-only. + */ +export const verifyContentSearchHits = async < + THit extends { itemId: ContentId | string; itemType: string }, +>( + c: Context, + hits: readonly THit[], +): Promise<{ dropped: THit[]; kept: THit[] }> => { + const actor = await resolveContentAccessActor(c); + if (actor.kind === "member" && actor.root) + return { dropped: [], kept: [...hits] }; + + const readable = new Map>(); + const types = [...new Set(hits.map(hit => hit.itemType))]; + for (const itemType of types) { + const model = modelOf(c, itemType); + if (!model || !contentAccessSourceOf(c, model)) continue; + + const condition = await contentAccessCondition(c, model, "read", actor); + const ids = hits + .filter(hit => hit.itemType === itemType) + .flatMap(hit => { + const id = parseContentId( + contentDefinitionOf(model).idStrategy, + hit.itemId, + ); + + return id === null ? [] : [id]; + }); + if (!condition) { + readable.set(itemType, new Set(ids.map(id => contentIdKey(id)))); + continue; + } + const columns = model.columns as Record; + const table: PgTable = model.table; + const rows = + ids.length === 0 + ? [] + : await c + .get("db") + .select({ id: columns.id }) + .from(table) + .where(and(inArray(columns.id, ids), condition)); + readable.set( + itemType, + new Set(rows.map(row => contentIdKey(row.id as ContentId))), + ); + } + + const kept: THit[] = []; + const dropped: THit[] = []; + for (const hit of hits) { + const allowed = readable.get(hit.itemType); + if (!allowed || allowed.has(contentIdKey(hit.itemId))) { + kept.push(hit); + } else { + dropped.push(hit); + } + } + + return { dropped, kept }; +}; diff --git a/packages/vitnode/src/content/access/server/source.ts b/packages/vitnode/src/content/access/server/source.ts new file mode 100644 index 0000000000..cee5cdfdea --- /dev/null +++ b/packages/vitnode/src/content/access/server/source.ts @@ -0,0 +1,503 @@ +import type { SQL } from "drizzle-orm"; +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { + and, + eq, + inArray, + isNull, + not, + notInArray, + or, + sql, +} from "drizzle-orm"; + +import type { ContentId } from "../../ids"; +import type { AnyContentModel } from "../../server/model"; +import type { AnyContentTypeDefinition } from "../../types"; +import type { ContentAccessAction, ContentAccessActor } from "../policy"; +import type { ContentAccessFamily } from "./families"; + +import { contentContainerFieldOf } from "../../containers/association"; +import { contentIdKey } from "../../ids"; +import { contentSearchContextSource } from "../../search"; +import { contentDefinitionOf } from "../../server/model"; +import { resolveContentAccessActor } from "./actor"; +import { + contentAccessAllowedScopes, + isContentAccessFamilyOpen, + listContentAccessFamilies, +} from "./families"; + +/** + * How a content type's records map to access scopes: + * + * - `container` - its own `field.container()` of an access-enabled collection; + * - `scopes` - `access.from`, a relation to a scope content type, where a + * record must be allowed by every scope it points at; + * - `parent` - a relation to a record that has access of its own (a reply's + * thread root, a comment's article), whose scopes it shares. + */ +export type ContentAccessSource = + | { + column: PgColumn; + family: ContentAccessFamily; + field: string; + kind: "container"; + } + | { + column: PgColumn; + field: string; + kind: "parent"; + parent: AnyContentModel; + } + | { + column?: PgColumn; + family: ContentAccessFamily; + field: string; + junction?: { itemId: PgColumn; relatedItemId: PgColumn; table: PgTable }; + kind: "scopes"; + }; + +/** A scope one record belongs to. */ +export interface ContentAccessScopeRef { + family: string; + scopeId: string; +} + +const modelsOf = (c: Context): { model: AnyContentModel; pluginId: string }[] => + c.get("core")?.contentModels ?? []; + +const modelById = (c: Context, id: string): AnyContentModel | undefined => + modelsOf(c).find(entry => entry.model.definition.id === id)?.model; + +/** The relation fields that make a record answer a parent, in priority order. */ +const parentFieldsOf = ( + definition: AnyContentTypeDefinition, +): { field: string; target: AnyContentTypeDefinition }[] => { + const candidates: { field: string; target: AnyContentTypeDefinition }[] = []; + const add = (field: string) => { + const descriptor = definition.fields[field]; + if ( + descriptor?.kind !== "relation" || + descriptor.multiple || + candidates.some(entry => entry.field === field) + ) { + return; + } + try { + const target = descriptor.target(); + if (target.id !== definition.id) candidates.push({ field, target }); + } catch { + // A relation whose target cannot load is no parent. + } + }; + + if (definition.thread.enabled) add(definition.thread.rootField); + for (const [name, descriptor] of Object.entries(definition.fields)) { + if ( + descriptor.kind === "relation" && + (descriptor as { container?: boolean }).container === true + ) { + add(name); + } + } + const context = contentSearchContextSource(definition); + if (context?.kind === "relation") add(context.field); + + return candidates; +}; + +// Per registry, then per model: the answer depends on which families the +// running app registered, and tests build more than one app per process. +const sources = new WeakMap< + object, + WeakMap +>(); +const NO_REGISTRY = {}; + +const sourceCacheOf = ( + c: Context, +): WeakMap => { + const core = (c.get("core") as object | undefined) ?? NO_REGISTRY; + let cache = sources.get(core); + if (!cache) { + cache = new WeakMap(); + sources.set(core, cache); + } + + return cache; +}; + +/** + * The access source of one model, or `null` when its records belong to no + * access scope. Static for the life of the process. + */ +export const contentAccessSourceOf = ( + c: Context, + model: AnyContentModel, + visiting: ReadonlySet = new Set(), +): ContentAccessSource | null => { + const cache = sourceCacheOf(c); + if (cache.has(model)) return cache.get(model) ?? null; + + const definition = contentDefinitionOf(model); + const columns = model.columns as Record; + const families = listContentAccessFamilies(c); + const resolve = (): ContentAccessSource | null => { + const from = definition.access?.from; + if (from) { + const descriptor = definition.fields[from]; + const target = + descriptor?.kind === "relation" ? descriptor.target() : undefined; + const family = families.find( + candidate => + candidate.kind === "contentType" && candidate.id === target?.id, + ); + if (!family) return null; + + const junction = ( + model.advancedTables.junctions as unknown as Record< + string, + Record | undefined + > + )[from]; + + return junction + ? { + family, + field: from, + junction: { + itemId: junction.itemId as PgColumn, + relatedItemId: junction.relatedItemId as PgColumn, + table: junction as unknown as PgTable, + }, + kind: "scopes", + } + : { column: columns[from], family, field: from, kind: "scopes" }; + } + + const container = contentContainerFieldOf(definition); + if (container) { + const family = families.find( + candidate => + candidate.kind === "container" && + candidate.id === container.field.definition.id, + ); + const column = columns[container.name]; + if (family && column) { + return { column, family, field: container.name, kind: "container" }; + } + } + + const next = new Set(visiting).add(definition.id); + for (const { field, target } of parentFieldsOf(definition)) { + if (next.has(target.id)) continue; + const parent = modelById(c, target.id); + const column = columns[field]; + if (!parent || !column) continue; + if (contentAccessSourceOf(c, parent, next)) { + return { column, field, kind: "parent", parent }; + } + } + + return null; + }; + + const source = resolve(); + // Only cache a complete answer; a call mid-cycle may be partial. + if (visiting.size === 0) cache.set(model, source); + + return source; +}; + +/** The family at the end of a source's parent chain. */ +export const contentAccessFamilyOf = ( + c: Context, + model: AnyContentModel, +): ContentAccessFamily | null => { + const source = contentAccessSourceOf(c, model); + if (!source) return null; + if (source.kind === "parent") return contentAccessFamilyOf(c, source.parent); + + return source.family; +}; + +/** + * Whether no access rule can change who reads this content type: it has no + * access source, or its family is open to everyone. Only then may a public + * response for it go into a cache shared by every viewer. + */ +export const isContentAccessOpen = async ( + c: Context, + model: AnyContentModel, +): Promise => { + const family = contentAccessFamilyOf(c, model); + + return !family || (await isContentAccessFamilyOpen(c, family)); +}; + +const NOTHING: SQL = sql`false`; + +const scopeIdValues = ( + ids: readonly string[], + numeric: boolean, +): (number | string)[] => + numeric ? ids.map(Number).filter(id => Number.isSafeInteger(id)) : [...ids]; + +const isNumericColumn = (column: PgColumn): boolean => + column.columnType === "PgInteger" || + column.columnType === "PgSerial" || + column.dataType === "number"; + +/** + * The `WHERE` condition that keeps only the records of `model` that `actor` + * may perform `action` on, or `undefined` when every record qualifies. Built + * from the scopes' current rules and tree, so it is the same check whatever + * reads the table: lists before pagination, single reads, counts and joins. + * Server-only. + */ +export const contentAccessCondition = async ( + c: Context, + model: AnyContentModel, + action: ContentAccessAction, + actor?: ContentAccessActor, +): Promise => { + const source = contentAccessSourceOf(c, model); + if (!source) return undefined; + const who = actor ?? (await resolveContentAccessActor(c)); + if (who.kind === "member" && who.root) return undefined; + + if (source.kind === "parent") { + const parentCondition = await contentAccessCondition( + c, + source.parent, + action, + who, + ); + if (!parentCondition) return undefined; + + const parentColumns = source.parent.columns as Record; + const parentTable: PgTable = source.parent.table; + + return or( + isNull(source.column), + inArray( + source.column, + c + .get("db") + .select({ id: parentColumns.id }) + .from(parentTable) + .where(parentCondition), + ), + ); + } + + const allowed = await contentAccessAllowedScopes(c, { + action, + actor: who, + family: source.family, + }); + if (allowed.all) return undefined; + + if (source.kind === "scopes" && source.junction) { + const { itemId, relatedItemId, table } = source.junction; + const columns = model.columns as Record; + const values = scopeIdValues(allowed.ids, isNumericColumn(relatedItemId)); + const blocked = + values.length === 0 + ? undefined + : notInArray(relatedItemId, values as never[]); + + // Allowed by every scope it points at: no junction row names a scope + // outside the allowed set. + return not( + sql`exists (select 1 from ${table} where ${and( + eq(itemId, columns.id), + blocked, + )})`, + ); + } + + const column = source.column; + if (!column) return NOTHING; + const values = scopeIdValues(allowed.ids, isNumericColumn(column)); + + return values.length === 0 + ? isNull(column) + : or(isNull(column), inArray(column, values as never[])); +}; + +/** + * The scopes each of `ids` currently belongs to, read from the database. A + * record missing from the result does not exist; one with an empty list has + * no scope and is open. + */ +export const contentRecordAccessScopes = async ( + c: Context, + model: AnyContentModel, + ids: readonly ContentId[], +): Promise> => { + const result = new Map(); + if (ids.length === 0) return result; + + const source = contentAccessSourceOf(c, model); + const columns = model.columns as Record; + const table: PgTable = model.table; + const db = c.get("db"); + + if (!source) { + const rows = await db + .select({ id: columns.id }) + .from(table) + .where(inArray(columns.id, [...ids])); + for (const row of rows) result.set(contentIdKey(row.id as ContentId), []); + + return result; + } + + if (source.kind === "scopes" && source.junction) { + const rows = await db + .select({ id: columns.id }) + .from(table) + .where(inArray(columns.id, [...ids])); + for (const row of rows) result.set(contentIdKey(row.id as ContentId), []); + + const links = await db + .select({ + itemId: source.junction.itemId, + relatedItemId: source.junction.relatedItemId, + }) + .from(source.junction.table) + .where(inArray(source.junction.itemId, [...ids])); + for (const link of links) { + const key = contentIdKey(link.itemId as ContentId); + result.get(key)?.push({ + family: source.family.id, + scopeId: String(link.relatedItemId), + }); + } + + return result; + } + + const column = source.column; + if (!column) return result; + const rows = await db + .select({ id: columns.id, value: column }) + .from(table) + .where(inArray(columns.id, [...ids])); + + if (source.kind === "parent") { + const parentIds = [ + ...new Map( + rows + .map(row => row.value) + .filter( + (value): value is number | string => + typeof value === "number" || typeof value === "string", + ) + .map(value => [contentIdKey(value), value]), + ).values(), + ]; + const parents = await contentRecordAccessScopes( + c, + source.parent, + parentIds, + ); + for (const row of rows) { + const value = row.value; + const key = contentIdKey(row.id as ContentId); + if (value === null || value === undefined) { + result.set(key, []); + continue; + } + const scopes = parents.get(contentIdKey(value as ContentId)); + // A parent that no longer exists resolves to nothing - never to "open". + result.set( + key, + scopes ?? [ + { + family: "", + scopeId: `missing:${contentIdKey(value as ContentId)}`, + }, + ], + ); + } + + return result; + } + + for (const row of rows) { + const value = row.value; + result.set( + contentIdKey(row.id as ContentId), + value === null || value === undefined + ? [] + : [ + { + family: source.family.id, + scopeId: contentIdKey(value as ContentId), + }, + ], + ); + } + + return result; +}; + +/** + * The scopes a record would belong to if `values` were written: the container + * or scope ids in the payload, or the scopes of the parent it names. The + * caller passes values it already validated; nothing here trusts a client's + * claim about scopes beyond the ids, which are resolved on the server. + */ +export const contentAccessScopesForValues = async ( + c: Context, + model: AnyContentModel, + values: Record, +): Promise => { + const source = contentAccessSourceOf(c, model); + if (!source) return []; + + const value = values[source.field]; + if (value === undefined || value === null) return []; + + if (source.kind === "parent") { + if (typeof value !== "number" && typeof value !== "string") return null; + const parents = await contentRecordAccessScopes(c, source.parent, [value]); + + return parents.get(contentIdKey(value)) ?? null; + } + + const ids = Array.isArray(value) ? value : [value]; + + return ids.map(id => ({ + family: source.family.id, + scopeId: String( + typeof id === "object" && id !== null && "id" in id + ? (id as { id: unknown }).id + : id, + ), + })); +}; + +/** + * {@link contentAccessCondition} for a content type by its definition, through + * the model the app registered for it. The public read services call this on + * every read. + */ +export const contentPublicAccessCondition = async ( + c: Context, + definition: Pick, + { + action = "read", + actor, + }: { action?: ContentAccessAction; actor?: ContentAccessActor } = {}, +): Promise => { + const model = modelById(c, definition.id); + if (!model) return undefined; + + return await contentAccessCondition(c, model, action, actor); +}; diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index fa218feda2..c2e793dabd 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -77,6 +77,11 @@ export interface ContentFormFieldSpec { targetIdStrategy?: ContentIdStrategy; /** `richText` fields only: the editor accepts image uploads. */ uploads?: true; + /** + * `richText` fields with `uploads`: the content type the uploads belong to, + * so one with access rules stores them privately. + */ + uploadsFor?: string; } export interface ContentFormSectionSpec { @@ -313,9 +318,18 @@ export const buildContentFormSpec = ({ // One form, shared and localized fields alike, in the order they were // declared. Where a value is *stored* is settled by `spec.localized` on the // way back out - it is not a reason to split the screen in two. - fields: formFieldNames.map(name => - projectFormField(name, fields[name], labelEnum, labelField), - ), + fields: formFieldNames.map(name => { + const projected = projectFormField( + name, + fields[name], + labelEnum, + labelField, + ); + + return projected.uploads + ? { ...projected, uploadsFor: definition.id } + : projected; + }), sections: definition.admin.form.sections.flatMap(section => { const sectionFields = section.fields.filter(name => rendered.has(name)); if (sectionFields.length === 0) return []; diff --git a/packages/vitnode/src/content/approvals/server/follow-container.ts b/packages/vitnode/src/content/approvals/server/follow-container.ts new file mode 100644 index 0000000000..a3b6bd0756 --- /dev/null +++ b/packages/vitnode/src/content/approvals/server/follow-container.ts @@ -0,0 +1,80 @@ +import type { PgColumn } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, eq, inArray } from "drizzle-orm"; + +import type { ContentId } from "../../ids"; +import type { AnyContentModel } from "../../server/model"; +import type { AnyContentTypeDefinition } from "../../types"; + +import { core_content_submissions } from "../../../database/content-submissions"; +import { contentContainerSourceOf } from "../../containers/association"; +import { resolveContentRowContainer } from "../../containers/server/policy"; +import { contentDefinitionOf } from "../../server/model"; + +/** + * Keeps pending submissions filed where their record is now. A queue is + * scoped to the moderators of a container, so a topic moved while it waits + * for approval has to leave the old forum's queue and reach the new one's - + * its replies included. Runs after the move committed; a failure is logged, + * and deciding a submission re-checks the record's current container anyway. + */ +export const followContentSubmissionContainer = async ( + c: Context, + definition: AnyContentTypeDefinition, + itemId: ContentId, +): Promise => { + const models = ( + (c.get("core")?.contentModels ?? []) as { model: AnyContentModel }[] + ).map(entry => entry.model); + const model = models.find(entry => entry.definition.id === definition.id); + if (!model) return; + + const db = c.get("db"); + const columns = model.columns as Record; + const [row] = (await db + .select() + .from(model.table) + .where(eq(columns.id, itemId)) + .limit(1)) as Record[]; + if (!row) return; + const container = await resolveContentRowContainer(c, db, definition, row); + const containerId = container?.container.id ?? null; + + const pending = (contentTypeId: string, ids: readonly ContentId[]) => + and( + eq(core_content_submissions.contentTypeId, contentTypeId), + inArray(core_content_submissions.itemId, ids.map(String)), + eq(core_content_submissions.status, "pending"), + ); + + await db + .update(core_content_submissions) + .set({ containerId }) + .where(pending(definition.id, [itemId])); + + // Records filed through this one (`field.relation({ container: true })`). + for (const dependent of models) { + const source = contentContainerSourceOf(contentDefinitionOf(dependent)); + if (source?.kind !== "relation" || source.target?.id !== definition.id) { + continue; + } + const dependentColumns = dependent.columns as Record; + const column = dependentColumns[source.name]; + if (!column) continue; + const rows = await db + .select({ id: dependentColumns.id }) + .from(dependent.table) + .where(eq(column, itemId)); + if (rows.length === 0) continue; + await db + .update(core_content_submissions) + .set({ containerId }) + .where( + pending( + contentDefinitionOf(dependent).id, + rows.map(entry => entry.id as ContentId), + ), + ); + } +}; diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts index 707f1d8dab..c4eefbbd17 100644 --- a/packages/vitnode/src/content/cache.ts +++ b/packages/vitnode/src/content/cache.ts @@ -76,6 +76,14 @@ export const contentDeliverySitemapTag = ( locale?: string, ): string => tag(contentTypeId, "sitemap", ...localeParts(locale)); +/** + * Every public read that an access scope family decides: the family's own + * scopes and every record filed in them. Expired whenever the family's rules + * or its tree change. + */ +export const contentAccessTag = (familyId: string): string => + tag(familyId, "access"); + /** Every public read of a content container definition's containers. */ export const contentContainerTag = (definitionId: string): string => tag(definitionId, "containers"); diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 64bfa4ab09..987935fd65 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -273,6 +273,8 @@ export const CONTENT_PUBLIC_WRITABLE_KINDS = [ * use, and `invalid_json` a body that is not JSON. */ export const CONTENT_PUBLIC_WRITE_ISSUE_CODES = { + /** The scope's access rules keep this member from reading or replying there. */ + accessDenied: "access_denied", editWindowClosed: "edit_window_closed", invalidJson: "invalid_json", moderatorsOnly: "moderators_only", diff --git a/packages/vitnode/src/content/containers/define.ts b/packages/vitnode/src/content/containers/define.ts index 5da837bf0e..b36e26543e 100644 --- a/packages/vitnode/src/content/containers/define.ts +++ b/packages/vitnode/src/content/containers/define.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import type { ContentAccessScopeInput } from "../access/policy"; import type { ContentFieldDescriptor, ContentFieldMap } from "../types"; import type { ContentContainerPoliciesInput } from "./policies"; import type { @@ -12,6 +13,7 @@ import type { ContentContainerSchemas, } from "./types"; +import { resolveContentAccessScopeInput } from "../access/policy"; import { CONTENT_ADMIN_PATH_SEGMENT_PATTERN, CONTENT_FIELD_NAME_PATTERN, @@ -361,6 +363,7 @@ export const defineContentContainer = < | false | undefined = undefined, >({ + access, admin, fields = {} as TFields, hierarchy, @@ -369,6 +372,20 @@ export const defineContentContainer = < policies, publicApi, }: { + /** + * Per-container access rules: who may see a container (`view`), read what is + * filed in it (`read`), reply to it (`comment`) and download its protected + * attachments (`downloadAttachments`). Containers inherit from their parent + * and never open up what a parent hides. `true` starts every action open to + * everyone; `defaults` sets `{ mode: "members" }` for some. Omit it and the + * collection has no access rules at all. + * + * Only turn it on when every custom route of the plugin that reads content + * filed here goes through the Content Engine's public services or + * `createContentAccess(c)` - a route that queries the table directly would + * ignore the rules. + */ + access?: boolean | ContentAccessScopeInput; admin?: ContentContainerAdminInput; fields?: TFields; hierarchy?: ContentContainerHierarchyInput; @@ -409,7 +426,15 @@ export const defineContentContainer = < const resolvedPublicApi = resolvePublicApi(id, fields, publicApi); + let resolvedAccess; + try { + resolvedAccess = resolveContentAccessScopeInput(access); + } catch (error) { + return fail(id, (error as Error).message); + } + return { + access: resolvedAccess, admin: resolveAdmin(id, admin), fields, hierarchy: resolveHierarchy(id, hierarchy), diff --git a/packages/vitnode/src/content/containers/server/admin-routes.ts b/packages/vitnode/src/content/containers/server/admin-routes.ts index aeb5e2a55e..da6683aa3e 100644 --- a/packages/vitnode/src/content/containers/server/admin-routes.ts +++ b/packages/vitnode/src/content/containers/server/admin-routes.ts @@ -14,6 +14,7 @@ import type { import type { ContentContainerUsageCheck } from "./service"; import { buildRoute } from "../../../api/lib/route"; +import { buildContentAccessAdminRoutes } from "../../access/server/admin-routes"; import { CONTENT_PERMISSIONS } from "../../const"; import { CONTENT_CONTAINER_ERROR_CODES } from "../const"; import { ContentContainerError } from "../errors"; @@ -257,8 +258,16 @@ export const buildContentContainerAdminRoutes = < }, }); + const access = definition.access.enabled + ? buildContentAccessAdminRoutes({ + familyId: definition.id, + permissionModule: module, + pluginId, + }) + : []; + if (!definition.policies.enabled) { - return [list, create, update, move, remove]; + return [list, create, update, move, remove, ...access]; } const policies = buildRoute({ @@ -337,5 +346,14 @@ export const buildContentContainerAdminRoutes = < }, }); - return [list, create, update, move, remove, policies, updatePolicies]; + return [ + list, + create, + update, + move, + remove, + policies, + updatePolicies, + ...access, + ]; }; diff --git a/packages/vitnode/src/content/containers/server/public-routes.test.ts b/packages/vitnode/src/content/containers/server/public-routes.test.ts index c0a95b43c0..bd0d260842 100644 --- a/packages/vitnode/src/content/containers/server/public-routes.test.ts +++ b/packages/vitnode/src/content/containers/server/public-routes.test.ts @@ -5,6 +5,11 @@ import { HTTPException } from "hono/http-exception"; import { describe, expect, it } from "vitest"; import { core_content_containers } from "@/database/content-containers"; +import { + BOARD as ACCESS_BOARD, + createAccessWorld, + USER, +} from "@/tests/content-access"; import { testBoardContainers } from "@/tests/content-fixtures"; import { createMemoryDb } from "@/tests/memory-db"; @@ -148,7 +153,14 @@ describe("GET /{idOrSlug}", () => { children: PublicContainer[]; container: PublicContainer; }; + // A collection without access rules answers every flag open. expect(body.container).toEqual({ + access: { + canComment: true, + canDownloadAttachments: true, + canRead: true, + canView: true, + }, depth: 2, id: BOARD.NEWS, metadata: { color: "#ffffff", description: "About news" }, @@ -185,3 +197,70 @@ describe("private metadata", () => { }, ); }); + +describe("with access rules", () => { + it.each([ + [ + undefined, + [ACCESS_BOARD.OPEN, ACCESS_BOARD.OPEN_CHILD, ACCESS_BOARD.READERS], + ], + [ + USER.MEMBER, + [ + ACCESS_BOARD.OPEN, + ACCESS_BOARD.OPEN_CHILD, + ACCESS_BOARD.MEMBERS, + ACCESS_BOARD.READERS, + ], + ], + [ + USER.VIP, + [ + ACCESS_BOARD.OPEN, + ACCESS_BOARD.OPEN_CHILD, + ACCESS_BOARD.MEMBERS, + ACCESS_BOARD.VIP, + ACCESS_BOARD.READERS, + ], + ], + ])("lists only the containers user %s may see", async (user, expected) => { + const world = await createAccessWorld(); + + const body = (await ( + await world.send("GET", "/boards", { user }) + ).json()) as { + items: PublicContainer[]; + }; + + expect(ids(body.items)).toEqual(expected); + }); + + it("says what the viewer may do in each container", async () => { + const world = await createAccessWorld(); + + const body = (await (await world.send("GET", "/boards")).json()) as { + items: (PublicContainer & { access: Record })[]; + }; + + expect( + body.items.find(item => item.id === ACCESS_BOARD.READERS)?.access, + ).toEqual({ + canComment: false, + canDownloadAttachments: false, + canRead: false, + canView: true, + }); + }); + + it.each([String(ACCESS_BOARD.VIP), `board-${ACCESS_BOARD.VIP}`])( + "is a 404 for %s, a container the viewer may not see", + async segment => { + const world = await createAccessWorld(); + + expect( + (await world.send("GET", `/boards/${segment}`, { user: USER.MEMBER })) + .status, + ).toBe(404); + }, + ); +}); diff --git a/packages/vitnode/src/content/containers/server/public-routes.ts b/packages/vitnode/src/content/containers/server/public-routes.ts index 1de5014f59..1711939d97 100644 --- a/packages/vitnode/src/content/containers/server/public-routes.ts +++ b/packages/vitnode/src/content/containers/server/public-routes.ts @@ -1,6 +1,7 @@ import { z } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; +import type { ContentAccessFlags } from "../../access/policy"; import type { AnyContentContainerDefinition, ContentContainerPublicRecord, @@ -8,7 +9,17 @@ import type { import { setPageCacheTags } from "../../../api/lib/page-cache"; import { buildRoute } from "../../../api/lib/route"; -import { contentContainerTag } from "../../cache"; +import { + CONTENT_ACCESS_OPEN_FLAGS, + zodContentAccessFlags, +} from "../../access/policy"; +import { contentAccessFlagsForScopes } from "../../access/server/decide"; +import { + findContentAccessFamily, + isContentAccessFamilyOpen, +} from "../../access/server/families"; +import { CONTENT_ACCESS_PRIVATE_HEADERS } from "../../access/server/http"; +import { contentAccessTag, contentContainerTag } from "../../cache"; import { CONTENT_CONTAINER_SLUG_MAX_LENGTH } from "../const"; import { contentContainerAncestors } from "../tree"; import { createContentContainerService } from "./service"; @@ -26,12 +37,83 @@ export const buildContentContainerPublicRoutes = < definition: TDefinition, { pluginId }: { pluginId: P }, ) => { - const record = definition.schemas.publicRecord as unknown as z.ZodType< - ContentContainerPublicRecord + // Every container says what the viewer may do in it - the frontend shows + // "sign in to read" from this, the server still decides. Without access + // rules every flag is `true`. + const record = definition.schemas.publicRecord.extend({ + access: zodContentAccessFlags, + }) as unknown as z.ZodType< + ContentContainerPublicRecord & { + access: ContentAccessFlags; + } >; const service = (c: Parameters[0]) => createContentContainerService(c, { definition, pluginId }); + type Context = Parameters[0]; + type Item = Awaited["list"]>>[number]; + + /** + * Every container the viewer may see, each with its flags; a container + * inside a hidden one is hidden too. `shareable` when no rule of the + * collection keeps anybody out, so the answer is everybody's. + */ + const visibleContainers = async ( + c: Context, + all: Item[], + ): Promise<{ + flags: Map; + items: Item[]; + shareable: boolean; + }> => { + const family = definition.access.enabled + ? findContentAccessFamily(c, definition.id) + : undefined; + if (!family) { + return { + flags: new Map(all.map(item => [item.id, CONTENT_ACCESS_OPEN_FLAGS])), + items: all, + shareable: true, + }; + } + + const flags = new Map(); + for (const item of all) { + const { denied: _denied, ...decided } = await contentAccessFlagsForScopes( + c, + { scopes: [{ family: family.id, scopeId: String(item.id) }] }, + ); + flags.set(item.id, decided); + } + + return { + flags, + items: all.filter(item => flags.get(item.id)?.canView === true), + shareable: await isContentAccessFamilyOpen(c, family), + }; + }; + + const withAccess = + ( + containers: ReturnType, + flags: Map, + ) => + (item: Item) => ({ + ...containers.toPublic(item), + access: flags.get(item.id) ?? CONTENT_ACCESS_OPEN_FLAGS, + }); + + const respond = (c: Context, body: T, shareable: boolean) => { + if (!shareable) return c.json(body, 200, CONTENT_ACCESS_PRIVATE_HEADERS); + + setPageCacheTags(c, [ + contentContainerTag(definition.id), + ...(definition.access.enabled ? [contentAccessTag(definition.id)] : []), + ]); + + return c.json(body, 200); + }; + const list = buildRoute({ pluginId, route: { @@ -61,7 +143,11 @@ export const buildContentContainerPublicRoutes = < handler: async c => { const { parent } = c.req.valid("query"); const containers = service(c); - const all = await containers.list(); + const { + flags, + items: all, + shareable, + } = await visibleContainers(c, await containers.list()); let items = all; if (parent === "root") { @@ -73,9 +159,11 @@ export const buildContentContainerPublicRoutes = < items = all.filter(item => item.parentId === parent); } - setPageCacheTags(c, [contentContainerTag(definition.id)]); - - return c.json({ items: items.map(containers.toPublic) }, 200); + return respond( + c, + { items: items.map(withAccess(containers, flags)) }, + shareable, + ); }, }); @@ -109,7 +197,13 @@ export const buildContentContainerPublicRoutes = < handler: async c => { const { idOrSlug } = c.req.valid("param"); const containers = service(c); - const all = await containers.list(); + const { + flags, + items: all, + shareable, + } = await visibleContainers(c, await containers.list()); + // A container the viewer may not see is not found, like one that never + // existed - its slug and id answer the same. const container = NUMERIC_ID.test(idOrSlug) ? all.find(item => item.id === Number(idOrSlug)) : all.find(item => item.slug === idOrSlug); @@ -118,18 +212,18 @@ export const buildContentContainerPublicRoutes = < } const ancestors = contentContainerAncestors(all, container.id); + const toPublic = withAccess(containers, flags); - setPageCacheTags(c, [contentContainerTag(definition.id)]); - - return c.json( + return respond( + c, { - ancestors: ancestors.map(containers.toPublic), + ancestors: ancestors.map(toPublic), children: all .filter(item => item.parentId === container.id) - .map(containers.toPublic), - container: containers.toPublic(container), + .map(toPublic), + container: toPublic(container), }, - 200, + shareable, ); }, }); diff --git a/packages/vitnode/src/content/containers/server/service.ts b/packages/vitnode/src/content/containers/server/service.ts index 9a864b3f9f..153e0b05dc 100644 --- a/packages/vitnode/src/content/containers/server/service.ts +++ b/packages/vitnode/src/content/containers/server/service.ts @@ -23,6 +23,8 @@ import type { import { core_content_containers } from "../../../database/content-containers"; import { invalidatePageCacheTags } from "../../../page-cache/store.server"; +import { expireContentAccessFamily } from "../../access/server/invalidate"; +import { deleteContentAccessRules } from "../../access/server/rules"; import { contentContainerTag } from "../../cache"; import { contentDefinitionOf } from "../../server/model"; import { @@ -368,6 +370,14 @@ export const createContentContainerService = < await invalidatePageCacheTags([contentContainerTag(definitionId)], { mode, }); + // A move or a delete changes who may read the subtree, so everything the + // collection's access rules decided goes too. + if (definition.access.enabled) { + await expireContentAccessFamily(c, { + id: definitionId, + kind: "container", + }); + } }; const emit = async < @@ -536,6 +546,7 @@ export const createContentContainerService = < eq(core_content_containers.definitionId, definitionId), ), ); + await deleteContentAccessRules(tx, definitionId, [String(id)]); await applyPlacements(tx, plan.shifted); return row; diff --git a/packages/vitnode/src/content/containers/types.ts b/packages/vitnode/src/content/containers/types.ts index fb518b2dd7..ba514577f6 100644 --- a/packages/vitnode/src/content/containers/types.ts +++ b/packages/vitnode/src/content/containers/types.ts @@ -1,5 +1,6 @@ import type { z } from "zod"; +import type { ResolvedContentAccessScope } from "../access/policy"; import type { ContentBooleanField, ContentEnumField, @@ -96,6 +97,8 @@ export interface ContentContainerDefinition< TPublicEnabled extends boolean = boolean, TPublicPath extends string = string, > { + /** Per-container access rules, or the disabled default. */ + access: ResolvedContentAccessScope; admin: { navigation: { enabled: boolean }; path: string; diff --git a/packages/vitnode/src/content/define-access.ts b/packages/vitnode/src/content/define-access.ts new file mode 100644 index 0000000000..146147f8ee --- /dev/null +++ b/packages/vitnode/src/content/define-access.ts @@ -0,0 +1,86 @@ +import type { ContentAccessScopeInput } from "./access/policy"; +import type { ContentFieldMap } from "./types"; + +import { + DISABLED_CONTENT_ACCESS_SCOPE, + resolveContentAccessScopeInput, +} from "./access/policy"; +import { ContentEngineError } from "./errors"; + +/** + * The `access` option of a content type, in one of two shapes: + * + * - `{ scope: true | { defaults } }` - the content type's records are access + * scopes themselves (a blog category): each gets its own view, read, comment + * and download rules in the AdminCP. + * - `{ from: "categoryId" }` - a relation field pointing at a scope content + * type. A record is readable only when **every** scope it points at allows + * the action. + */ +export type ContentAccessInput = + | { from: TField } + | { scope: ContentAccessScopeInput | true }; + +export interface ResolvedContentTypeAccess { + /** The relation field whose targets are this type's scopes, or `null`. */ + from: null | string; + scope: typeof DISABLED_CONTENT_ACCESS_SCOPE; +} + +export const DISABLED_CONTENT_TYPE_ACCESS: ResolvedContentTypeAccess = { + from: null, + scope: DISABLED_CONTENT_ACCESS_SCOPE, +}; + +export const resolveContentTypeAccess = ( + id: string, + fields: ContentFieldMap, + access: ContentAccessInput | false | undefined, +): ResolvedContentTypeAccess => { + if (access === undefined || access === false) { + return DISABLED_CONTENT_TYPE_ACCESS; + } + + const fail = (message: string): never => { + throw new ContentEngineError(message, { contentTypeId: id }); + }; + + if (typeof access !== "object" || access === null || Array.isArray(access)) { + return fail( + 'access is `{ scope: true }`, `{ scope: { defaults } }` or `{ from: "" }`.', + ); + } + + const keys = Object.keys(access); + if (keys.length !== 1 || (keys[0] !== "scope" && keys[0] !== "from")) { + return fail( + "access takes exactly one of `scope` (records of this type are access scopes) or `from` (a relation to a scope content type).", + ); + } + + if ("scope" in access) { + try { + return { + from: null, + scope: resolveContentAccessScopeInput(access.scope), + }; + } catch (error) { + return fail((error as Error).message.replace(/^access/, "access.scope")); + } + } + + const name = access.from; + const field = fields[name]; + if (field?.kind !== "relation") { + return fail( + `access.from names "${name}", which is not a relation field of this content type.`, + ); + } + if (field.localized === true) { + return fail( + `access.from names the localized relation "${name}". Access scopes are shared by every language - use a shared relation.`, + ); + } + + return { from: name, scope: DISABLED_CONTENT_ACCESS_SCOPE }; +}; diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 3364b4fdb0..ad078430e5 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -1,3 +1,4 @@ +import type { ContentAccessInput } from "./define-access"; import type { ContentIdStrategy } from "./ids"; import type { AnyContentTypeDefinition, @@ -58,6 +59,7 @@ import { CONTENT_IDENTIFIER_MAX_LENGTH, CONTENT_TABLE_NAME_PATTERN, } from "./const"; +import { resolveContentTypeAccess } from "./define-access"; import { resolveAdmin } from "./define-admin"; import { resolveEditorial } from "./define-editorial"; import { @@ -301,6 +303,7 @@ export const defineContentType = < TPublicPeople extends ContentPublicPersonField = never, TPublicListField extends TPublicField = never, >({ + access, admin = {}, audit, delivery, @@ -321,6 +324,15 @@ export const defineContentType = < thread, visibility, }: { + /** + * Role-based access rules. `{ scope: true }` makes every record of this type + * an access scope with its own view, read, comment and download rules (a + * blog category); `{ from: "categoryId" }` files records under the scopes a + * relation points at, and a record is readable only when every one of them + * allows it. Records that answer a parent (thread replies, comments) inherit + * their parent's access without any option. Omit it and nothing changes. + */ + access?: ContentAccessInput | false; /** * How the generated AdminCP screens present the content type: list columns, * form layout, dialog or page mode, title field, URL path and the staff @@ -762,6 +774,7 @@ export const defineContentType = < ContentPublicPeopleResolved, ContentPublicListFieldsResolved > = { + access: resolveContentTypeAccess(id, fieldMap, access), admin: resolvedAdmin, advanced: resolvedAdvanced, audit: resolveContentAudit(id, audit, fieldNames), diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts index e6ae956a52..4f79c2b761 100644 --- a/packages/vitnode/src/content/errors.ts +++ b/packages/vitnode/src/content/errors.ts @@ -444,7 +444,7 @@ export class ContentPublicWriteDenied extends ContentEngineError { }: { contentTypeId: string; /** Why, when the reason alone would leave the member guessing. */ - issue?: "edit_window_closed" | "moderators_only" | null; + issue?: "access_denied" | "edit_window_closed" | "moderators_only" | null; reason: ContentPublicWriteDenial; }) { super(`Public write denied: ${reason}.`, { contentTypeId }); @@ -454,7 +454,11 @@ export class ContentPublicWriteDenied extends ContentEngineError { this.reason = reason; } - readonly issue: "edit_window_closed" | "moderators_only" | null; + readonly issue: + | "access_denied" + | "edit_window_closed" + | "moderators_only" + | null; readonly reason: ContentPublicWriteDenial; } diff --git a/packages/vitnode/src/content/files.ts b/packages/vitnode/src/content/files.ts index af017391ae..823f6b06a5 100644 --- a/packages/vitnode/src/content/files.ts +++ b/packages/vitnode/src/content/files.ts @@ -36,6 +36,12 @@ export interface ContentFileDescriptor { mimeType: null | string; /** The original file name. */ name: string; + /** + * `true` when the reader may not download this attachment: `name`, `size`, + * `mimeType` and `url` are then empty, and a page says only that there is + * one. + */ + restricted?: true; /** Bytes. */ size: number; /** Where the browser downloads or displays the file from. */ @@ -51,6 +57,7 @@ export const zodContentFileDescriptor = z.strictObject({ id: z.number().int().positive(), mimeType: z.string().nullable(), name: z.string(), + restricted: z.literal(true).optional(), size: z.number().int().nonnegative(), url: z.string(), width: z.number().int().positive().optional(), diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index ce6c22ef24..16d5b38c96 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -1,3 +1,32 @@ +export { + CONTENT_ACCESS_ACTIONS, + CONTENT_ACCESS_MODES, + CONTENT_ACCESS_PREREQUISITES, + CONTENT_ACCESS_REQUIRED_CODE, +} from "./access/const"; +export { + CONTENT_ACCESS_OPEN_FLAGS, + contentAccessActionChain, + contentAccessAudienceMatches, + contentAccessRequirements, + decideContentAccess, + resolveContentAccessRules, + zodContentAccessAudience, + zodContentAccessFlags, + zodContentAccessOverrides, +} from "./access/policy"; +export type { + ContentAccessAction, + ContentAccessActor, + ContentAccessAudience, + ContentAccessDecision, + ContentAccessDefaultsInput, + ContentAccessFlags, + ContentAccessOverrides, + ContentAccessPolicy, + ContentAccessRequirement, + ContentAccessScopeInput, +} from "./access/policy"; export { contentContainerAdminHref, contentContainerI18nKeys, @@ -27,8 +56,8 @@ export type { RegisteredFrontendContentContainer, RegisteredFrontendContentType, } from "./admin/registry"; - export { resolveContentAdminRoute } from "./admin/route"; + export type { ContentAdminAction, ContentAdminRoute, @@ -74,6 +103,7 @@ export type { export { zodContentApprovalState } from "./approvals/schema"; export type { ContentApprovalState } from "./approvals/schema"; export { + contentAccessTag, contentContainerTag, contentDeliveryRedirectTag, contentDeliverySitemapTag, @@ -298,6 +328,7 @@ export type { ContentContainerUpdateInput, } from "./containers/types"; export { defineContentType } from "./define"; +export type { ContentAccessInput } from "./define-access"; export { contentDeliveryAlternateHref, contentDeliveryDisabled, diff --git a/packages/vitnode/src/content/rich-text/elements.ts b/packages/vitnode/src/content/rich-text/elements.ts index 8ad063396c..1ac485f538 100644 --- a/packages/vitnode/src/content/rich-text/elements.ts +++ b/packages/vitnode/src/content/rich-text/elements.ts @@ -296,6 +296,27 @@ const imageElement = (node: RichTextNode): null | RichTextElement => { }; const attachmentElement = (node: RichTextNode): null | RichTextElement => { + // Stripped by the server for a reader who may not download it: say only + // that there is one. + if (node.attrs?.restricted === true) { + return { + attrs: { class: "tiptap-attachment", "data-restricted": "true" }, + children: [ + { + attrs: { class: "tiptap-attachment-name" }, + children: ["An attachment is available."], + tag: "span", + }, + { + attrs: { class: "tiptap-attachment-meta" }, + children: ["You do not have permission to download it."], + tag: "span", + }, + ], + tag: "span", + }; + } + const href = sanitizeRichTextSrc(node.attrs?.src); if (href === null) return null; diff --git a/packages/vitnode/src/content/server/delivery-routes.ts b/packages/vitnode/src/content/server/delivery-routes.ts index b076ee2972..00a0feca00 100644 --- a/packages/vitnode/src/content/server/delivery-routes.ts +++ b/packages/vitnode/src/content/server/delivery-routes.ts @@ -5,10 +5,15 @@ import { HTTPException } from "hono/http-exception"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentDeliveryService } from "./delivery-service"; -import type { ContentModel } from "./model"; +import type { AnyContentModel, ContentModel } from "./model"; import { setPageCacheTags } from "../../api/lib/page-cache"; import { buildRoute } from "../../api/lib/route"; +import { + CONTENT_ACCESS_PRIVATE_HEADERS, + contentAccessPageCacheTags, + isContentAccessShareable, +} from "../access/server/http"; import { contentTypeName } from "../admin/labels"; import { CONTENT_DELIVERY_REDIRECT_STATUS, @@ -36,6 +41,7 @@ export const buildContentDeliveryRoutes = < { pluginId }: { pluginId: P }, ) => { const { definition } = model; + const accessModel = model as unknown as AnyContentModel; const name = contentTypeName(definition.id); const localized = definition.localization.enabled; @@ -171,11 +177,23 @@ export const buildContentDeliveryRoutes = < locale: resolved.locale, }); + // A resolution depends on what this viewer may read once any access + // rule applies, so it is then nobody else's. + const shareable = await isContentAccessShareable(c, accessModel); + if (!shareable) { + return c.json(resolution, 200, { + ...CONTENT_ACCESS_PRIVATE_HEADERS, + ...(resolution.type === "content" && resolution.hidden + ? { "X-Robots-Tag": "noindex, nofollow" } + : {}), + }); + } + if (resolution.type === "content" && !resolution.hidden) { - setPageCacheTags( - c, - contentRecordPageCacheTags(definition, resolution.itemId), - ); + setPageCacheTags(c, [ + ...contentRecordPageCacheTags(definition, resolution.itemId), + ...contentAccessPageCacheTags(c, accessModel), + ]); } return resolution.type === "content" && resolution.hidden @@ -219,7 +237,14 @@ export const buildContentDeliveryRoutes = < }); if (!metadata) throw notFound(name); - setPageCacheTags(c, contentRecordPageCacheTags(definition, id)); + if (!(await isContentAccessShareable(c, accessModel))) { + return c.json(metadata, 200, CONTENT_ACCESS_PRIVATE_HEADERS); + } + + setPageCacheTags(c, [ + ...contentRecordPageCacheTags(definition, id), + ...contentAccessPageCacheTags(c, accessModel), + ]); return c.json(metadata, 200); }, diff --git a/packages/vitnode/src/content/server/delivery-sitemap.ts b/packages/vitnode/src/content/server/delivery-sitemap.ts index bfa8e06912..afd0ed2148 100644 --- a/packages/vitnode/src/content/server/delivery-sitemap.ts +++ b/packages/vitnode/src/content/server/delivery-sitemap.ts @@ -11,6 +11,8 @@ import type { AnyContentTypeDefinition } from "../types"; import type { ContentDeliverySitemapArgs } from "./delivery-service"; import type { ContentModel } from "./model"; +import { CONTENT_ACCESS_GUEST } from "../access/server/actor"; +import { contentPublicAccessCondition } from "../access/server/source"; import { CONTENT_SITEMAP_DEFAULT_PAGE_SIZE, CONTENT_SITEMAP_MAX_URLS, @@ -95,6 +97,11 @@ export const readContentDeliverySitemapPage = async < const conditions: (SQL | undefined)[] = [ publishedCondition(base), + // A sitemap is public, so it lists only what a guest may read - whoever + // happens to be asking for it. + await contentPublicAccessCondition(c, definition, { + actor: CONTENT_ACCESS_GUEST, + }), cursor === null ? undefined : gt(columns.id, cursor), // `IS DISTINCT FROM TRUE`, which is the only spelling that agrees with the // metadata. `contentDeliveryRobots` reads `value !== true`, so a `noIndex` of diff --git a/packages/vitnode/src/content/server/emit.ts b/packages/vitnode/src/content/server/emit.ts index 8c23ec9d5f..ca1f882684 100644 --- a/packages/vitnode/src/content/server/emit.ts +++ b/packages/vitnode/src/content/server/emit.ts @@ -27,6 +27,8 @@ import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentPublicState } from "./page-cache"; +import { followContentSubmissionContainer } from "../approvals/server/follow-container"; +import { contentContainerFieldOf } from "../containers/association"; import { contentEventName } from "../events"; import { invalidateContentPages } from "./page-cache"; import { publishContentRefresh } from "./refresh/publish"; @@ -111,6 +113,29 @@ export const emitContentEvent = async ( ), ]); + // A record that moved to another container takes its pending approvals - and + // its replies' - to the new container's moderators. + const moved = contentContainerFieldOf(definition)?.name; + if ( + action === "updated" && + moved !== undefined && + "changedFields" in payload && + Array.isArray(payload.changedFields) && + (payload.changedFields as string[]).includes(moved) + ) { + await followContentSubmissionContainer( + c, + definition, + payload.contentId, + ).catch(async (error: unknown) => { + await c + .get("log") + ?.error( + `[content-approvals] ${definition.id}: pending submissions could not follow the record to its new container (${error instanceof Error ? error.message : String(error)}).`, + ); + }); + } + // After the caches above, so whoever reads again on this signal misses them. await publishContentRefresh(c, { action, diff --git a/packages/vitnode/src/content/server/files.ts b/packages/vitnode/src/content/server/files.ts index ae06fee6cb..327f6042e5 100644 --- a/packages/vitnode/src/content/server/files.ts +++ b/packages/vitnode/src/content/server/files.ts @@ -14,6 +14,10 @@ import type { ContentDatabase } from "./service"; import { core_files, core_files_alt } from "../../database/files"; import { parseImageDimensions } from "../../lib/api/upload"; +import { + contentPrivateAdminFileUrl, + protectContentPublicRowFiles, +} from "../access/server/files"; import { CONTENT_FILE_CODES } from "../const"; import { ContentInputError } from "../errors"; import { contentFileConstraints, validateContentFile } from "../files"; @@ -60,6 +64,7 @@ const fileSelection = { mimeType: core_files.mimeType, name: core_files.name, size: core_files.size, + visibility: core_files.visibility, }; interface ContentFileRow { @@ -70,6 +75,7 @@ interface ContentFileRow { mimeType: null | string; name: string; size: number; + visibility?: "private" | "public"; } interface ContentFileOriginRow extends ContentFileRow { @@ -78,7 +84,7 @@ interface ContentFileOriginRow extends ContentFileRow { const toDescriptor = ( row: ContentFileRow, - url: (key: string) => string, + url: (row: ContentFileRow) => string, ): ContentFileDescriptor => { const dimensions = parseImageDimensions(row.metadata); @@ -87,7 +93,7 @@ const toDescriptor = ( mimeType: row.mimeType, name: row.name, size: row.size, - url: url(row.key), + url: url(row), ...(row.alts && row.mimeType?.startsWith("image/") ? { alts: row.alts } : {}), @@ -117,7 +123,15 @@ const descriptorsByIdOf = ( ): Map => { const hasAdapter = !!c.get("core").storage?.adapter; const storage = c.get("storage"); - const url = (key: string): string => (hasAdapter ? storage.getUrl(key) : ""); + // A private file has no public URL. Here it is the AdminCP's own download + // route (staff only); public reads swap it for the content type's + // authorized route. + const url = (row: ContentFileRow): string => { + if (!hasAdapter) return ""; + if (row.visibility === "private") return contentPrivateAdminFileUrl(row.id); + + return storage.getUrl(row.key); + }; return new Map(rows.map(row => [row.id, toDescriptor(row, url)])); }; @@ -199,10 +213,27 @@ export const withContentRowFiles = async ( }); }; +/** + * The public rows with every file field turned into descriptors, then + * adjusted for the reader: private files point at the authorized download + * route, and attachments the reader may not download are stripped - in + * rich-text documents too. + */ export const resolveContentPublicRowFiles = async ( c: Context, definition: AnyContentTypeDefinition, rows: Record[], +): Promise[]> => + await protectContentPublicRowFiles( + c, + definition, + await describeContentPublicRowFiles(c, definition, rows), + ); + +const describeContentPublicRowFiles = async ( + c: Context, + definition: AnyContentTypeDefinition, + rows: Record[], ): Promise[]> => { if (!definition.publicApi.enabled || rows.length === 0) return rows; @@ -401,7 +432,9 @@ export const contentFileDescriptorFromUpload = ( mimeType: result.mimeType, name: result.name, size: result.size, - url: result.url, + // A private upload has no public URL; the AdminCP shows it through its own + // staff-only route. + url: result.url === "" ? contentPrivateAdminFileUrl(result.id) : result.url, ...(result.dimensions ? { height: result.dimensions.height, width: result.dimensions.width } : {}), diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index eab8f2574f..e3559b54f1 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -1,4 +1,48 @@ export { escapeLikePattern } from "../../lib/api/like-pattern"; +export { + CONTENT_ACCESS_GUEST, + loadContentAccessActors, + resolveContentAccessActor, +} from "../access/server/actor"; +export { buildContentAccessAdminRoutes } from "../access/server/admin-routes"; +export { + canAccessContentRecord, + contentAccessFlagsForScopes, + contentAccessRequirementsForScopes, + contentRecordAccessFlags, + createContentAccess, + decideContentAccessForScopes, +} from "../access/server/decide"; +export type { ContentAccess } from "../access/server/decide"; +export { + contentAccessAllowedScopes, + findContentAccessFamily, + isContentAccessFamilyOpen, + listContentAccessFamilies, +} from "../access/server/families"; +export type { + ContentAccessAllowedScopes, + ContentAccessFamily, +} from "../access/server/families"; +export { + contentModelsInAccessFamily, + expireContentAccessFamily, +} from "../access/server/invalidate"; +export { contentAccessScopeReport } from "../access/server/report"; +export type { ContentAccessScopeReport } from "../access/server/report"; +export { deleteContentAccessRules } from "../access/server/rules"; +export { + contentAccessCondition, + contentAccessFamilyOf, + contentAccessScopesForValues, + contentAccessSourceOf, + contentRecordAccessScopes, + isContentAccessOpen, +} from "../access/server/source"; +export type { + ContentAccessScopeRef, + ContentAccessSource, +} from "../access/server/source"; export { ContentSubmissionDecisionError, decideContentSubmission, diff --git a/packages/vitnode/src/content/server/localized-public-service.ts b/packages/vitnode/src/content/server/localized-public-service.ts index a91f6fc4b8..0f80391fae 100644 --- a/packages/vitnode/src/content/server/localized-public-service.ts +++ b/packages/vitnode/src/content/server/localized-public-service.ts @@ -14,6 +14,7 @@ import type { PaginationCursorColumn, PaginationCursorSelection, } from "../../api/lib/with-pagination"; +import type { ContentAccessActor } from "../access/policy"; import type { AnyContentTypeDefinition, ContentPublicSelect } from "../types"; import type { ContentAdvancedStore } from "./advanced-store"; import type { ContentLanguage } from "./language-resolver"; @@ -24,6 +25,7 @@ import type { } from "./public-service"; import { withPagination } from "../../api/lib/with-pagination"; +import { contentPublicAccessCondition } from "../access/server/source"; import { CONTENT_PUBLIC_DEFAULT_PAGE_SIZE, CONTENT_PUBLIC_MAX_PAGE_SIZE, @@ -406,9 +408,10 @@ export const createContentLocalizedPublicService = < resolved: ResolvedLocale, condition: SQL | undefined, { + actor, hidden = false, strict = false, - }: { hidden?: boolean; strict?: boolean } = {}, + }: { actor?: ContentAccessActor; hidden?: boolean; strict?: boolean } = {}, ): Promise | null> => { // A strict read must not fall back - `findBySlug`. Dropping the fallback // language makes the join inner and the visibility test single-armed, so @@ -424,6 +427,7 @@ export const createContentLocalizedPublicService = < contentPublicReadCondition(basePublication, { hidden }), visibleIn(scope), condition, + await contentPublicAccessCondition(c, definition, { actor }), ), { limit: 1 }, ); @@ -441,6 +445,7 @@ export const createContentLocalizedPublicService = < if (!resolved) return null; return await readOne(readerFor(options), resolved, eq(columns.id, id), { + actor: options?.actor, hidden: options?.hidden, }); }, @@ -466,7 +471,7 @@ export const createContentLocalizedPublicService = < projection, resolved, slugIn(resolved.requested.id), - { hidden: options?.hidden, strict: true }, + { actor: options?.actor, hidden: options?.hidden, strict: true }, ); if (row || !options?.fallback || !resolved.fallbackTo) return row; @@ -474,11 +479,12 @@ export const createContentLocalizedPublicService = < projection, resolved, slugIn(resolved.fallbackTo.id), - { hidden: options?.hidden }, + { actor: options?.actor, hidden: options?.hidden }, ); }, findMany: async ({ + actor, filters = {}, hidden, locale, @@ -507,6 +513,7 @@ export const createContentLocalizedPublicService = < ); const where = conditions( + await contentPublicAccessCondition(c, definition, { actor }), contentPublicListCondition({ advanced, columns, definition }, hidden), // The localized filter goes through the same visibility test as the read // itself, so a filter can only ever match the translation the reader diff --git a/packages/vitnode/src/content/server/notifications/access.ts b/packages/vitnode/src/content/server/notifications/access.ts index 1949a43a57..6a73ea0533 100644 --- a/packages/vitnode/src/content/server/notifications/access.ts +++ b/packages/vitnode/src/content/server/notifications/access.ts @@ -8,14 +8,21 @@ import type { NotificationAccessArgs } from "../../../api/lib/notifications/regi import type { EnvVitNode } from "../../../api/middlewares/global.middleware"; import type { ContentSubmissionReviewScope } from "../../approvals/server/reviewers"; import type { AnyContentTypeDefinition } from "../../types"; +import type { AnyContentModel } from "../model"; import type { ContentNotificationAccessFilter } from "./define"; import { core_content_submissions } from "../../../database/content-submissions"; +import { loadContentAccessActors } from "../../access/server/actor"; +import { decideContentAccessForScopes } from "../../access/server/decide"; +import { + contentAccessSourceOf, + contentRecordAccessScopes, +} from "../../access/server/source"; import { filterContentSubmissionReviewers, loadContentSubmissionReviewScope, } from "../../approvals/server/reviewers"; -import { contentIdFromKey } from "../../ids"; +import { contentIdFromKey, contentIdKey, parseContentId } from "../../ids"; import { contentDefinitionOf, findContentModel } from "../model"; import { isContentBaseRowPublic } from "../visibility"; @@ -169,6 +176,48 @@ const isRootPublic = async ( return root !== null && isContentBaseRowPublic(root); }; +/** + * The recipients the record's access rules let read it now, with their roles + * read from the database for this check - a member who lost a role stops + * getting the notification, and stops seeing it in the inbox, at once. + */ +const readersOf = async ( + c: Context, + content: AnyContentTypeDefinition, + contentId: number | string, + userIds: number[], +): Promise => { + const model = ( + (c.get("core")?.contentModels ?? []) as { model: AnyContentModel }[] + ).find(entry => entry.model.definition.id === content.id)?.model; + if (!model || !contentAccessSourceOf(c, model)) return userIds; + + const itemId = parseContentId(content.idStrategy, contentId); + if (itemId === null) return []; + const scopes = (await contentRecordAccessScopes(c, model, [itemId])).get( + contentIdKey(itemId), + ); + if (!scopes) return []; + + const actors = await loadContentAccessActors(c, userIds); + const decisions = await Promise.all( + userIds.map(async userId => { + const actor = actors.get(userId); + if (!actor) return false; + + return ( + await decideContentAccessForScopes(c, { + action: "read", + actor, + scopes, + }) + ).allowed; + }), + ); + + return userIds.filter((_, index) => decisions[index]); +}; + /** * Builds a notification type's `access` hook from Content Engine state, for * notifications whose `data` carries {@link ContentNotificationRefs}. Core @@ -188,9 +237,10 @@ const isRootPublic = async ( * queue lists it in and the one the record is filed in today, read fresh * from the database. The record does not have to be public. * - * `filter` narrows the result further for plugin-specific rules. Content - * Engine reads are not role-based, so without it every recipient who passes - * the state checks may see the notification. Server-only. + * Either way, a recipient also has to be allowed to read the record under the + * access rules of its scopes (a forum, a blog category) - checked live, so a + * revoked role hides the notification too. `filter` narrows the result + * further for plugin-specific rules. Server-only. * * @example * ```ts @@ -276,6 +326,9 @@ export const contentNotificationAccess = return []; } + allowed = await readersOf(c, content, refs.contentId, allowed); + if (allowed.length === 0) return []; + return filter ? await filter({ c, diff --git a/packages/vitnode/src/content/server/permission-matrix.test.ts b/packages/vitnode/src/content/server/permission-matrix.test.ts index 5f0a1d9dc3..af632a2d66 100644 --- a/packages/vitnode/src/content/server/permission-matrix.test.ts +++ b/packages/vitnode/src/content/server/permission-matrix.test.ts @@ -43,6 +43,8 @@ const kitchenSink = defineContentType({ scheduling: true, }, liveEditing: true, + // Each record is an access scope, so the access editor's routes are audited. + access: { scope: true }, fields: { title: field.text({ localized: true, required: true, maxLength: 200 }), slug: field.slug({ localized: true, source: "title" }), @@ -267,6 +269,13 @@ describe("the generated permission matrix", () => { "POST /{id}/draft/discard": "can_edit", "POST /{id}/locks": "can_edit", "PUT /{id}/draft": "can_edit", + // The access editor: reading a scope's rules, previewing a change and + // counting its files only look; saving rules and moving files write. + "GET /{id}/access": "can_view", + "POST /{id}/access/preview": "can_view", + "PUT /{id}/access": "can_edit", + "GET /access/files": "can_view", + "POST /access/files/protect": "can_edit", }; it("gates every route on a staff permission", async () => { diff --git a/packages/vitnode/src/content/server/public-access.test.ts b/packages/vitnode/src/content/server/public-access.test.ts new file mode 100644 index 0000000000..13ea8cb43b --- /dev/null +++ b/packages/vitnode/src/content/server/public-access.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; + +import { + ACCESS_RULES, + BOARD, + createAccessWorld, + ROLE, + testAccessBoards, + TOPIC, + USER, +} from "@/tests/content-access"; + +import { CONTENT_ACCESS_REQUIRED_CODE } from "../access/const"; + +interface Page { + edges: { id: number }[]; + pageInfo: { count: number; totalCount: number }; +} + +describe("public lists", () => { + it.each([ + [undefined, [TOPIC.OPEN, TOPIC.OPEN_CHILD]], + [USER.MEMBER, [TOPIC.OPEN, TOPIC.MEMBERS, TOPIC.READERS, TOPIC.OPEN_CHILD]], + [USER.VIP, Object.values(TOPIC).sort((a, b) => a - b)], + ])("lists only what user %s may read", async (user, expected) => { + const world = await createAccessWorld(); + + const response = await world.send("GET", "/topics", { user }); + const body = (await response.json()) as Page; + + expect(body.edges.map(edge => edge.id)).toEqual(expected); + expect(body.pageInfo.totalCount).toBe(expected.length); + }); + + it("filters before paginating, so a page and its total never count the rest", async () => { + const world = await createAccessWorld(); + + const body = (await ( + await world.send("GET", "/topics?first=1") + ).json()) as Page; + + expect(body.edges.map(edge => edge.id)).toEqual([TOPIC.OPEN]); + expect(body.pageInfo).toMatchObject({ count: 1, totalCount: 2 }); + }); + + it.each([ + [BOARD.VIP, 404], + [BOARD.READERS, 403], + ])( + "answers a filter on board %i the guest cannot read with %i", + async (board, status) => { + const world = await createAccessWorld(); + + expect((await world.send("GET", `/topics?board=${board}`)).status).toBe( + status, + ); + }, + ); +}); + +describe("public detail", () => { + it.each([ + ["a record in a scope the guest may not see", TOPIC.VIP, undefined, 404], + ["a record that does not exist", 999, undefined, 404], + ["a record a member may read", TOPIC.READERS, USER.MEMBER, 200], + ])("answers %s with %i", async (_label, id, user, status) => { + const world = await createAccessWorld(); + + expect( + (await world.send("GET", `/topics/topic-${id}`, { user })).status, + ).toBe(status); + }); + + it("says only that access is required in a scope the guest may see but not read", async () => { + const world = await createAccessWorld(); + + const response = await world.send("GET", `/topics/topic-${TOPIC.READERS}`); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + accessRequired: true, + code: CONTENT_ACCESS_REQUIRED_CODE, + }); + }); +}); + +describe("caching", () => { + it.each([["/topics"], [`/topics/topic-${TOPIC.OPEN}`], ["/boards"]])( + "keeps GET %s out of shared caches once any scope is restricted", + async path => { + const world = await createAccessWorld(); + + const response = await world.send("GET", path); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + }, + ); + + it.each([["/topics"], [`/topics/topic-${TOPIC.OPEN}`], ["/boards"]])( + "lets GET %s be shared while nothing is restricted", + async path => { + const world = await createAccessWorld({ rules: {} }); + + const response = await world.send("GET", path); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBeNull(); + }, + ); +}); + +describe("replies", () => { + const rules = { + ...ACCESS_RULES, + [testAccessBoards.id]: { + ...ACCESS_RULES[testAccessBoards.id], + [BOARD.READERS]: { + comment: { mode: "roles" as const, roleIds: [ROLE.VIP] }, + read: { mode: "members" as const }, + }, + }, + }; + + it.each([ + ["a guest", undefined, TOPIC.OPEN, 401], + ["a member, in a thread they may not see", USER.MEMBER, TOPIC.VIP, 404], + ["a member, without the comment rule", USER.MEMBER, TOPIC.READERS, 403], + ["a member with the comment rule's role", USER.VIP, TOPIC.READERS, 201], + ])("answers %s with %i", async (_label, user, topic, status) => { + const world = await createAccessWorld({ rules }); + + const response = await world.send("POST", "/replies", { + body: { values: { body: "Me too", topic } }, + user, + }); + + expect(response.status).toBe(status); + }); +}); diff --git a/packages/vitnode/src/content/server/public-permissions.ts b/packages/vitnode/src/content/server/public-permissions.ts index e0e4ff1446..aba2d4efb0 100644 --- a/packages/vitnode/src/content/server/public-permissions.ts +++ b/packages/vitnode/src/content/server/public-permissions.ts @@ -4,11 +4,15 @@ import type { Context } from "hono"; import { z } from "@hono/zod-openapi"; import { inArray } from "drizzle-orm"; +import type { ContentAccessFlags } from "../access/policy"; import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentModel } from "./model"; import { buildRoute } from "../../api/lib/route"; +import { CONTENT_ACCESS_OPEN_FLAGS } from "../access/policy"; +import { contentRecordAccessFlags } from "../access/server/decide"; +import { contentAccessSourceOf } from "../access/server/source"; import { contentTypeName } from "../admin/labels"; import { CONTENT_PUBLIC_MAX_PAGE_SIZE } from "../const"; import { @@ -27,7 +31,7 @@ import { import { isContentBaseRowPublic } from "./visibility"; /** What the current visitor may do with one record. */ -export interface ContentViewerPermissions { +export interface ContentViewerPermissions extends ContentAccessFlags { /** * `POST /{id}/delete` will accept this visitor: a moderator with * `can_delete`, or the author when one of their roles allows deleting own @@ -90,10 +94,29 @@ export const resolveContentViewerPermissions = async ( .from(table) .where(inArray(columns.id, ids))) as Record[]; const user = c.get("user"); + const accessModel = model as unknown as Parameters< + typeof contentRecordAccessFlags + >[1]; + // Replies are written under their parent's `comment` rule, everything else + // under the scope's `read` rule. + const writeFlag: keyof ContentAccessFlags = + contentAccessSourceOf(c, accessModel)?.kind === "parent" + ? "canComment" + : "canRead"; + const accessFlags = await contentRecordAccessFlags( + c, + accessModel, + rows.map(row => row.id as ContentId), + ); const decided = await Promise.all( rows.map(async row => { const id = row.id as ContentId; + const access = + accessFlags.get(contentIdKey(id)) ?? CONTENT_ACCESS_OPEN_FLAGS; + // A record the viewer may not read is not described at all - not even + // to its author, whose access may have been taken away since. + if (!access.canRead) return null; const isPublic = isContentBaseRowPublic(row); const ownerId = contentPublicWriteOwnerOf(definition, row); const isOwner = user !== null && ownerId === user.id; @@ -115,7 +138,7 @@ export const resolveContentViewerPermissions = async ( policy: effectiveContentPolicy(definition, container), row, }); - canEdit = decision.allowed; + canEdit = decision.allowed && access[writeFlag]; } const isPublished = isContentBaseRowPublic({ ...row, hiddenAt: null }); @@ -131,6 +154,7 @@ export const resolveContentViewerPermissions = async ( if (!isPublic && !canEdit && !canHide && !canDelete) return null; return { + ...access, canDelete, canEdit, canHide, @@ -176,7 +200,7 @@ export const contentPermissionsRoute = < route: { method: "get", path: "/permissions", - description: `What the current visitor may do with each listed ${name}: edit, delete, hide or report it`, + description: `What the current visitor may do with each listed ${name}: read, comment on, download from, edit, delete, hide or report it`, request: { query: z.object({ ids: z @@ -192,10 +216,14 @@ export const contentPermissionsRoute = < schema: z.object({ items: z.array( z.object({ + canComment: z.boolean(), canDelete: z.boolean(), + canDownloadAttachments: z.boolean(), canEdit: z.boolean(), canHide: z.boolean(), + canRead: z.boolean(), canReport: z.boolean(), + canView: z.boolean(), id: schemas.params.shape.id, }), ), diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index 127c11ec20..1b76895e3f 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -17,6 +17,7 @@ import type { ContentPublicOrderableFieldName, } from "../types"; import type { ContentModel } from "./model"; +import type { AnyContentModel } from "./model"; import type { ContentPreviewTokenPayload } from "./preview-token"; import type { ContentPublicService } from "./public-service"; import type { ContentThreadNode } from "./thread"; @@ -27,6 +28,18 @@ import { zodPaginationPageInfo, zodPaginationQuery, } from "../../api/lib/with-pagination"; +import { decideContentAccessForScopes } from "../access/server/decide"; +import { contentFileDownloadRoute } from "../access/server/download-route"; +import { + CONTENT_ACCESS_PRIVATE_HEADERS, + CONTENT_ACCESS_SYSTEM_ACTOR, + contentAccessMissReason, + contentAccessPageCacheTags, + contentAccessRequiredResponse, + isContentAccessShareable, + zodContentAccessRequired, +} from "../access/server/http"; +import { contentAccessSourceOf } from "../access/server/source"; import { contentTypeName } from "../admin/labels"; import { contentContainerTag } from "../cache"; import { @@ -150,6 +163,49 @@ export const buildContentPublicRoutes = < }); const project = createContentPublicProjector(definition); + const accessModel = model as unknown as AnyContentModel; + + /** + * A list filtered by the scope its records are filed in (a container, or an + * `access.from` scope): `"notFound"` when the viewer may not see that scope, + * `"accessRequired"` when they may see it but not read inside it. + */ + const filterScopeRefusal = async ( + c: Context, + filters: Record, + ): Promise<"accessRequired" | "notFound" | null> => { + const source = contentAccessSourceOf(c, accessModel); + if (!source || source.kind === "parent") return null; + + const requested = filters[source.field]; + const ids = ( + typeof requested === "object" && requested !== null && "in" in requested + ? (requested as { in: unknown[] }).in + : [requested] + ).filter( + (value): value is number | string => + typeof value === "number" || typeof value === "string", + ); + if (ids.length === 0) return null; + + for (const id of ids) { + const scopes = [{ family: source.family.id, scopeId: String(id) }]; + if ( + !(await decideContentAccessForScopes(c, { action: "view", scopes })) + .allowed + ) { + return "notFound"; + } + if ( + !(await decideContentAccessForScopes(c, { action: "read", scopes })) + .allowed + ) { + return "accessRequired"; + } + } + + return null; + }; /** * Which language this request is for, or `null` - always a 404. An explicit @@ -331,6 +387,13 @@ export const buildContentPublicRoutes = < description: `Up to ${CONTENT_PUBLIC_MAX_PAGE_SIZE} published ${name} records`, }, 400: { description: "Invalid query parameters" }, + 403: { + content: { + "application/json": { schema: zodContentAccessRequired }, + }, + description: + "The filter names a scope the viewer may see but not read", + }, 404: { description: "The requested locale is not served, or the container filter names a container that does not exist", @@ -370,6 +433,12 @@ export const buildContentPublicRoutes = < } } + const refusal = await filterScopeRefusal(c, filters); + if (refusal === "notFound") { + throw new HTTPException(404, { message: "Container not found." }); + } + if (refusal === "accessRequired") return contentAccessRequiredResponse(c); + // An explicit locale naming no language this install serves is the same // 404 the detail route answers, not an empty list: an empty list would say // "this language has no articles", which is a different and untrue thing. @@ -377,6 +446,7 @@ export const buildContentPublicRoutes = < if (!resolved) throw notFound(); const hidden = await contentListHiddenAccess(c, { model, pluginId }); + const shareable = await isContentAccessShareable(c, accessModel); const readPage = async () => await service(c).findMany({ filters, @@ -394,9 +464,9 @@ export const buildContentPublicRoutes = < query: { cursor, first, last, page, search }, }); - if (hidden) { + if (hidden || !shareable) { return c.json(await readPage(), 200, { - "Cache-Control": "private, no-store", + ...CONTENT_ACCESS_PRIVATE_HEADERS, ...contentLocaleHeaders(resolved), }); } @@ -418,6 +488,7 @@ export const buildContentPublicRoutes = < setPageCacheTags(c, [ ...contentListPageCacheTags(definition), + ...contentAccessPageCacheTags(c, accessModel), ...(containerFilter ? [contentContainerTag(containerFilter.field.definition.id)] : []), @@ -634,12 +705,17 @@ export const buildContentPublicRoutes = < items: page.items.map(serializeThreadNode), }; - if (hiddenAccess || readable.hidden) { - return c.json(body, 200, { "Cache-Control": "private, no-store" }); + if ( + hiddenAccess || + readable.hidden || + !(await isContentAccessShareable(c, accessModel)) + ) { + return c.json(body, 200, CONTENT_ACCESS_PRIVATE_HEADERS); } setPageCacheTags(c, [ ...contentListPageCacheTags(definition), + ...contentAccessPageCacheTags(c, accessModel), ...contentListPageCacheTags(readable.target), ]); @@ -788,45 +864,62 @@ export const buildContentPublicRoutes = < }, description: `${name} found`, }, + 403: { + content: { + "application/json": { schema: zodContentAccessRequired }, + }, + description: `${name} is in a scope the viewer may see but not read. The body says only that`, + }, 404: { description: `${name} not found` }, }, }, handler: async c => { const resolved = await localeFor(c); if (!resolved) throw notFound(); + const shareable = await isContentAccessShareable(c, accessModel); // A draft, an unpublished row, a cleared publication date, a translation // that is not published in this language and a typo are all the same 404. // A 403 would confirm the record exists, which is the one thing a draft // URL must not do - and so would a 404 that only some of them produced. const slug = c.req.param("slug"); - // Only the published read is cached - it is the same for every reader. - // The hidden fallback below depends on who is asking and never is. - const row = await rememberContentPublicRead(c, { - contentTypeId: definition.id, - key: { - locale: resolved.locale ?? null, - route: "detail", - slug, - source: resolved.source, - }, - load: async () => - await service(c).findBySlug(slug, { - fallback: true, - locale: resolved.locale, - }), - }); + const readRow = async () => + await service(c).findBySlug(slug, { + fallback: true, + locale: resolved.locale, + }); + // Only a read no access rule can change is cached - it is the same for + // every reader. The hidden fallback below depends on who is asking and + // never is. + const row = shareable + ? await rememberContentPublicRead(c, { + contentTypeId: definition.id, + key: { + locale: resolved.locale ?? null, + route: "detail", + slug, + source: resolved.source, + }, + load: readRow, + }) + : await readRow(); + if (row && !shareable) { + return c.json(row, 200, { + ...CONTENT_ACCESS_PRIVATE_HEADERS, + ...contentLocaleHeaders(resolved), + }); + } if (row) { - setPageCacheTags( - c, - contentRecordPageCacheTags( + setPageCacheTags(c, [ + ...contentRecordPageCacheTags( definition, parseContentId( definition.idStrategy, (row as Record).id, ), ), - ); + ...contentAccessPageCacheTags(c, accessModel), + ]); return c.json(row, 200, contentLocaleHeaders(resolved)); } @@ -837,7 +930,33 @@ export const buildContentPublicRoutes = < pluginId, slug, }); - if (!hidden) throw notFound(); + if (!hidden) { + if (shareable) throw notFound(); + + // Published, but outside what this viewer may read: say so only when + // they may see where it is filed, and say nothing else about it. + const existing = await service(c).findBySlug(slug, { + actor: CONTENT_ACCESS_SYSTEM_ACTOR, + fallback: true, + locale: resolved.locale, + select: ["id"], + }); + const id = existing + ? parseContentId( + definition.idStrategy, + (existing as Record).id, + ) + : null; + if ( + id !== null && + (await contentAccessMissReason(c, accessModel, id)) === + "accessRequired" + ) { + return contentAccessRequiredResponse(c); + } + + throw notFound(); + } return c.json(hidden, 200, { "Cache-Control": "private, no-store", @@ -873,6 +992,16 @@ export const buildContentPublicRoutes = < ...(definition.publicWrite.enabled ? [contentPublicDeleteRoute(model, { pluginId })] : []), + // Three segments, so it never shadows `/{slug}`. Mounted for every type + // that exposes a file or a rich-text document, which is where protected + // files and their authorized URLs come from. + ...(definition.publicApi.fields.some(field => { + const kind = definition.fields[field]?.kind; + + return kind === "file" || kind === "richText"; + }) + ? [contentFileDownloadRoute(model, { pluginId })] + : []), detail, ...contentPublicWriteRoutes(model, { pluginId }), ]; diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index 24dbb89906..ad7a47a017 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -9,6 +9,7 @@ import type { Context } from "hono"; import { and, eq, inArray, or } from "drizzle-orm"; import type { PaginationCursorColumn } from "../../api/lib/with-pagination"; +import type { ContentAccessActor } from "../access/policy"; import type { ContentIdOf } from "../ids"; import type { AnyContentTypeDefinition, @@ -25,6 +26,7 @@ import type { import type { ContentPageInfo } from "./service"; import { withPagination } from "../../api/lib/with-pagination"; +import { contentPublicAccessCondition } from "../access/server/source"; import { CONTENT_PUBLIC_DEFAULT_PAGE_SIZE, CONTENT_PUBLIC_MAX_PAGE_SIZE, @@ -59,6 +61,12 @@ import { export { nestContentPublicRow } from "./public-row-hydration"; export interface ContentPublicReadOptions { + /** + * Whose access rules the read applies. Defaults to the request's own viewer; + * pass a guest to build something every visitor will see (a sitemap), or a + * recipient to check what a notification may say. + */ + actor?: ContentAccessActor; /** Language to read, on a localized content type. Defaults to `localization.defaultLocale`. */ locale?: string; } @@ -145,6 +153,8 @@ export interface ContentPublicService { findManyByIds?: ( ids: readonly ContentIdOf[], options?: { + /** Whose access rules apply. Defaults to the request's viewer. */ + actor?: ContentAccessActor; /** * Also read published records that are hidden. Only for ids the caller * already cleared for this reader, such as a thread's moderators. @@ -482,12 +492,21 @@ export const createContentPublicService = < const { hydrate, project, selection } = options.select ? reader(contentPublicSelectProjection(definition, options.select)) : detail; + const access = await contentPublicAccessCondition(c, definition, { + actor: options.actor, + }); const [row] = await keepVisible( await c .get("db") .select(withParent(selection)) .from(table) - .where(and(contentPublicReadCondition(published, options), condition)) + .where( + and( + contentPublicReadCondition(published, options), + condition, + access, + ), + ) .limit(1), ); @@ -514,20 +533,32 @@ export const createContentPublicService = < contentPublicReadCondition(published, { hidden: true }), ) : publishedCondition(published); + const access = await contentPublicAccessCondition(c, definition, { + actor: options?.actor, + }); const rows = await keepVisible( await c .get("db") .select(withParent(detail.selection)) .from(table) - .where(and(readable, inArray(primaryCursor, [...ids]))), + .where(and(readable, inArray(primaryCursor, [...ids]), access)), { hiddenParents: options?.hiddenParents }, ); return (await detail.hydrate(rows)).map(detail.project); }, - findMany: async ({ filters = {}, hidden, orderBy, query = {} } = {}) => { + findMany: async ({ + actor, + filters = {}, + hidden, + orderBy, + query = {}, + } = {}) => { const conditions = [ + // Before pagination, so a page, its cursor and its count only ever + // know about records this viewer may read. + await contentPublicAccessCondition(c, definition, { actor }), contentPublicListCondition({ advanced, columns, definition }, hidden), buildFilterCondition({ allowed: publicApi.filterableFields, diff --git a/packages/vitnode/src/content/server/public-visibility-routes.ts b/packages/vitnode/src/content/server/public-visibility-routes.ts index 56355e6f29..c25435076f 100644 --- a/packages/vitnode/src/content/server/public-visibility-routes.ts +++ b/packages/vitnode/src/content/server/public-visibility-routes.ts @@ -11,6 +11,7 @@ import type { ContentVisibilityAction } from "../visibility"; import type { AnyContentModel, ContentModel } from "./model"; import { buildRoute } from "../../api/lib/route"; +import { canAccessContentRecord } from "../access/server/decide"; import { contentTypeName } from "../admin/labels"; import { CONTENT_HIDDEN_REASON_MAX_LENGTH } from "../const"; import { contentIdKey } from "../ids"; @@ -29,9 +30,15 @@ import { contentIdentifier, jsonBody, readOptionalJson } from "./route-helpers"; import { syncContentSearch } from "./search-sync"; import { isContentBaseRowPublic } from "./visibility"; +/** + * The row, but only when the viewer may read it under the access rules of its + * scopes: an action on a record somebody may not read is the same 404 as on + * one that does not exist - moderators included, who act from the site with + * their own roles. + */ const readRow = async ( c: Context, - model: { columns: { id: unknown }; table: PgTable }, + model: AnyContentModel, id: ContentId, ): Promise | undefined> => { const [row] = (await c @@ -40,8 +47,11 @@ const readRow = async ( .from(model.table) .where(eq(model.columns.id as never, id)) .limit(1)) as Record[]; + if (!row) return undefined; - return row; + return (await canAccessContentRecord(c, { action: "read", id, model })) + ? row + : undefined; }; /** @@ -90,7 +100,7 @@ export const contentPublicVisibilityRoutes = < if (!user) throw new HTTPException(401, { message: "Sign in first." }); const id = contentIdentifier(c, definition); - const row = await readRow(c, model, id); + const row = await readRow(c, anyModel, id); if (!row || !isContentBaseRowPublic({ ...row, hiddenAt: null })) { throw new HTTPException(404, { message: `${name} not found.` }); } diff --git a/packages/vitnode/src/content/server/public-write-service.ts b/packages/vitnode/src/content/server/public-write-service.ts index 75e3566540..d5d0e1f17a 100644 --- a/packages/vitnode/src/content/server/public-write-service.ts +++ b/packages/vitnode/src/content/server/public-write-service.ts @@ -8,6 +8,8 @@ import type { Context } from "hono"; import { eq, getColumns, getColumnTable, inArray } from "drizzle-orm"; import { getTableConfig } from "drizzle-orm/pg-core"; +import type { ContentAccessAction } from "../access/policy"; +import type { ContentAccessScopeRef } from "../access/server/source"; import type { ContentSubmissionRow } from "../approvals/server/submissions"; import type { EffectiveContentContainerPolicy } from "../containers/policies"; import type { ContentRecordContainer } from "../containers/server/policy"; @@ -19,10 +21,16 @@ import type { ContentPublicUpdateBody, ContentPublicWriteRecord, } from "../types"; -import type { ContentModel } from "./model"; +import type { AnyContentModel, ContentModel } from "./model"; import type { ContentPublicWriteActor } from "./public-write-authorization"; import type { ContentDatabase } from "./service"; +import { decideContentAccessForScopes } from "../access/server/decide"; +import { + contentAccessScopesForValues, + contentAccessSourceOf, + contentRecordAccessScopes, +} from "../access/server/source"; import { emitContentApprovalSubmitted, findLatestContentSubmission, @@ -194,6 +202,39 @@ export const createContentPublicWriteService = < issue: ContentPublicWriteDenied["issue"] = null, ) => new ContentPublicWriteDenied({ contentTypeId, issue, reason }); + const accessModel = model as unknown as AnyContentModel; + // A reply is written under its parent's `comment` rule; anything else needs + // to read the scope it is written into. + const writeAction = (): ContentAccessAction => + contentAccessSourceOf(c, accessModel)?.kind === "parent" + ? "comment" + : "read"; + + /** + * The access rules of the scopes a write lands in, decided from the + * database for this request - so a role taken away a moment ago is already + * gone, whatever form the member still has open. A scope they may not even + * see is a 404, like a record that does not exist. + */ + const assertWriteAccess = async ( + scopes: ContentAccessScopeRef[] | null | undefined, + ): Promise => { + if (!scopes) throw deny("notFound"); + if (scopes.length === 0) return; + + const action = writeAction(); + const decision = await decideContentAccessForScopes(c, { action, scopes }); + if (decision.allowed) return; + + const visible = await decideContentAccessForScopes(c, { + action: "view", + scopes, + }); + throw visible.allowed + ? deny("forbidden", CONTENT_PUBLIC_WRITE_ISSUE_CODES.accessDenied) + : deny("notFound"); + }; + const requireActor = (): ContentPublicWriteActor => { const actor = resolveContentPublicWriteActor(c); if (!actor) throw deny("unauthenticated"); @@ -421,6 +462,12 @@ export const createContentPublicWriteService = < ) => { if (!row) throw deny("notFound"); + await assertWriteAccess( + ( + await contentRecordAccessScopes(c, accessModel, [row.id as ContentId]) + ).get(contentIdKey(row.id as ContentId)), + ); + const container = await resolveContentRowContainer(c, tx, definition, row); const policy = effectiveContentPolicy(definition, container); const decision = await authorizeContentPublicWrite(c, { @@ -516,6 +563,9 @@ export const createContentPublicWriteService = < const prepare = async (tx: ContentDatabase) => { await assertPublicRelationTargets(tx, parsed); + await assertWriteAccess( + await contentAccessScopesForValues(c, accessModel, parsed), + ); const container = await asInputError( async () => await resolveContentWriteContainer(c, tx, definition, parsed), diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 39ef897364..b4610eea1c 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -28,6 +28,11 @@ import { zodPaginationQuery, } from "../../api/lib/with-pagination"; import { StorageImageUnprocessableError } from "../../api/models/storage"; +import { + buildContentAccessAdminRoutes, + buildContentAccessEffectiveRoute, +} from "../access/server/admin-routes"; +import { contentUploadVisibility } from "../access/server/files"; import { contentTypeName } from "../admin/labels"; import { zodContentConflict, @@ -774,6 +779,9 @@ export const buildContentRoutes = < : {}), maxBytes: constraints.maxBytes, metadata: { contentTypeId: definition.id, field }, + // Behind access rules, and the adapter can keep it private: never a + // public URL, only the record's authorized download route. + visibility: contentUploadVisibility(c, model), }); } catch (error) { // `StorageModel` speaks in `HTTPException`s, whose messages are exactly @@ -2100,6 +2108,25 @@ export const buildContentRoutes = < : []), // Mounted under the same module and the same permissions, so a localized // content type gets its translation routes without a second registration. + // A scope content type (a blog category) gets the same access editor + // routes a container collection has; one filed under scopes gets the + // effective-access check its editor shows. + ...(definition.access.scope.enabled + ? buildContentAccessAdminRoutes({ + familyId: definition.id, + permissionModule: definition.permissionModule, + pluginId, + }) + : []), + ...(definition.access.from + ? [ + buildContentAccessEffectiveRoute({ + contentTypeId: definition.id, + permissionModule: definition.permissionModule, + pluginId, + }), + ] + : []), ...(definition.localization.enabled ? [ ...buildContentTranslationRoutes(model, { pluginId }), diff --git a/packages/vitnode/src/content/server/search-sync.ts b/packages/vitnode/src/content/server/search-sync.ts index d7be892b7e..40dd3f9492 100644 --- a/packages/vitnode/src/content/server/search-sync.ts +++ b/packages/vitnode/src/content/server/search-sync.ts @@ -1,7 +1,7 @@ import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; import type { Context } from "hono"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { z } from "zod"; import type { QueueModel } from "../../api/models/queue"; @@ -11,6 +11,9 @@ import type { AnyContentModel } from "./model"; import type { RegisteredContentModel } from "./model"; import type { ContentSearchContext } from "./search-document"; +import { searchItemIdFromKey } from "../../api/models/search"; +import { core_search_index } from "../../database/search"; +import { contentContainerFieldOf } from "../containers/association"; import { parseContentId } from "../ids"; import { partitionContentFields } from "../localization"; import { contentColumnsToValues, contentStorageColumns } from "../paths"; @@ -81,6 +84,17 @@ export interface ContentSearchSyncOutcome { error?: Error; } +/** + * The fields that decide which access scopes a record is filed in: its + * container and its `access.from` relation. A change to one has to re-stamp + * the record's search document, and its dependents', even when nothing + * visible in them moved. + */ +const accessFieldsOf = (definition: AnyContentTypeDefinition): string[] => + [definition.access?.from, contentContainerFieldOf(definition)?.name].filter( + (name): name is string => typeof name === "string", + ); + /** What the row is, and what the index therefore has to hold. */ const decide = ( definition: AnyContentTypeDefinition, @@ -102,8 +116,11 @@ const decide = ( // is built from actually moved. A slug change is covered, because the exposed // slug is one of the indexed field names. const indexed = contentSearchIndexedPaths(definition); + const access = accessFieldsOf(definition); - return (changedFields ?? []).some(name => indexed.has(name)) + return (changedFields ?? []).some( + name => indexed.has(name) || access.includes(name), + ) ? "upsert" : "skip"; }; @@ -242,7 +259,11 @@ const dependentSyncOf = ( return changed === true ? "upsert" : "skip"; } - const linked = [definition.search.titleField, definition.publicApi.slugField]; + const linked = [ + definition.search.titleField, + definition.publicApi.slugField, + ...accessFieldsOf(definition), + ]; return (changedFields ?? []).some(name => linked.includes(name)) ? "upsert" @@ -635,7 +656,10 @@ export const syncContentLocalizedSearch = async ( // declared field, so a publish never reaches this. if (input.operation === "update" || input.operation === "restore") { const indexed = contentSearchIndexedPaths(definition); - const moved = (input.changedFields ?? []).some(name => indexed.has(name)); + const access = accessFieldsOf(definition); + const moved = (input.changedFields ?? []).some( + name => indexed.has(name) || access.includes(name), + ); if (!moved) return []; } @@ -727,9 +751,62 @@ export const syncContentLocalizedSearch = async ( ); } + if ( + (input.operation === "update" || input.operation === "restore") && + (input.changedFields ?? []).some(name => + accessFieldsOf(definition).includes(name), + ) + ) { + await refreshContentSearchDependentAccess(c, definition, itemId); + } + return outcomes; }; +/** + * Re-stamps the access scopes of every document indexed under one record - a + * blog article's comments after its categories changed. Reads the index for + * them rather than every dependent table, and never throws: a failure is + * logged, the search-time check against the content tables still holds, and + * the reconciliation job catches up. + */ +export const refreshContentSearchDependentAccess = async ( + c: Context, + definition: Pick, + itemId: ContentId, +): Promise => { + if (typeof itemId !== "number") return; + + try { + const rows = await c + .get("db") + .selectDistinct({ + itemId: core_search_index.itemId, + itemType: core_search_index.itemType, + }) + .from(core_search_index) + .where( + and( + eq(core_search_index.containerType, definition.id), + eq(core_search_index.containerId, itemId), + ), + ); + const contentTypes = c.get("core")?.contentTypes ?? []; + await c.get("search").refreshAccessScopes( + rows.map(row => ({ + itemId: searchItemIdFromKey(contentTypes, row.itemType, row.itemId), + itemType: row.itemType, + })), + ); + } catch (error) { + await c + .get("log") + .error( + `[content-search] ${definition.id} ${String(itemId)}: dependent access scopes could not be refreshed (${error instanceof Error ? error.message : String(error)}). The reconciliation job will retry.`, + ); + } +}; + /** * The shared collections a search document needs, or `undefined`. * diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 3b66f5dc7b..0d6e5364f2 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -22,6 +22,7 @@ import type { CONTENT_VISIBILITY_FILTERS, } from "./const"; import type { AnyContentContainerDefinition } from "./containers/types"; +import type { ResolvedContentTypeAccess } from "./define-access"; import type { ContentFileDescriptor } from "./files"; import type { ContentId, @@ -2091,6 +2092,8 @@ export interface ContentTypeDefinition< TPublicPeople extends string = string, TPublicListField extends string = string, > { + /** Role-based access rules, or the disabled default when `access` is omitted. */ + access: ResolvedContentTypeAccess; admin: ResolvedContentAdminConfig; /** Generated junction tables, child tables and the leaf-path mapping. */ advanced: ResolvedContentAdvancedConfig; diff --git a/packages/vitnode/src/database/content-access.ts b/packages/vitnode/src/database/content-access.ts new file mode 100644 index 0000000000..3c6bff5262 --- /dev/null +++ b/packages/vitnode/src/database/content-access.ts @@ -0,0 +1,58 @@ +import { camelCase, index, primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; + +import { core_roles } from "./roles"; + +/** + * One access rule an administrator set on one scope: a container, a blog + * category, or any other scope a plugin declares. A scope with no row for an + * action inherits it. `family` is the container collection or content type + * id, `scopeId` the scope's own id as text. + */ +export const core_content_access_rules = camelCase.table.withRLS( + "core_content_access_rules", + t => ({ + id: t.serial().primaryKey(), + family: t.varchar({ length: 100 }).notNull(), + scopeId: t.varchar({ length: 64 }).notNull(), + action: t.varchar({ length: 32 }).notNull(), + mode: t.varchar({ length: 16 }).notNull(), + createdAt: t.timestamp().notNull().defaultNow(), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + t => [ + uniqueIndex("core_content_access_rules_scope_action_key").on( + t.family, + t.scopeId, + t.action, + ), + ], +); + +/** + * The roles a `roles` rule lets in. Deleting a role deletes its rows, and a + * `roles` rule with no row left lets nobody in - a deleted role never opens a + * restricted scope. + */ +export const core_content_access_rule_roles = camelCase.table.withRLS( + "core_content_access_rule_roles", + t => ({ + ruleId: t + .integer() + .notNull() + .references(() => core_content_access_rules.id, { + onDelete: "cascade", + }), + roleId: t + .integer() + .notNull() + .references(() => core_roles.id, { onDelete: "cascade" }), + }), + t => [ + primaryKey({ columns: [t.ruleId, t.roleId] }), + index("core_content_access_rule_roles_role_id_idx").on(t.roleId), + ], +); diff --git a/packages/vitnode/src/database/files.ts b/packages/vitnode/src/database/files.ts index 4b09a4fef8..4d1dc5e688 100644 --- a/packages/vitnode/src/database/files.ts +++ b/packages/vitnode/src/database/files.ts @@ -24,6 +24,13 @@ export const core_files = camelCase.table.withRLS( .varchar({ enum: ["automatic", "manual", "disabled"], length: 16 }) .notNull() .default("automatic"), + // `private` objects live in the storage adapter's private store and are + // served only through an authorized route; `public` ones have a public + // URL anybody holding it can open. + visibility: t + .varchar({ enum: ["public", "private"], length: 16 }) + .notNull() + .default("public"), createdAt: t.timestamp().notNull().defaultNow(), }), t => [ diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts index 8e635a0633..1ab852d5df 100644 --- a/packages/vitnode/src/database/relations.ts +++ b/packages/vitnode/src/database/relations.ts @@ -4,6 +4,7 @@ import * as admins from "./admins"; import * as ai from "./ai"; import * as audit from "./audit"; import * as content from "./content"; +import * as contentAccess from "./content-access"; import * as contentContainers from "./content-containers"; import * as contentSubmissions from "./content-submissions"; import * as cron from "./cron"; @@ -28,6 +29,7 @@ export const coreSchema = { ...ai, ...audit, ...content, + ...contentAccess, ...contentContainers, ...contentSubmissions, ...cron, diff --git a/packages/vitnode/src/database/search.ts b/packages/vitnode/src/database/search.ts index a5048950d1..1af020f7b8 100644 --- a/packages/vitnode/src/database/search.ts +++ b/packages/vitnode/src/database/search.ts @@ -101,6 +101,16 @@ export const core_search_index = camelCase.table.withRLS( ), containerType: t.varchar({ length: 100 }), containerId: t.integer(), + // The access scopes the item was filed in when it was indexed, as + // `family:scopeId` (`forum.forums:12`, `blog.category:3`). A query keeps a + // row only when every scope is one the viewer may read; the content + // tables are still checked before a result is returned, so a stale row + // never grants anything. + accessScopes: t + .text() + .array() + .notNull() + .default(sql`'{}'::text[]`), url: t.text(), isPublic: t.boolean().notNull().default(true), isHidden: t.boolean().notNull().default(false), @@ -130,6 +140,7 @@ export const core_search_index = camelCase.table.withRLS( index("core_search_index_is_public_idx").on(t.isPublic), index("core_search_index_image_id_idx").on(t.imageId), index("core_search_index_container_idx").on(t.containerType, t.containerId), + index("core_search_index_access_scopes_idx").using("gin", t.accessScopes), ], ); diff --git a/packages/vitnode/src/lib/audit/actions.ts b/packages/vitnode/src/lib/audit/actions.ts index c9a0664183..ce9caf14ec 100644 --- a/packages/vitnode/src/lib/audit/actions.ts +++ b/packages/vitnode/src/lib/audit/actions.ts @@ -91,6 +91,8 @@ export const CONTENT_AUDIT_ACTIONS = [ "content.translation_published", "content.translation_unpublished", "content.translation_restored", + "content.access_updated", + "content.access_files_protected", ] as const; /** Everything core records under `@vitnode/core`. */ diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index b37ddb54ab..f967105f50 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -357,7 +357,9 @@ "insert": "Insert {name}", "insert_all": "Insert all ({count})", "image": "Image" - } + }, + "restricted_title": "An attachment is available.", + "restricted_desc": "You do not have permission to download it." }, "table": { "label": "Table", @@ -1774,6 +1776,7 @@ "duplicate": "Duplicate", "hide": "Hide", "unhide": "Unhide", + "access": "Access", "activity": "Activity" }, "create": { @@ -2967,7 +2970,9 @@ "translation_deleted": "Deleted a translation of {type}", "translation_published": "Published a translation of {type}", "translation_unpublished": "Unpublished a translation of {type}", - "translation_restored": "Restored a translation of {type}" + "translation_restored": "Restored a translation of {type}", + "access_updated": "Changed access rules", + "access_files_protected": "Moved public files to private storage" }, "role": { "created": "Created role", @@ -3069,7 +3074,126 @@ "provider": "Provider", "reference": "Reference", "customerId": "Customer id", - "errorCode": "Error code" + "errorCode": "Error code", + "access_view": "View access", + "access_read": "Read access", + "access_comment": "Comment access", + "access_downloadAttachments": "Download access" + } + }, + "access": { + "loading": "Checking your access", + "sign_in": "Sign in", + "read": { + "guest": { + "title": "Sign in to read this", + "desc": "This part of the community is open to signed-in members only. Sign in and it might just open up for you." + }, + "member": { + "title": "You don't have access here", + "desc": "Your account can't read this yet. If you think it should, ask an administrator." + } + }, + "comment": { + "guest": { + "title": "Sign in to reply", + "desc": "Replies are for signed-in members. Sign in to join the conversation." + }, + "member": { + "title": "Replies are closed to you here", + "desc": "You can read this, but your account can't reply in this place." + } + }, + "attachment": { + "title": "An attachment is available", + "desc": "You do not have permission to download it." + }, + "admin": { + "action": "Access", + "title": "Access", + "desc": "Who may see {name}, read what is inside, reply and download attachments. Each rule can inherit from the parent - a child can be stricter than its parent, never more open.", + "submit": "Save access", + "success": "Access saved", + "successDesc": "New rules for {name} apply from the next request.", + "loadError": "The access rules could not be loaded.", + "retry": "Try again", + "inherit": "Inherit ({value})", + "this_scope": "this one", + "deleted_role": "a deleted role", + "roles_label": "Roles that may {action}", + "roles_desc": "Members with any of these roles, as a primary or secondary role. Pick at least one.", + "modes": { + "roles": "Selected roles" + }, + "audience": { + "everyone": "Everyone", + "members": "Signed-in members", + "roles": "Members with {roles}", + "nobody": "Nobody (no role left)" + }, + "actions": { + "view": { + "label": "View", + "desc": "See that it exists - its name and description in lists and breadcrumbs." + }, + "read": { + "label": "Read", + "desc": "Open and read what is filed in it, find it in search and Discover." + }, + "comment": { + "label": "Comment", + "desc": "Reply to what is filed in it. Signing in and every moderation rule still apply." + }, + "downloadAttachments": { + "label": "Download attachments", + "desc": "Download protected attachments of what readers can read." + } + }, + "source": { + "scope": "Inherit follows {name}.", + "default": "Inherit follows the collection's default.", + "fallback": "Inherit means everyone - nothing above sets this." + }, + "effective": { + "title": "Effective access", + "open": "Everyone", + "self": "{audience} - set here or inherited for {scope}", + "ancestor": "{audience} for {action} - required by {scope}, a parent", + "prerequisite": "{audience} for {action} - needed first", + "needs": "Needs {action} first, so it is never wider than that.", + "loading": "Checking who can read this" + }, + "warnings": { + "nobody": "Nobody but a root role can {action} here: the rule on {scope} has no role left." + }, + "descendants": "{count, plural, one {# scope inside follows these rules too.} other {# scopes inside follow these rules too.}}", + "attachments": { + "unsupported": "The storage adapter keeps files public, so downloads stay open to everyone. Configure private storage to restrict them." + }, + "errors": { + "invalidAccess": "One of the rules is not valid.", + "notFound": "It no longer exists.", + "unknownRole": "One of the roles no longer exists. Pick the roles again.", + "unsupportedStorage": "Downloads can't be restricted: the storage adapter keeps files public.", + "unknown": "Something went wrong. Try again in a moment." + }, + "files": { + "title": "Files", + "loading": "Counting files", + "desc": "{private, plural, =0 {No file is stored privately} one {# file is stored privately} other {# files are stored privately}}; {public, plural, =0 {none still has a public address.} one {# still has a public address anybody holding it can open.} other {# still have a public address anybody holding it can open.}}", + "protect": "Protect public files", + "success": "Files protected", + "successDesc": "{moved} moved to private storage, {remaining} left for another run, {shared} used elsewhere too and kept public - upload those again where they should be private.", + "failed": "The files could not be moved. Try again in a moment.", + "unsupported": "The storage adapter keeps every file public, so these stay reachable by address whatever the rules say. Configure a private store to protect them." + }, + "notice": { + "title": "Who can see this", + "open": "Everyone can read this, comment on it and download its attachments - none of its categories restricts it.", + "and": ", and also ", + "intersection": "A reader has to meet the rule of every category it is in, so two restricted categories together let in fewer people than either one alone.", + "missing": "One of the selected categories no longer exists. Nobody but a root role can read a record filed there." + } } } }, diff --git a/packages/vitnode/src/tanstack/admin/containers/route.tsx b/packages/vitnode/src/tanstack/admin/containers/route.tsx index 53306f524a..3310a0b8bf 100644 --- a/packages/vitnode/src/tanstack/admin/containers/route.tsx +++ b/packages/vitnode/src/tanstack/admin/containers/route.tsx @@ -19,6 +19,7 @@ import { requireAdminPermission } from "../screen"; export const ADMIN_CONTAINERS_NAMESPACES = [ "core.global", + "core.access", "core.containers", ] as const; diff --git a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx index dbe637c591..1f979d03c8 100644 --- a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx +++ b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx @@ -9,6 +9,7 @@ import { FileClockIcon, HistoryIcon, LinkIcon, + LockIcon, PencilIcon, SendIcon, Trash2Icon, @@ -52,6 +53,7 @@ import { contentVisibilityTransition, isContentHidden, } from "@/content/visibility"; +import { ContentAccessSheet } from "@/views/admin/views/access/access-sheet"; import { ContentDuplicateDialog } from "@/views/admin/views/content/actions/duplicate-dialog"; import { CONTENT_EDITORIAL_ACTION_IDS, @@ -299,6 +301,7 @@ const ACTION_ICONS: Record< Exclude, React.ReactNode > = { + access: , activity: , delete: , delivery: , @@ -508,6 +511,11 @@ const ContentRowActionsMenu = ({ permission: CONTENT_PERMISSIONS.delete, plugin: pluginId, }); + const canEdit = useAdminPermission({ + module, + permission: CONTENT_PERMISSIONS.edit, + plugin: pluginId, + }); const target = contentApiTarget(definition, pluginId); const visibility = contentVisibilityTransition({ @@ -515,8 +523,10 @@ const ContentRowActionsMenu = ({ }); const slots = contentAdminSlots(); const items = contentRowActionIds({ + access: definition.access.scope.enabled, canCreate, canDelete, + canEdit, canHide, canPublish, canView, @@ -647,6 +657,25 @@ const ContentRowActionsMenu = ({ /> ) : null} + {panel?.id === "access" ? ( + { + await invalidateContentList(queryClient, definition.id); + }} + open={panel.open} + scopeId={String(row.id)} + scopeName={title} + scopeNames={new Map([[String(row.id), title]])} + target={{ + family: definition.id, + module: `content/${definition.permissionModule}`, + pluginId, + }} + /> + ) : null} + {panel?.id === "delete" ? ( => { }; /** Everything a middleware-configuration cache entry's key starts with. */ -const MIDDLEWARE_QUERY_KEY = ["vitnode", "middleware"] as const; +export const MIDDLEWARE_QUERY_KEY = ["vitnode", "middleware"] as const; /** * The deployment configuration as a TanStack Query. A successful answer is diff --git a/packages/vitnode/src/tanstack/auth/queries.test.ts b/packages/vitnode/src/tanstack/auth/queries.test.ts index 138ac7ee75..752e4441f4 100644 --- a/packages/vitnode/src/tanstack/auth/queries.test.ts +++ b/packages/vitnode/src/tanstack/auth/queries.test.ts @@ -192,16 +192,13 @@ describe("removeUserIdentityQueries", () => { }); /** - * A list of prefixes, never `queryClient.clear()`. - * - * The session is the one worth spelling out: a sign-out *writes* the anonymous - * session and then invalidates it, so an entry has to be there to write. The - * messages, the middleware config and the public feed are the same data for - * every visitor and re-fetching them because somebody signed in is the blunt - * version of this. The AdminCP entry is not this function's either - it has - * its own list, and the two are called side by side rather than merged. + * Never `queryClient.clear()`: a sign-out *writes* the anonymous session and + * then invalidates it, so an entry has to be there to write. The messages and + * the middleware config say the same thing to every visitor. Everything else + * - search, plugin content, AdminCP screens - may depend on whose roles asked + * once access rules restrict anything, so it goes. */ - it("leaves the session, the public caches and the AdminCP's own list", () => { + it("keeps only the session, translations and the middleware config", () => { const queryClient = seeded(); removeUserIdentityQueries(queryClient); @@ -209,8 +206,8 @@ describe("removeUserIdentityQueries", () => { expect(held(queryClient, SESSION)).toBe(true); expect(held(queryClient, INTL)).toBe(true); expect(held(queryClient, MIDDLEWARE)).toBe(true); - expect(held(queryClient, PUBLIC_FEED)).toBe(true); - expect(held(queryClient, ADMIN_SCREEN)).toBe(true); - expect(held(queryClient, PLUGIN)).toBe(true); + expect(held(queryClient, PUBLIC_FEED)).toBe(false); + expect(held(queryClient, ADMIN_SCREEN)).toBe(false); + expect(held(queryClient, PLUGIN)).toBe(false); }); }); diff --git a/packages/vitnode/src/tanstack/auth/queries.ts b/packages/vitnode/src/tanstack/auth/queries.ts index f1c9a9e4df..eda44baa55 100644 --- a/packages/vitnode/src/tanstack/auth/queries.ts +++ b/packages/vitnode/src/tanstack/auth/queries.ts @@ -6,10 +6,35 @@ import { SSO_CONNECTIONS_IDENTITY_ROOT } from "@/views/auth/settings/sso/sso-con import { MY_FILES_IDENTITY_ROOT } from "@/views/files/my-files-query"; import { NOTIFICATIONS_IDENTITY_ROOT } from "@/views/notifications/notifications-query"; +import { INTL_QUERY_SCOPE } from "../i18n/query"; +import { MIDDLEWARE_QUERY_KEY } from "./middleware-config"; +import { SESSION_QUERY_KEY } from "./state"; + +const startsWith = ( + queryKey: readonly unknown[], + prefix: readonly unknown[], +): boolean => prefix.every((part, index) => queryKey[index] === part); + +const isKept = (queryKey: readonly unknown[]): boolean => + // The session itself (refetched right after), translations and the + // deployment configuration, which say the same thing to everybody. + startsWith(queryKey, SESSION_QUERY_KEY) || + startsWith(queryKey, INTL_QUERY_SCOPE) || + startsWith(queryKey, MIDDLEWARE_QUERY_KEY); + +/** + * Drops everything the previous visitor's identity may have shaped, after a + * sign-in, a sign-out or an account switch. That is more than their files and + * devices: with access rules, a forum list, an article, search results and + * comment permissions all depend on whose roles asked - so every cached + * answer goes, and pages read them again as the new visitor. Only the session + * and translations stay. + */ export const removeUserIdentityQueries = (queryClient: QueryClient): void => { queryClient.removeQueries({ queryKey: MY_FILES_IDENTITY_ROOT }); queryClient.removeQueries({ queryKey: DEVICES_IDENTITY_ROOT }); queryClient.removeQueries({ queryKey: PASSKEYS_IDENTITY_ROOT }); queryClient.removeQueries({ queryKey: SSO_CONNECTIONS_IDENTITY_ROOT }); queryClient.removeQueries({ queryKey: NOTIFICATIONS_IDENTITY_ROOT }); + queryClient.removeQueries({ predicate: query => !isKept(query.queryKey) }); }; diff --git a/packages/vitnode/src/tanstack/comments/content-comments.tsx b/packages/vitnode/src/tanstack/comments/content-comments.tsx index c770daedb2..0548cc736d 100644 --- a/packages/vitnode/src/tanstack/comments/content-comments.tsx +++ b/packages/vitnode/src/tanstack/comments/content-comments.tsx @@ -35,6 +35,7 @@ import type { ContentCommentNode, ContentCommentTreeNode } from "./thread"; import type { ContentCommentsController } from "./use-content-comments"; import { LOGIN_PATH, returnToFor } from "../auth/redirects"; +import { ContentAccessRequired } from "../content/access-required"; import { ContentCommentActions, contentCommentAuthor, @@ -549,6 +550,8 @@ export const ContentCommentsComposer = ({

{t("compose.title")}

{comments.isSessionPending ? (