diff --git a/migrations/0006_puzzling_bloodstrike.sql b/migrations/0006_puzzling_bloodstrike.sql new file mode 100644 index 000000000..dec623889 --- /dev/null +++ b/migrations/0006_puzzling_bloodstrike.sql @@ -0,0 +1,6 @@ +ALTER TABLE `app_views` ADD `viewer_hash` text;--> statement-breakpoint +-- Backfill existing rows with their unique id so historical view counts are +-- preserved under COUNT(DISTINCT viewer_hash) and the unique index below is +-- satisfied. Going-forward dedup uses a per-viewer/per-bucket hash on insert. +UPDATE `app_views` SET `viewer_hash` = `id` WHERE `viewer_hash` IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `app_views_app_viewer_idx` ON `app_views` (`app_id`,`viewer_hash`); \ No newline at end of file diff --git a/migrations/meta/0006_snapshot.json b/migrations/meta/0006_snapshot.json new file mode 100644 index 000000000..a3dafdb21 --- /dev/null +++ b/migrations/meta/0006_snapshot.json @@ -0,0 +1,2673 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5c1763db-0358-4a2b-8ffe-005b26913cfc", + "prevId": "47bef73c-609f-49af-afc0-9deba2f38165", + "tables": { + "ai_gateways": { + "name": "ai_gateways", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloudflare_account_id": { + "name": "cloudflare_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gateway_id": { + "name": "gateway_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gateway_name": { + "name": "gateway_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gateway_slug": { + "name": "gateway_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credits_remaining": { + "name": "credits_remaining", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "credits_last_updated": { + "name": "credits_last_updated", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_created": { + "name": "auto_created", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ai_gateways_user_idx": { + "name": "ai_gateways_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "ai_gateways_account_idx": { + "name": "ai_gateways_account_idx", + "columns": [ + "cloudflare_account_id" + ], + "isUnique": false + }, + "ai_gateways_user_account_idx": { + "name": "ai_gateways_user_account_idx", + "columns": [ + "user_id", + "cloudflare_account_id" + ], + "isUnique": false + }, + "ai_gateways_gateway_id_idx": { + "name": "ai_gateways_gateway_id_idx", + "columns": [ + "cloudflare_account_id", + "gateway_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "ai_gateways_user_id_users_id_fk": { + "name": "ai_gateways_user_id_users_id_fk", + "tableFrom": "ai_gateways", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_gateways_cloudflare_account_id_cloudflare_accounts_id_fk": { + "name": "ai_gateways_cloudflare_account_id_cloudflare_accounts_id_fk", + "tableFrom": "ai_gateways", + "tableTo": "cloudflare_accounts", + "columnsFrom": [ + "cloudflare_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_preview": { + "name": "key_preview", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "last_used": { + "name": "last_used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + "key_hash" + ], + "isUnique": false + }, + "api_keys_is_active_idx": { + "name": "api_keys_is_active_idx", + "columns": [ + "is_active" + ], + "isUnique": false + }, + "api_keys_expires_at_idx": { + "name": "api_keys_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_comments": { + "name": "app_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_edited": { + "name": "is_edited", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "app_comments_app_idx": { + "name": "app_comments_app_idx", + "columns": [ + "app_id" + ], + "isUnique": false + }, + "app_comments_user_idx": { + "name": "app_comments_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "app_comments_parent_idx": { + "name": "app_comments_parent_idx", + "columns": [ + "parent_comment_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "app_comments_app_id_apps_id_fk": { + "name": "app_comments_app_id_apps_id_fk", + "tableFrom": "app_comments", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_comments_user_id_users_id_fk": { + "name": "app_comments_user_id_users_id_fk", + "tableFrom": "app_comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_likes": { + "name": "app_likes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reaction_type": { + "name": "reaction_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'like'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "app_likes_app_user_idx": { + "name": "app_likes_app_user_idx", + "columns": [ + "app_id", + "user_id" + ], + "isUnique": true + }, + "app_likes_user_idx": { + "name": "app_likes_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "app_likes_app_id_apps_id_fk": { + "name": "app_likes_app_id_apps_id_fk", + "tableFrom": "app_likes", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_likes_user_id_users_id_fk": { + "name": "app_likes_user_id_users_id_fk", + "tableFrom": "app_likes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_views": { + "name": "app_views", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address_hash": { + "name": "ip_address_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewer_hash": { + "name": "viewer_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "app_views_app_idx": { + "name": "app_views_app_idx", + "columns": [ + "app_id" + ], + "isUnique": false + }, + "app_views_user_idx": { + "name": "app_views_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "app_views_viewed_at_idx": { + "name": "app_views_viewed_at_idx", + "columns": [ + "viewed_at" + ], + "isUnique": false + }, + "app_views_app_viewed_at_idx": { + "name": "app_views_app_viewed_at_idx", + "columns": [ + "app_id", + "viewed_at" + ], + "isUnique": false + }, + "app_views_app_viewer_idx": { + "name": "app_views_app_viewer_idx", + "columns": [ + "app_id", + "viewer_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "app_views_app_id_apps_id_fk": { + "name": "app_views_app_id_apps_id_fk", + "tableFrom": "app_views", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_views_user_id_users_id_fk": { + "name": "app_views_user_id_users_id_fk", + "tableFrom": "app_views", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apps": { + "name": "apps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_prompt": { + "name": "original_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "final_prompt": { + "name": "final_prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "framework": { + "name": "framework", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generating'" + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_repository_url": { + "name": "github_repository_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_repository_visibility": { + "name": "github_repository_visibility", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_archived": { + "name": "is_archived", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "parent_app_id": { + "name": "parent_app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "screenshot_url": { + "name": "screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "screenshot_captured_at": { + "name": "screenshot_captured_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_deployed_at": { + "name": "last_deployed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apps_user_idx": { + "name": "apps_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "apps_status_idx": { + "name": "apps_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "apps_visibility_idx": { + "name": "apps_visibility_idx", + "columns": [ + "visibility" + ], + "isUnique": false + }, + "apps_session_token_idx": { + "name": "apps_session_token_idx", + "columns": [ + "session_token" + ], + "isUnique": false + }, + "apps_parent_app_idx": { + "name": "apps_parent_app_idx", + "columns": [ + "parent_app_id" + ], + "isUnique": false + }, + "apps_search_idx": { + "name": "apps_search_idx", + "columns": [ + "title", + "description" + ], + "isUnique": false + }, + "apps_framework_status_idx": { + "name": "apps_framework_status_idx", + "columns": [ + "framework", + "status" + ], + "isUnique": false + }, + "apps_visibility_status_idx": { + "name": "apps_visibility_status_idx", + "columns": [ + "visibility", + "status" + ], + "isUnique": false + }, + "apps_created_at_idx": { + "name": "apps_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "apps_updated_at_idx": { + "name": "apps_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "apps_user_id_users_id_fk": { + "name": "apps_user_id_users_id_fk", + "tableFrom": "apps", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "old_values": { + "name": "old_values", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_values": { + "name": "new_values", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "audit_logs_user_idx": { + "name": "audit_logs_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "audit_logs_entity_idx": { + "name": "audit_logs_entity_idx", + "columns": [ + "entity_type", + "entity_id" + ], + "isUnique": false + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_attempts": { + "name": "auth_attempts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt_type": { + "name": "attempt_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "auth_attempts_lookup_idx": { + "name": "auth_attempts_lookup_idx", + "columns": [ + "identifier", + "attempted_at" + ], + "isUnique": false + }, + "auth_attempts_ip_idx": { + "name": "auth_attempts_ip_idx", + "columns": [ + "ip_address", + "attempted_at" + ], + "isUnique": false + }, + "auth_attempts_success_idx": { + "name": "auth_attempts_success_idx", + "columns": [ + "success", + "attempted_at" + ], + "isUnique": false + }, + "auth_attempts_type_idx": { + "name": "auth_attempts_type_idx", + "columns": [ + "attempt_type", + "attempted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cloudflare_accounts": { + "name": "cloudflare_accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_name": { + "name": "account_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_email": { + "name": "account_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "cloudflare_accounts_user_idx": { + "name": "cloudflare_accounts_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "cloudflare_accounts_account_id_idx": { + "name": "cloudflare_accounts_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "cloudflare_accounts_user_account_idx": { + "name": "cloudflare_accounts_user_account_idx", + "columns": [ + "user_id", + "account_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "cloudflare_accounts_user_id_users_id_fk": { + "name": "cloudflare_accounts_user_id_users_id_fk", + "tableFrom": "cloudflare_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment_likes": { + "name": "comment_likes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reaction_type": { + "name": "reaction_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'like'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "comment_likes_comment_user_idx": { + "name": "comment_likes_comment_user_idx", + "columns": [ + "comment_id", + "user_id" + ], + "isUnique": true + }, + "comment_likes_user_idx": { + "name": "comment_likes_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "comment_likes_comment_idx": { + "name": "comment_likes_comment_idx", + "columns": [ + "comment_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comment_likes_comment_id_app_comments_id_fk": { + "name": "comment_likes_comment_id_app_comments_id_fk", + "tableFrom": "comment_likes", + "tableTo": "app_comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_likes_user_id_users_id_fk": { + "name": "comment_likes_user_id_users_id_fk", + "tableFrom": "comment_likes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_verification_tokens": { + "name": "email_verification_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "email_verification_tokens_token_hash_unique": { + "name": "email_verification_tokens_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "email_verification_tokens_lookup_idx": { + "name": "email_verification_tokens_lookup_idx", + "columns": [ + "token_hash" + ], + "isUnique": false + }, + "email_verification_tokens_expiry_idx": { + "name": "email_verification_tokens_expiry_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "email_verification_tokens_user_id_users_id_fk": { + "name": "email_verification_tokens_user_id_users_id_fk", + "tableFrom": "email_verification_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "favorites_user_app_idx": { + "name": "favorites_user_app_idx", + "columns": [ + "user_id", + "app_id" + ], + "isUnique": true + }, + "favorites_user_idx": { + "name": "favorites_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "favorites_app_idx": { + "name": "favorites_app_idx", + "columns": [ + "app_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_app_id_apps_id_fk": { + "name": "favorites_app_id_apps_id_fk", + "tableFrom": "favorites", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_states": { + "name": "oauth_states", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_used": { + "name": "is_used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "oauth_states_state_unique": { + "name": "oauth_states_state_unique", + "columns": [ + "state" + ], + "isUnique": true + }, + "oauth_states_state_idx": { + "name": "oauth_states_state_idx", + "columns": [ + "state" + ], + "isUnique": true + }, + "oauth_states_expires_at_idx": { + "name": "oauth_states_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_states_user_id_users_id_fk": { + "name": "oauth_states_user_id_users_id_fk", + "tableFrom": "oauth_states", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "password_reset_tokens": { + "name": "password_reset_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "password_reset_tokens_token_hash_unique": { + "name": "password_reset_tokens_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "password_reset_tokens_lookup_idx": { + "name": "password_reset_tokens_lookup_idx", + "columns": [ + "token_hash" + ], + "isUnique": false + }, + "password_reset_tokens_expiry_idx": { + "name": "password_reset_tokens_expiry_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_revoked": { + "name": "is_revoked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_hash": { + "name": "access_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_activity": { + "name": "last_activity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + }, + "sessions_access_token_hash_idx": { + "name": "sessions_access_token_hash_idx", + "columns": [ + "access_token_hash" + ], + "isUnique": false + }, + "sessions_refresh_token_hash_idx": { + "name": "sessions_refresh_token_hash_idx", + "columns": [ + "refresh_token_hash" + ], + "isUnique": false + }, + "sessions_last_activity_idx": { + "name": "sessions_last_activity_idx", + "columns": [ + "last_activity" + ], + "isUnique": false + }, + "sessions_is_revoked_idx": { + "name": "sessions_is_revoked_idx", + "columns": [ + "is_revoked" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stars": { + "name": "stars", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "starred_at": { + "name": "starred_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "stars_user_app_idx": { + "name": "stars_user_app_idx", + "columns": [ + "user_id", + "app_id" + ], + "isUnique": true + }, + "stars_user_idx": { + "name": "stars_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "stars_app_idx": { + "name": "stars_app_idx", + "columns": [ + "app_id" + ], + "isUnique": false + }, + "stars_app_starred_at_idx": { + "name": "stars_app_starred_at_idx", + "columns": [ + "app_id", + "starred_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "stars_user_id_users_id_fk": { + "name": "stars_user_id_users_id_fk", + "tableFrom": "stars", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "stars_app_id_apps_id_fk": { + "name": "stars_app_id_apps_id_fk", + "tableFrom": "stars", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_settings": { + "name": "system_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "system_settings_key_unique": { + "name": "system_settings_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "system_settings_key_idx": { + "name": "system_settings_key_idx", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "system_settings_updated_by_users_id_fk": { + "name": "system_settings_updated_by_users_id_fk", + "tableFrom": "system_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_model_configs": { + "name": "user_model_configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_action_name": { + "name": "agent_action_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_tokens": { + "name": "max_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_override": { + "name": "provider_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fallback_model": { + "name": "fallback_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_model_configs_user_agent_idx": { + "name": "user_model_configs_user_agent_idx", + "columns": [ + "user_id", + "agent_action_name" + ], + "isUnique": true + }, + "user_model_configs_user_idx": { + "name": "user_model_configs_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "user_model_configs_is_active_idx": { + "name": "user_model_configs_is_active_idx", + "columns": [ + "is_active" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_model_configs_user_id_users_id_fk": { + "name": "user_model_configs_user_id_users_id_fk", + "tableFrom": "user_model_configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_model_providers": { + "name": "user_model_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_id": { + "name": "secret_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_model_providers_user_name_idx": { + "name": "user_model_providers_user_name_idx", + "columns": [ + "user_id", + "name" + ], + "isUnique": true + }, + "user_model_providers_user_idx": { + "name": "user_model_providers_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "user_model_providers_is_active_idx": { + "name": "user_model_providers_is_active_idx", + "columns": [ + "is_active" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_model_providers_user_id_users_id_fk": { + "name": "user_model_providers_user_id_users_id_fk", + "tableFrom": "user_model_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failed_login_attempts": { + "name": "failed_login_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_changed_at": { + "name": "password_changed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferences": { + "name": "preferences", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'system'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'UTC'" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "is_suspended": { + "name": "is_suspended", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_provider_unique_idx": { + "name": "users_provider_unique_idx", + "columns": [ + "provider", + "provider_id" + ], + "isUnique": true + }, + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + "username" + ], + "isUnique": false + }, + "users_failed_login_attempts_idx": { + "name": "users_failed_login_attempts_idx", + "columns": [ + "failed_login_attempts" + ], + "isUnique": false + }, + "users_locked_until_idx": { + "name": "users_locked_until_idx", + "columns": [ + "locked_until" + ], + "isUnique": false + }, + "users_is_active_idx": { + "name": "users_is_active_idx", + "columns": [ + "is_active" + ], + "isUnique": false + }, + "users_last_active_at_idx": { + "name": "users_last_active_at_idx", + "columns": [ + "last_active_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_otps": { + "name": "verification_otps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "otp": { + "name": "otp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "verification_otps_email_idx": { + "name": "verification_otps_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "verification_otps_expires_at_idx": { + "name": "verification_otps_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + }, + "verification_otps_used_idx": { + "name": "verification_otps_used_idx", + "columns": [ + "used" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 192829a9e..a949c54be 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1777015990625, "tag": "0005_cloudflare_oauth_connect", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1783433162184, + "tag": "0006_puzzling_bloodstrike", + "breakpoints": true } ] } \ No newline at end of file diff --git a/worker/api/controllers/appView/controller.ts b/worker/api/controllers/appView/controller.ts index cc9e88b6e..2cbeae581 100644 --- a/worker/api/controllers/appView/controller.ts +++ b/worker/api/controllers/appView/controller.ts @@ -10,7 +10,11 @@ import { GitCloneTokenData, } from './types'; import { AgentSummary } from '../../../agents/core/types'; +import { toPublicAppDetail } from '../apps/publicAppDto'; import { createLogger } from '../../../logger'; +import { RateLimitService } from '../../../services/rate-limit/rateLimits'; +import { RateLimitExceededError } from 'shared/types/errors'; +import { extractRequestMetadata } from '../../../utils/authUtils'; import { buildUserWorkerUrl, buildGitCloneUrl } from 'worker/utils/urls'; import { JWTUtils } from '../../../utils/jwtUtils'; @@ -29,6 +33,15 @@ export class AppViewController extends BaseController { const user = await AppViewController.getOptionalUser(request, env); const userId = user?.id; + try { + await RateLimitService.enforcePublicAppsRateLimit(env, context.config.security.rateLimit, user ?? null, request); + } catch (error) { + if (error instanceof RateLimitExceededError) { + return AppViewController.createErrorResponse('Too many requests', 429); + } + throw error; + } + // Get app details with stats using app service const appService = new AppService(env); const appResult = await appService.getAppDetails(appId, userId); @@ -42,14 +55,18 @@ export class AppViewController extends BaseController { return AppViewController.createErrorResponse('App not found', 404); } - // Track view for all users (including owners and anonymous users) + // Track view for all users (including owners and anonymous users). + // Views are deduplicated per viewer per time bucket by AppService, + // so anonymous viewers are identified by request metadata rather + // than a unique-per-request token. if (userId) { - // Authenticated user view - await appService.recordAppView(appId, userId); + await appService.recordAppView(appId, { userId }); } else { - // Anonymous user view - use a special anonymous identifier - // This could be enhanced with session tracking or IP-based deduplication - await appService.recordAppView(appId, 'anonymous-' + Date.now()); + const metadata = extractRequestMetadata(request); + await appService.recordAppView(appId, { + ipAddress: metadata.ipAddress, + userAgent: metadata.userAgent, + }); } // Try to fetch current agent state to get latest generated code @@ -69,12 +86,17 @@ export class AppViewController extends BaseController { const cloudflareUrl = appResult.deploymentId ? buildUserWorkerUrl(env, appResult.deploymentId) : ''; + // Only the owner may see operational fields (userId, deploymentId, + // private-repo GitHub URL). The prompt + generated code remain + // visible to all viewers of a public app (intended feature). + const isOwner = !!userId && appResult.userId === userId; + const responseData: AppDetailsData = { - ...appResult, // Spread all EnhancedAppData fields including stats + ...toPublicAppDetail(appResult, isOwner), cloudflareUrl: cloudflareUrl, previewUrl: previewUrl || cloudflareUrl, user: { - id: appResult.userId!, + id: isOwner ? appResult.userId! : '', displayName: appResult.userName || 'Unknown', avatarUrl: appResult.userAvatar }, diff --git a/worker/api/controllers/appView/types.ts b/worker/api/controllers/appView/types.ts index e9acc6f14..53fe966ca 100644 --- a/worker/api/controllers/appView/types.ts +++ b/worker/api/controllers/appView/types.ts @@ -4,7 +4,7 @@ */ import { AgentSummary } from '../../../agents/core/types'; -import { EnhancedAppData } from '../../../database/types'; +import { PublicAppDetailData } from '../../../database/types'; /** * Generated code file structure @@ -16,10 +16,11 @@ export interface GeneratedCodeFile { } /** - * Response data for getAppDetails - extends existing EnhancedAppData - * Adds only fields unique to app view response, uses EnhancedAppData stats directly + * Response data for getAppDetails - extends the safe public detail projection. + * Adds only fields unique to app view response; owner-only fields (userId, + * deploymentId) are optional and populated only when the viewer owns the app. */ -export interface AppDetailsData extends EnhancedAppData { +export interface AppDetailsData extends PublicAppDetailData { cloudflareUrl: string | null; previewUrl: string | null; user: { diff --git a/worker/api/controllers/apps/controller.ts b/worker/api/controllers/apps/controller.ts index 97b102175..10a1e5182 100644 --- a/worker/api/controllers/apps/controller.ts +++ b/worker/api/controllers/apps/controller.ts @@ -12,8 +12,12 @@ import { UpdateAppVisibilityData, AppDeleteData } from './types'; +import { toPublicAppListItem } from './publicAppDto'; +import { parsePublicAppsQuery } from './publicAppsQuery'; // import { withCache } from '../../../services/cache/wrapper'; import { createLogger } from '../../../logger'; +import { RateLimitService } from '../../../services/rate-limit/rateLimits'; +import { RateLimitExceededError } from 'shared/types/errors'; export class AppController extends BaseController { static logger = createLogger('AppController'); @@ -110,21 +114,31 @@ export class AppController extends BaseController { } // Get public apps feed (like a global board) - static getPublicApps = async function(this: AppController, request: Request, env: Env, _ctx: ExecutionContext, _context: RouteContext): Promise>> { + static getPublicApps = async function(this: AppController, request: Request, env: Env, _ctx: ExecutionContext, context: RouteContext): Promise>> { try { + const user = await AppController.getOptionalUser(request, env); + try { + await RateLimitService.enforcePublicAppsRateLimit(env, context.config.security.rateLimit, user ?? null, request); + } catch (error) { + if (error instanceof RateLimitExceededError) { + return AppController.createErrorResponse('Too many requests', 429); + } + throw error; + } + const url = new URL(request.url); - - // Parse query parameters with type safety - const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100); - const page = parseInt(url.searchParams.get('page') || '1'); - const offset = (page - 1) * limit; + + // Validate + bound attacker-controlled pagination/search params. + const parsed = parsePublicAppsQuery(url.searchParams); + if (!parsed.ok) { + return AppController.createErrorResponse(parsed.error, 400); + } + const { limit, offset, search } = parsed.value; const sort = (url.searchParams.get('sort') || 'recent') as AppSortOption; const order = (url.searchParams.get('order') || 'desc') as SortOrder; const period = (url.searchParams.get('period') || 'all') as TimePeriod; const framework = url.searchParams.get('framework') || undefined; - const search = url.searchParams.get('search') || undefined; - - const user = await AppController.getOptionalUser(request, env); + const userId = user?.id; // Get apps @@ -140,15 +154,20 @@ export class AppController extends BaseController { userId }); - // Format response with relative timestamps + // Format response with relative timestamps. + // Whitelist each row through the safe public projection before it + // leaves the worker (drops prompts, deploymentId, userId, etc.). const responseData: PublicAppsData = { - apps: result.data.map(app => ({ - ...app, - userName: app.userName || 'Anonymous User', - userAvatar: app.userAvatar || null, - updatedAtFormatted: formatRelativeTime(app.updatedAt), - createdAtFormatted: app.createdAt ? formatRelativeTime(app.createdAt) : '' - })), + apps: result.data.map(app => { + const safeApp = toPublicAppListItem(app); + return { + ...safeApp, + userName: safeApp.userName || 'Anonymous User', + userAvatar: safeApp.userAvatar || null, + updatedAtFormatted: formatRelativeTime(safeApp.updatedAt), + createdAtFormatted: safeApp.createdAt ? formatRelativeTime(safeApp.createdAt) : '' + }; + }), pagination: result.pagination }; diff --git a/worker/api/controllers/apps/publicAppDto.test.ts b/worker/api/controllers/apps/publicAppDto.test.ts new file mode 100644 index 000000000..f78cffb49 --- /dev/null +++ b/worker/api/controllers/apps/publicAppDto.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { toPublicAppListItem, toPublicAppDetail } from './publicAppDto'; +import type { EnhancedAppData } from '../../../database/types'; + +/** + * Regression tests for the public response projections. These lock the wire + * contract: sensitive/operational columns must never appear on the public + * endpoints, and the intended public feature (original prompt on the detail + * endpoint) must remain present. This catches any future re-leak from naively + * spreading a full app row into a response. + */ + +// Fields that must never appear in a public listing response. +const LISTING_DENY_LIST = [ + 'originalPrompt', + 'finalPrompt', + 'sessionToken', + 'userId', + 'deploymentId', + 'parentAppId', + 'isArchived', + 'version', + 'screenshotCapturedAt', +] as const; + +// Fields that must never appear in a non-owner detail response. +const DETAIL_NON_OWNER_DENY_LIST = [ + 'finalPrompt', + 'sessionToken', + 'userId', + 'deploymentId', + 'parentAppId', +] as const; + +function makeApp(overrides: Partial = {}): EnhancedAppData { + return { + // schema.apps columns (sensitive ones deliberately populated) + id: 'app-1', + title: 'Test App', + description: 'A test app', + iconUrl: null, + originalPrompt: 'build me a secret internal tool with API keys', + finalPrompt: 'refined secret prompt', + framework: 'react', + userId: 'user-123', + sessionToken: 'anon-session-token-abc', + visibility: 'public', + status: 'completed', + deploymentId: 'deployment-xyz', + githubRepositoryUrl: 'https://github.com/acme/private-repo', + githubRepositoryVisibility: 'private', + isArchived: false, + isFeatured: true, + version: 3, + parentAppId: 'parent-app-9', + screenshotUrl: 'https://cdn/shot.png', + screenshotCapturedAt: new Date('2024-01-01'), + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-02-01'), + lastDeployedAt: new Date('2024-02-02'), + // EnhancedAppData additions + userName: 'Alice', + userAvatar: 'https://cdn/avatar.png', + starCount: 5, + userStarred: false, + userFavorited: false, + viewCount: 10, + forkCount: 1, + likeCount: 0, + ...overrides, + }; +} + +describe('toPublicAppListItem', () => { + it('excludes all sensitive/operational fields', () => { + const result = toPublicAppListItem(makeApp()); + const keys = Object.keys(result); + for (const field of LISTING_DENY_LIST) { + expect(keys).not.toContain(field); + } + }); + + it('keeps safe display + stats fields', () => { + const result = toPublicAppListItem(makeApp()); + expect(result.id).toBe('app-1'); + expect(result.title).toBe('Test App'); + expect(result.userName).toBe('Alice'); + expect(result.starCount).toBe(5); + expect(result.viewCount).toBe(10); + expect(result.isFeatured).toBe(true); + }); + + it('nulls GitHub fields for private repositories', () => { + const result = toPublicAppListItem(makeApp()); + expect(result.githubRepositoryUrl).toBeNull(); + expect(result.githubRepositoryVisibility).toBeNull(); + }); + + it('exposes GitHub fields for public repositories', () => { + const result = toPublicAppListItem( + makeApp({ + githubRepositoryUrl: 'https://github.com/acme/public-repo', + githubRepositoryVisibility: 'public', + }), + ); + expect(result.githubRepositoryUrl).toBe('https://github.com/acme/public-repo'); + expect(result.githubRepositoryVisibility).toBe('public'); + }); + + it('never serializes a private repo URL to JSON', () => { + const json = JSON.stringify(toPublicAppListItem(makeApp())); + expect(json).not.toContain('private-repo'); + expect(json).not.toContain('secret'); + expect(json).not.toContain('deployment-xyz'); + expect(json).not.toContain('user-123'); + }); +}); + +describe('toPublicAppDetail (non-owner)', () => { + it('excludes operational fields but keeps the original prompt', () => { + const result = toPublicAppDetail(makeApp(), false); + const keys = Object.keys(result); + for (const field of DETAIL_NON_OWNER_DENY_LIST) { + // userId/deploymentId are present as keys but must be undefined + if (field === 'userId' || field === 'deploymentId') { + expect(result[field]).toBeUndefined(); + } else { + expect(keys).not.toContain(field); + } + } + // Intended public feature: prompt remains visible. + expect(result.originalPrompt).toBe('build me a secret internal tool with API keys'); + }); + + it('nulls private GitHub fields for non-owners', () => { + const result = toPublicAppDetail(makeApp(), false); + expect(result.githubRepositoryUrl).toBeNull(); + expect(result.githubRepositoryVisibility).toBeNull(); + }); + + it('does not serialize finalPrompt/sessionToken/deploymentId/userId', () => { + const json = JSON.stringify(toPublicAppDetail(makeApp(), false)); + expect(json).not.toContain('refined secret prompt'); + expect(json).not.toContain('anon-session-token-abc'); + expect(json).not.toContain('deployment-xyz'); + expect(json).not.toContain('user-123'); + }); +}); + +describe('toPublicAppDetail (owner)', () => { + it('includes userId and deploymentId for the owner', () => { + const result = toPublicAppDetail(makeApp(), true); + expect(result.userId).toBe('user-123'); + expect(result.deploymentId).toBe('deployment-xyz'); + }); + + it('exposes the private repo URL to the owner', () => { + const result = toPublicAppDetail(makeApp(), true); + expect(result.githubRepositoryUrl).toBe('https://github.com/acme/private-repo'); + expect(result.githubRepositoryVisibility).toBe('private'); + }); + + it('still never exposes sessionToken or finalPrompt to the owner', () => { + const json = JSON.stringify(toPublicAppDetail(makeApp(), true)); + expect(json).not.toContain('anon-session-token-abc'); + expect(json).not.toContain('refined secret prompt'); + }); +}); diff --git a/worker/api/controllers/apps/publicAppDto.ts b/worker/api/controllers/apps/publicAppDto.ts new file mode 100644 index 000000000..0a2375c66 --- /dev/null +++ b/worker/api/controllers/apps/publicAppDto.ts @@ -0,0 +1,87 @@ +/** + * Public response projections for app endpoints. + * + * The persistence model (schema.apps / EnhancedAppData) intentionally carries + * sensitive and operational data (original prompts, deployment IDs, anonymous + * session tokens, internal user IDs). These helpers are the single source of + * truth for what may leave the worker on the public app endpoints. Any new + * public field must be added here explicitly. + */ + +import type { + EnhancedAppData, + PublicAppListData, + PublicAppDetailData, +} from '../../../database/types'; + +/** + * GitHub repository fields are only safe to expose for public repositories. + * For private repos (or non-owner viewers) both fields are nulled. + */ +function publicGithubFields( + app: Pick, +): Pick { + const isPublicRepo = app.githubRepositoryVisibility === 'public'; + return { + githubRepositoryUrl: isPublicRepo ? app.githubRepositoryUrl : null, + githubRepositoryVisibility: isPublicRepo ? app.githubRepositoryVisibility : null, + }; +} + +/** + * Whitelist an enhanced app row for the public listing response. Drops every + * sensitive/operational column; exposes GitHub fields only for public repos. + */ +export function toPublicAppListItem(app: EnhancedAppData): PublicAppListData { + return { + id: app.id, + title: app.title, + description: app.description, + iconUrl: app.iconUrl, + framework: app.framework, + visibility: app.visibility, + status: app.status, + isFeatured: app.isFeatured, + screenshotUrl: app.screenshotUrl, + createdAt: app.createdAt, + updatedAt: app.updatedAt, + lastDeployedAt: app.lastDeployedAt, + ...publicGithubFields(app), + userName: app.userName, + userAvatar: app.userAvatar, + starCount: app.starCount, + viewCount: app.viewCount, + forkCount: app.forkCount, + likeCount: app.likeCount, + userStarred: app.userStarred, + userFavorited: app.userFavorited, + }; +} + +/** + * Whitelist an enhanced app row for the detail response. + * + * The original prompt and generated code are intended to be visible on public + * apps, so `originalPrompt` is always included. Operational fields (`userId`, + * `deploymentId`) and private-repo GitHub URLs are only exposed to the owner. + */ +export function toPublicAppDetail( + app: EnhancedAppData, + isOwner: boolean, +): PublicAppDetailData { + const base = toPublicAppListItem(app); + const github = isOwner + ? { + githubRepositoryUrl: app.githubRepositoryUrl, + githubRepositoryVisibility: app.githubRepositoryVisibility, + } + : {}; + + return { + ...base, + ...github, + originalPrompt: app.originalPrompt, + userId: isOwner ? app.userId : undefined, + deploymentId: isOwner ? app.deploymentId : undefined, + }; +} diff --git a/worker/api/controllers/apps/publicAppsQuery.test.ts b/worker/api/controllers/apps/publicAppsQuery.test.ts new file mode 100644 index 000000000..4235a667c --- /dev/null +++ b/worker/api/controllers/apps/publicAppsQuery.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { + parsePublicAppsQuery, + MAX_PUBLIC_APPS_PAGE, + MAX_PUBLIC_APPS_LIMIT, +} from './publicAppsQuery'; + +function query(params: Record): URLSearchParams { + return new URLSearchParams(params); +} + +describe('parsePublicAppsQuery', () => { + it('applies defaults for an empty query', () => { + const result = parsePublicAppsQuery(query({})); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.limit).toBe(20); + expect(result.value.page).toBe(1); + expect(result.value.offset).toBe(0); + expect(result.value.search).toBeUndefined(); + } + }); + + it('caps limit at the maximum', () => { + const result = parsePublicAppsQuery(query({ limit: '99999' })); + expect(result.ok && result.value.limit).toBe(MAX_PUBLIC_APPS_LIMIT); + }); + + it('rejects an out-of-range page', () => { + const result = parsePublicAppsQuery(query({ page: '999999' })); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain(`${MAX_PUBLIC_APPS_PAGE}`); + } + }); + + it('accepts the maximum page', () => { + const result = parsePublicAppsQuery(query({ page: String(MAX_PUBLIC_APPS_PAGE), limit: '10' })); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.offset).toBe((MAX_PUBLIC_APPS_PAGE - 1) * 10); + } + }); + + it('rejects a too-short search', () => { + const result = parsePublicAppsQuery(query({ search: 'a' })); + expect(result.ok).toBe(false); + }); + + it('rejects a too-long search', () => { + const result = parsePublicAppsQuery(query({ search: 'a'.repeat(65) })); + expect(result.ok).toBe(false); + }); + + it('rejects wildcard metacharacters in search', () => { + expect(parsePublicAppsQuery(query({ search: '%' })).ok).toBe(false); + expect(parsePublicAppsQuery(query({ search: 'ab%cd' })).ok).toBe(false); + expect(parsePublicAppsQuery(query({ search: 'ab_cd' })).ok).toBe(false); + }); + + it('accepts a valid search term', () => { + const result = parsePublicAppsQuery(query({ search: 'todo app' })); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.search).toBe('todo app'); + } + }); +}); diff --git a/worker/api/controllers/apps/publicAppsQuery.ts b/worker/api/controllers/apps/publicAppsQuery.ts new file mode 100644 index 000000000..2e2d4a93a --- /dev/null +++ b/worker/api/controllers/apps/publicAppsQuery.ts @@ -0,0 +1,57 @@ +/** + * Request-shape parsing + hard bounds for the unauthenticated public apps + * listing. Both `page` and `search` are attacker-controlled, so they are capped + * here to keep D1 read costs bounded (unbounded OFFSET / leading-wildcard LIKE + * scans otherwise). Kept as a pure function so the bounds are unit-testable. + */ + +export const MAX_PUBLIC_APPS_LIMIT = 100; +export const MAX_PUBLIC_APPS_PAGE = 50; +export const MIN_SEARCH_LENGTH = 2; +export const MAX_SEARCH_LENGTH = 64; + +export interface PublicAppsQueryBounds { + limit: number; + page: number; + offset: number; + search?: string; +} + +export type PublicAppsQueryResult = + | { ok: true; value: PublicAppsQueryBounds } + | { ok: false; error: string }; + +/** + * Validate and bound the pagination/search parameters. Returns `ok: false` with + * a client-safe message when a bound is violated (maps to HTTP 400). + */ +export function parsePublicAppsQuery(params: URLSearchParams): PublicAppsQueryResult { + const limit = Math.min(Math.max(parseInt(params.get('limit') || '20') || 20, 1), MAX_PUBLIC_APPS_LIMIT); + + const page = parseInt(params.get('page') || '1') || 1; + if (page < 1 || page > MAX_PUBLIC_APPS_PAGE) { + return { ok: false, error: `page must be between 1 and ${MAX_PUBLIC_APPS_PAGE}` }; + } + + const rawSearch = params.get('search') || undefined; + if (rawSearch !== undefined) { + if (rawSearch.length < MIN_SEARCH_LENGTH || rawSearch.length > MAX_SEARCH_LENGTH) { + return { ok: false, error: `search must be between ${MIN_SEARCH_LENGTH} and ${MAX_SEARCH_LENGTH} characters` }; + } + // Reject SQL LIKE wildcard metacharacters so the server fully controls + // the pattern that reaches the LIKE query. + if (/[%_]/.test(rawSearch)) { + return { ok: false, error: 'search may not contain wildcard characters' }; + } + } + + return { + ok: true, + value: { + limit, + page, + offset: (page - 1) * limit, + search: rawSearch, + }, + }; +} diff --git a/worker/api/controllers/apps/types.ts b/worker/api/controllers/apps/types.ts index 467190268..9bced766c 100644 --- a/worker/api/controllers/apps/types.ts +++ b/worker/api/controllers/apps/types.ts @@ -3,12 +3,13 @@ */ import { App } from '../../../database/schema'; -import { AppWithFavoriteStatus, PaginationInfo, FavoriteToggleResult, EnhancedAppData } from '../../../database/types'; +import { AppWithFavoriteStatus, PaginationInfo, FavoriteToggleResult, PublicAppListData } from '../../../database/types'; /** - * App with extended user and social stats for public listings + * App with extended user and social stats for public listings. + * Uses the safe public projection - no sensitive/operational columns. */ -export type AppWithUserAndStats = EnhancedAppData & { +export type AppWithUserAndStats = PublicAppListData & { updatedAtFormatted: string; }; diff --git a/worker/database/schema.ts b/worker/database/schema.ts index 66ab025b5..760ca487b 100644 --- a/worker/database/schema.ts +++ b/worker/database/schema.ts @@ -304,6 +304,12 @@ export const appViews = sqliteTable('app_views', { userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }), // Null for anonymous sessionToken: text('session_token'), // For anonymous tracking ipAddressHash: text('ip_address_hash'), // Hashed IP for privacy + // Stable per-viewer, per-time-bucket identity used to deduplicate views so + // a single client cannot arbitrarily inflate view counts. For authenticated + // viewers derived from the user id; for anonymous viewers from a hash of + // ip + user-agent + appId. Combined with the unique index below and an + // upsert on insert, this bounds how fast counts can grow. + viewerHash: text('viewer_hash'), // View Context referrer: text('referrer'), @@ -318,6 +324,7 @@ export const appViews = sqliteTable('app_views', { userIdx: index('app_views_user_idx').on(table.userId), viewedAtIdx: index('app_views_viewed_at_idx').on(table.viewedAt), appViewedAtIdx: index('app_views_app_viewed_at_idx').on(table.appId, table.viewedAt), + appViewerIdx: uniqueIndex('app_views_app_viewer_idx').on(table.appId, table.viewerHash), })); // ======================================== diff --git a/worker/database/services/AppService.ts b/worker/database/services/AppService.ts index a28e8c997..ef077af98 100644 --- a/worker/database/services/AppService.ts +++ b/worker/database/services/AppService.ts @@ -34,6 +34,25 @@ type RankedAppQueryResult = { recentStars?: number; }; +// Bounds the time the worker waits on the public-listing D1 reads. D1 has no +// query-cancellation, so this caps the worker's wait (and surfaces a clear +// error) rather than aborting the underlying query. +const PUBLIC_APPS_QUERY_TIMEOUT_MS = 2000; + +// Time-bucket window for view deduplication. Repeated views from the same +// viewer within this window collapse to a single recorded view. +const VIEW_DEDUP_BUCKET_MS = 10 * 60 * 1000; // 10 minutes + +/** + * Identity of a viewer for view-count deduplication. Either an authenticated + * user id, or anonymous request metadata (ip + user-agent). + */ +export interface ViewerIdentity { + userId?: string; + ipAddress?: string; + userAgent?: string; +} + export class AppService extends BaseService { private readonly RANKING_WEIGHTS = { VIEWS: 1, @@ -41,6 +60,18 @@ export class AppService extends BaseService { FORKS: 5 }; + /** + * Race a query promise against a timeout so a single expensive scan cannot + * hold the worker open indefinitely. + */ + private withQueryTimeout(promise: Promise, label: string, ms: number = PUBLIC_APPS_QUERY_TIMEOUT_MS): Promise { + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) as Promise; + } + // ======================================== @@ -79,7 +110,7 @@ export class AppService extends BaseService { const whereClause = this.buildWhereConditions(whereConditions); const readDb = this.getReadDb('fast'); - const basicApps = await this.executeRankedQuery( + const basicApps = await this.withQueryTimeout(this.executeRankedQuery( readDb, whereClause, sort, @@ -87,7 +118,7 @@ export class AppService extends BaseService { order, limit, offset - ).catch((error: unknown) => { + ), 'getPublicApps ranked query').catch((error: unknown) => { this.logger.error('executeRankedQuery failed', { errorMessage: error instanceof Error ? error.message : String(error), errorName: error instanceof Error ? error.name : 'UnknownError', @@ -102,10 +133,10 @@ export class AppService extends BaseService { }); // Get total count for pagination - const totalCountResult = await readDb + const totalCountResult = await this.withQueryTimeout(readDb .select({ count: sql`COUNT(*)` }) .from(schema.apps) - .where(whereClause) + .where(whereClause), 'getPublicApps count query') .catch((error: unknown) => { this.logger.error('Count query failed', { errorMessage: error instanceof Error ? error.message : String(error), @@ -578,9 +609,9 @@ export class AppService extends BaseService { const userReadDb = userId ? this.getReadDb('fresh') : readDb; const [viewCount, starCount, isFavorite, userHasStarred] = await Promise.all([ - // View count + // View count (distinct viewers so flooding cannot inflate it) readDb - .select({ count: sql`count(*)` }) + .select({ count: sql`count(distinct ${schema.appViews.viewerHash})` }) .from(schema.appViews) .where(eq(schema.appViews.appId, appId)) .get() @@ -678,24 +709,50 @@ export class AppService extends BaseService { } /** - * Record app view with duplicate prevention + * Record an app view, deduplicated per viewer per time bucket. + * + * A stable `viewerHash` is derived from the authenticated user id, or (for + * anonymous viewers) from a hash of ip + user-agent + appId, bucketed to a + * fixed window. The unique index on (appId, viewerHash) plus an upsert means + * repeated views from the same client within a bucket collapse to a single + * row, so view counts cannot be arbitrarily inflated. */ - async recordAppView(appId: string, userId: string): Promise { + async recordAppView(appId: string, viewer: ViewerIdentity): Promise { try { + const viewerHash = await this.computeViewerHash(appId, viewer); await this.database .insert(schema.appViews) .values({ id: generateId(), appId, - userId, + userId: viewer.userId ?? null, + viewerHash, viewedAt: new Date() }) + .onConflictDoNothing({ + target: [schema.appViews.appId, schema.appViews.viewerHash] + }) .run(); } catch { // Ignore duplicate view errors } } + /** + * Derive a stable, non-reversible per-viewer, per-time-bucket identity. + */ + private async computeViewerHash(appId: string, viewer: ViewerIdentity): Promise { + const bucket = Math.floor(Date.now() / VIEW_DEDUP_BUCKET_MS); + const seed = viewer.userId + ? `u:${viewer.userId}` + : `a:${viewer.ipAddress ?? 'unknown'}:${viewer.userAgent ?? 'unknown'}:${appId}`; + const data = new TextEncoder().encode(`${seed}:${bucket}`); + const digest = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(digest)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + } + /** * Get user apps with analytics data */ @@ -833,8 +890,9 @@ export class AppService extends BaseService { const periodThreshold = sort === 'trending' ? this.getTimePeriodThreshold(period) : null; const periodUnixTimestamp = periodThreshold ? Math.floor(periodThreshold.getTime() / 1000) : 0; - // Define count subqueries - const viewCountSubquery = sql`(SELECT COUNT(*) FROM ${schema.appViews} WHERE ${schema.appViews.appId} = ${schema.apps.id})`; + // Define count subqueries. Views are counted by distinct viewer + // hash so retroactive/repeated views cannot re-inflate rankings. + const viewCountSubquery = sql`(SELECT COUNT(DISTINCT ${schema.appViews.viewerHash}) FROM ${schema.appViews} WHERE ${schema.appViews.appId} = ${schema.apps.id})`; const starCountSubquery = sql`(SELECT COUNT(*) FROM ${schema.stars} WHERE ${schema.stars.appId} = ${schema.apps.id})`; const forkCountSubquery = sql`(SELECT COUNT(*) FROM ${schema.apps} AS forks WHERE forks.parent_app_id = ${schema.apps.id})`; @@ -862,7 +920,7 @@ export class AppService extends BaseService { .offset(offset); } else { // trending // Trending algorithm: Activity score (scaled by 10M) + recency bonus - const recentViewsSubquery = sql`(SELECT COUNT(*) FROM ${schema.appViews} WHERE ${schema.appViews.appId} = ${schema.apps.id} AND ${schema.appViews.viewedAt} >= ${periodUnixTimestamp})`; + const recentViewsSubquery = sql`(SELECT COUNT(DISTINCT ${schema.appViews.viewerHash}) FROM ${schema.appViews} WHERE ${schema.appViews.appId} = ${schema.apps.id} AND ${schema.appViews.viewedAt} >= ${periodUnixTimestamp})`; const recentStarsSubquery = sql`(SELECT COUNT(*) FROM ${schema.stars} WHERE ${schema.stars.appId} = ${schema.apps.id} AND ${schema.stars.starredAt} >= ${periodUnixTimestamp})`; const orderByExpression = sql`( @@ -916,7 +974,7 @@ export class AppService extends BaseService { private getCountSubqueries() { return { - viewCount: sql`(SELECT COUNT(*) FROM ${schema.appViews} WHERE ${schema.appViews.appId} = ${schema.apps.id})`, + viewCount: sql`(SELECT COUNT(DISTINCT ${schema.appViews.viewerHash}) FROM ${schema.appViews} WHERE ${schema.appViews.appId} = ${schema.apps.id})`, starCount: sql`(SELECT COUNT(*) FROM ${schema.stars} WHERE ${schema.stars.appId} = ${schema.apps.id})`, forkCount: sql`(SELECT COUNT(*) FROM ${schema.apps} AS forks WHERE forks.parent_app_id = ${schema.apps.id})` }; diff --git a/worker/database/types.ts b/worker/database/types.ts index b80d54940..28f8d1acf 100644 --- a/worker/database/types.ts +++ b/worker/database/types.ts @@ -35,6 +35,49 @@ export interface EnhancedAppData extends schema.App { likeCount?: number; } +/** + * Safe public projection of an app for listing responses. Explicitly excludes + * sensitive/operational columns (originalPrompt, finalPrompt, sessionToken, + * userId, deploymentId, parentAppId, etc.). GitHub fields are only populated + * for public repositories. + */ +export type PublicAppListData = Pick< + EnhancedAppData, + | 'id' + | 'title' + | 'description' + | 'iconUrl' + | 'framework' + | 'visibility' + | 'status' + | 'isFeatured' + | 'screenshotUrl' + | 'createdAt' + | 'updatedAt' + | 'lastDeployedAt' + | 'githubRepositoryUrl' + | 'githubRepositoryVisibility' + | 'userName' + | 'userAvatar' + | 'starCount' + | 'viewCount' + | 'forkCount' + | 'likeCount' + | 'userStarred' + | 'userFavorited' +>; + +/** + * Safe public projection of an app for the detail endpoint. Adds the original + * prompt (intended public feature on public apps). Owner-only operational + * fields (userId, deploymentId) are populated only when the viewer owns the app. + */ +export type PublicAppDetailData = PublicAppListData & + Pick & { + userId?: string | null; + deploymentId?: string | null; + }; + /** * App with favorite status for user-specific queries */ diff --git a/worker/services/rate-limit/config.ts b/worker/services/rate-limit/config.ts index c7fc5d93c..faf6ce947 100644 --- a/worker/services/rate-limit/config.ts +++ b/worker/services/rate-limit/config.ts @@ -53,6 +53,7 @@ export enum RateLimitType { AUTH_RATE_LIMIT = 'authRateLimit', APP_CREATION = 'appCreation', LLM_CALLS = 'llmCalls', + PUBLIC_APPS = 'publicApps', } export interface RateLimitSettings { @@ -60,6 +61,7 @@ export interface RateLimitSettings { [RateLimitType.AUTH_RATE_LIMIT]: RLRateLimitConfig; [RateLimitType.APP_CREATION]: DORateLimitConfig | KVRateLimitConfig; [RateLimitType.LLM_CALLS]: LLMCallsRateLimitConfig; + [RateLimitType.PUBLIC_APPS]: DORateLimitConfig | KVRateLimitConfig; } export const DEFAULT_RATE_LIMIT_SETTINGS: RateLimitSettings = { @@ -90,4 +92,14 @@ export const DEFAULT_RATE_LIMIT_SETTINGS: RateLimitSettings = { // Connected users still consume the free daily allotment first; only BYOK (actively-billing) users skip limits. excludeCloudflareConnected: false, }, + // Per-client limit for the unauthenticated public app discovery endpoints + // (listing + detail). DO-based so it works locally and needs no binding. + publicApps: { + enabled: true, + store: RateLimitStore.DURABLE_OBJECT, + limit: 120, + period: 60, // 120 requests / minute per client + burst: 40, + burstWindow: 10, + }, }; diff --git a/worker/services/rate-limit/rateLimits.ts b/worker/services/rate-limit/rateLimits.ts index 045bdbb00..e149cd0e0 100644 --- a/worker/services/rate-limit/rateLimits.ts +++ b/worker/services/rate-limit/rateLimits.ts @@ -147,6 +147,51 @@ export class RateLimitService { } } + /** + * Per-client limit for the unauthenticated public app discovery endpoints + * (listing + detail). Layered on top of the global API limiter to make + * bulk-harvest / scan attacks more expensive. Throws RateLimitExceededError + * when the limit is exceeded. + */ + static async enforcePublicAppsRateLimit( + env: Env, + config: RateLimitSettings, + user: AuthUser | null, + request: Request + ): Promise { + if (!config[RateLimitType.PUBLIC_APPS].enabled) { + return; + } + const identifier = await this.getUniversalIdentifier(user, request); + + const key = this.buildRateLimitKey(RateLimitType.PUBLIC_APPS, identifier); + + try { + const result = await this.enforce(env, key, config, RateLimitType.PUBLIC_APPS); + if (!result.success) { + this.logger.warn('Public apps rate limit exceeded', { + identifier, + key, + userAgent: request.headers.get('User-Agent'), + ip: request.headers.get('CF-Connecting-IP') + }); + captureSecurityEvent('rate_limit_exceeded', { + limitType: RateLimitType.PUBLIC_APPS, + identifier, + key, + userAgent: request.headers.get('User-Agent') || undefined, + ip: request.headers.get('CF-Connecting-IP') || undefined, + }); + throw new RateLimitExceededError(`Public apps rate limit exceeded`, RateLimitType.PUBLIC_APPS); + } + } catch (error) { + if (error instanceof RateLimitExceededError || error instanceof SecurityError) { + throw error; + } + this.logger.error('Failed to enforce public apps rate limit', error); + } + } + static async enforceAuthRateLimit( env: Env, config: RateLimitSettings,