From 6b8eeb6814b56fe2dfcaf4a534d62e503ef10194 Mon Sep 17 00:00:00 2001 From: Christomatt Date: Mon, 26 Jan 2026 20:00:17 +0100 Subject: [PATCH] feat(chat): Implement end-to-end encrypted swarm messaging system - Add Swarm Chat documentation with architecture, API endpoints, and security considerations - Create database schema for chat conversations, messages, and typing indicators - Implement chat API endpoints for sending, receiving, and managing messages - Add client-side encryption utilities using RSA-OAEP with SHA-256 - Create chat page UI for viewing conversations and messages - Add chat type definitions for TypeScript support - Update database schema with new chat tables and relationships - Update sidebar navigation to include chat link - Enable true end-to-end encrypted messaging across swarm nodes without ActivityPub limitations --- SWARM_CHAT.md | 160 + drizzle/0008_illegal_carlie_cooper.sql | 46 + drizzle/meta/0008_snapshot.json | 4308 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/api/swarm/chat/conversations/route.ts | 87 + src/app/api/swarm/chat/inbox/route.ts | 115 + src/app/api/swarm/chat/messages/route.ts | 136 + src/app/api/swarm/chat/send/route.ts | 198 + src/app/chat/page.tsx | 644 +++ src/components/Sidebar.tsx | 8 + src/db/schema.ts | 99 + src/lib/swarm/chat-crypto.ts | 83 + src/lib/swarm/chat-types.ts | 66 + 13 files changed, 5957 insertions(+) create mode 100644 SWARM_CHAT.md create mode 100644 drizzle/0008_illegal_carlie_cooper.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 src/app/api/swarm/chat/conversations/route.ts create mode 100644 src/app/api/swarm/chat/inbox/route.ts create mode 100644 src/app/api/swarm/chat/messages/route.ts create mode 100644 src/app/api/swarm/chat/send/route.ts create mode 100644 src/app/chat/page.tsx create mode 100644 src/lib/swarm/chat-crypto.ts create mode 100644 src/lib/swarm/chat-types.ts diff --git a/SWARM_CHAT.md b/SWARM_CHAT.md new file mode 100644 index 0000000..0615128 --- /dev/null +++ b/SWARM_CHAT.md @@ -0,0 +1,160 @@ +# Swarm Chat + +A real-time, end-to-end encrypted chat system built exclusively for the Synapsis Swarm network. + +## Features + +- **End-to-End Encryption**: Messages are encrypted using recipient's public key +- **Cross-Node Messaging**: Chat with users on any Synapsis node +- **Real-Time Delivery**: Messages delivered instantly via swarm inbox +- **Read Receipts**: See when messages are delivered and read +- **Typing Indicators**: Know when someone is typing (coming soon) +- **No ActivityPub Limitations**: Built specifically for swarm, not constrained by AP spec + +## Architecture + +### Database Schema + +**chat_conversations**: Tracks conversations between users +- Stores participant info and last message preview +- Unique constraint ensures one conversation per user pair + +**chat_messages**: Individual encrypted messages +- Content encrypted with recipient's public key +- Swarm message ID for deduplication +- Delivery and read status tracking + +**chat_typing_indicators**: Real-time typing status (future) + +### API Endpoints + +**POST /api/swarm/chat/send** +- Send a message to any user (local or remote) +- Encrypts content with recipient's public key +- Delivers to remote nodes via swarm inbox + +**POST /api/swarm/chat/inbox** +- Receives messages from other swarm nodes +- Validates and stores encrypted messages +- Updates conversation metadata + +**GET /api/swarm/chat/conversations** +- Lists all conversations for current user +- Includes unread counts and last message preview + +**GET /api/swarm/chat/messages** +- Fetches messages for a conversation +- Supports cursor-based pagination +- Returns encrypted content for client-side decryption + +**PATCH /api/swarm/chat/messages** +- Marks messages as read +- Updates read receipts + +### Encryption + +Messages are encrypted using RSA-OAEP with SHA-256: + +1. **Sending**: Message encrypted with recipient's public key +2. **Storage**: Only encrypted content stored in database +3. **Decryption**: Client-side decryption using user's private key + +This ensures true end-to-end encryption - even the server cannot read messages. + +## Usage + +### Starting a Chat + +```typescript +// Send a message to start a conversation +await fetch('/api/swarm/chat/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipientHandle: 'user@remote.node', + content: 'Hello!', + }), +}); +``` + +### Receiving Messages + +Messages are automatically received via the swarm inbox endpoint. The system: +1. Validates the sender and recipient +2. Checks for duplicate messages +3. Creates or updates the conversation +4. Stores the encrypted message +5. Returns success to the sender + +### Client-Side Decryption + +```typescript +import { decryptMessage } from '@/lib/swarm/chat-crypto'; + +// Decrypt a message using user's private key +const plaintext = decryptMessage( + message.encryptedContent, + userPrivateKey +); +``` + +## Security Considerations + +1. **Private Key Storage**: User private keys should be encrypted at rest +2. **Key Derivation**: Consider using password-based key derivation +3. **Forward Secrecy**: Future enhancement - implement Signal protocol +4. **Message Signing**: Verify sender authenticity with signatures +5. **Rate Limiting**: Prevent spam and abuse + +## Future Enhancements + +- [ ] Group chats (multi-party encryption) +- [ ] Voice/video calls (WebRTC) +- [ ] File attachments (encrypted) +- [ ] Message reactions +- [ ] Message editing/deletion +- [ ] Typing indicators (real-time) +- [ ] Online/offline status +- [ ] Push notifications +- [ ] Desktop notifications +- [ ] Message search +- [ ] Conversation archiving +- [ ] Block/mute users +- [ ] Forward secrecy (Signal protocol) + +## Migration + +Run the migration to create the chat tables: + +```bash +npm run db:generate +npm run db:migrate +``` + +## Testing + +Test the chat system: + +1. Create two accounts on different nodes +2. Navigate to `/chat` on one account +3. Click "New Chat" and enter the other user's handle +4. Send a message +5. Check the other account's `/chat` page + +## Differences from ActivityPub DMs + +Traditional ActivityPub direct messages are just posts with limited visibility. Swarm Chat is superior: + +- **True E2E Encryption**: Not possible with ActivityPub +- **Real-Time**: Direct delivery, no polling required +- **Proper Chat UX**: Conversations, read receipts, typing indicators +- **Lightweight**: No heavyweight ActivityPub overhead +- **Swarm-Native**: Built for the swarm, not retrofitted + +## Contributing + +Contributions welcome! Priority areas: +- Client-side encryption implementation +- Real-time updates (WebSocket/SSE) +- Mobile-responsive UI improvements +- Group chat support diff --git a/drizzle/0008_illegal_carlie_cooper.sql b/drizzle/0008_illegal_carlie_cooper.sql new file mode 100644 index 0000000..fcfb604 --- /dev/null +++ b/drizzle/0008_illegal_carlie_cooper.sql @@ -0,0 +1,46 @@ +CREATE TABLE "chat_conversations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "type" text DEFAULT 'direct' NOT NULL, + "participant1_id" uuid NOT NULL, + "participant2_handle" text NOT NULL, + "last_message_at" timestamp, + "last_message_preview" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "chat_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "conversation_id" uuid NOT NULL, + "sender_handle" text NOT NULL, + "sender_display_name" text, + "sender_avatar_url" text, + "sender_node_domain" text, + "encrypted_content" text NOT NULL, + "swarm_message_id" text, + "delivered_at" timestamp, + "read_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "chat_messages_swarm_message_id_unique" UNIQUE("swarm_message_id") +); +--> statement-breakpoint +CREATE TABLE "chat_typing_indicators" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "conversation_id" uuid NOT NULL, + "user_handle" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "chat_conversations" ADD CONSTRAINT "chat_conversations_participant1_id_users_id_fk" FOREIGN KEY ("participant1_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_conversation_id_chat_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."chat_conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_typing_indicators" ADD CONSTRAINT "chat_typing_indicators_conversation_id_chat_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."chat_conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "chat_conversations_participant1_idx" ON "chat_conversations" USING btree ("participant1_id");--> statement-breakpoint +CREATE INDEX "chat_conversations_last_message_idx" ON "chat_conversations" USING btree ("last_message_at");--> statement-breakpoint +CREATE UNIQUE INDEX "chat_conversations_unique" ON "chat_conversations" USING btree ("participant1_id","participant2_handle");--> statement-breakpoint +CREATE INDEX "chat_messages_conversation_idx" ON "chat_messages" USING btree ("conversation_id");--> statement-breakpoint +CREATE INDEX "chat_messages_created_idx" ON "chat_messages" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "chat_messages_swarm_id_idx" ON "chat_messages" USING btree ("swarm_message_id");--> statement-breakpoint +CREATE INDEX "chat_typing_conversation_idx" ON "chat_typing_indicators" USING btree ("conversation_id");--> statement-breakpoint +CREATE INDEX "chat_typing_expires_idx" ON "chat_typing_indicators" USING btree ("expires_at");--> statement-breakpoint +CREATE UNIQUE INDEX "chat_typing_unique" ON "chat_typing_indicators" USING btree ("conversation_id","user_handle"); \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..f4ace08 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,4308 @@ +{ + "id": "8199ab3b-1fc4-493c-86c0-5d7bde7ab5cc", + "prevId": "7eb1ca27-9c1e-4783-ab75-9b2c26c06b52", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.blocks": { + "name": "blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_user_id": { + "name": "blocked_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blocks_user_idx": { + "name": "blocks_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blocks_blocked_user_idx": { + "name": "blocks_blocked_user_idx", + "columns": [ + { + "expression": "blocked_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocks_user_id_users_id_fk": { + "name": "blocks_user_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocks_blocked_user_id_users_id_fk": { + "name": "blocks_blocked_user_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocked_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_activity_logs": { + "name": "bot_activity_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bot_id": { + "name": "bot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_activity_logs_bot_idx": { + "name": "bot_activity_logs_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_activity_logs_action_idx": { + "name": "bot_activity_logs_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_activity_logs_created_idx": { + "name": "bot_activity_logs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_activity_logs_bot_id_bots_id_fk": { + "name": "bot_activity_logs_bot_id_bots_id_fk", + "tableFrom": "bot_activity_logs", + "tableTo": "bots", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_content_items": { + "name": "bot_content_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_processed": { + "name": "is_processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "interest_score": { + "name": "interest_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "interest_reason": { + "name": "interest_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "bot_content_items_source_idx": { + "name": "bot_content_items_source_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_content_items_processed_idx": { + "name": "bot_content_items_processed_idx", + "columns": [ + { + "expression": "is_processed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_content_items_external_idx": { + "name": "bot_content_items_external_idx", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_content_items_source_id_bot_content_sources_id_fk": { + "name": "bot_content_items_source_id_bot_content_sources_id_fk", + "tableFrom": "bot_content_items", + "tableTo": "bot_content_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_content_items_post_id_posts_id_fk": { + "name": "bot_content_items_post_id_posts_id_fk", + "tableFrom": "bot_content_items", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_content_sources": { + "name": "bot_content_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bot_id": { + "name": "bot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subreddit": { + "name": "subreddit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_encrypted": { + "name": "api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "keywords": { + "name": "keywords", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_fetch_at": { + "name": "last_fetch_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_errors": { + "name": "consecutive_errors", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_content_sources_bot_idx": { + "name": "bot_content_sources_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_content_sources_type_idx": { + "name": "bot_content_sources_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_content_sources_bot_id_bots_id_fk": { + "name": "bot_content_sources_bot_id_bots_id_fk", + "tableFrom": "bot_content_sources", + "tableTo": "bots", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_mentions": { + "name": "bot_mentions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bot_id": { + "name": "bot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_processed": { + "name": "is_processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_post_id": { + "name": "response_post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_remote": { + "name": "is_remote", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remote_actor_url": { + "name": "remote_actor_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_mentions_bot_idx": { + "name": "bot_mentions_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_mentions_processed_idx": { + "name": "bot_mentions_processed_idx", + "columns": [ + { + "expression": "is_processed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_mentions_created_idx": { + "name": "bot_mentions_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_mentions_bot_id_bots_id_fk": { + "name": "bot_mentions_bot_id_bots_id_fk", + "tableFrom": "bot_mentions", + "tableTo": "bots", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_mentions_post_id_posts_id_fk": { + "name": "bot_mentions_post_id_posts_id_fk", + "tableFrom": "bot_mentions", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_mentions_author_id_users_id_fk": { + "name": "bot_mentions_author_id_users_id_fk", + "tableFrom": "bot_mentions", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bot_mentions_response_post_id_posts_id_fk": { + "name": "bot_mentions_response_post_id_posts_id_fk", + "tableFrom": "bot_mentions", + "tableTo": "posts", + "columnsFrom": [ + "response_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_rate_limits": { + "name": "bot_rate_limits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bot_id": { + "name": "bot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "window_type": { + "name": "window_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_count": { + "name": "post_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reply_count": { + "name": "reply_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_rate_limits_bot_window_idx": { + "name": "bot_rate_limits_bot_window_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_rate_limits_bot_id_bots_id_fk": { + "name": "bot_rate_limits_bot_id_bots_id_fk", + "tableFrom": "bot_rate_limits", + "tableTo": "bots", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bots": { + "name": "bots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "personality_config": { + "name": "personality_config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "llm_provider": { + "name": "llm_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "llm_model": { + "name": "llm_model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "llm_api_key_encrypted": { + "name": "llm_api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_config": { + "name": "schedule_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autonomous_mode": { + "name": "autonomous_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspension_reason": { + "name": "suspension_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_post_at": { + "name": "last_post_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bots_user_id_idx": { + "name": "bots_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bots_owner_id_idx": { + "name": "bots_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bots_active_idx": { + "name": "bots_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bots_user_id_users_id_fk": { + "name": "bots_user_id_users_id_fk", + "tableFrom": "bots", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bots_owner_id_users_id_fk": { + "name": "bots_owner_id_users_id_fk", + "tableFrom": "bots", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_conversations": { + "name": "chat_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "participant1_id": { + "name": "participant1_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "participant2_handle": { + "name": "participant2_handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_message_preview": { + "name": "last_message_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_conversations_participant1_idx": { + "name": "chat_conversations_participant1_idx", + "columns": [ + { + "expression": "participant1_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_conversations_last_message_idx": { + "name": "chat_conversations_last_message_idx", + "columns": [ + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_conversations_unique": { + "name": "chat_conversations_unique", + "columns": [ + { + "expression": "participant1_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "participant2_handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_conversations_participant1_id_users_id_fk": { + "name": "chat_conversations_participant1_id_users_id_fk", + "tableFrom": "chat_conversations", + "tableTo": "users", + "columnsFrom": [ + "participant1_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_handle": { + "name": "sender_handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_avatar_url": { + "name": "sender_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_node_domain": { + "name": "sender_node_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_content": { + "name": "encrypted_content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "swarm_message_id": { + "name": "swarm_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_conversation_idx": { + "name": "chat_messages_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_messages_created_idx": { + "name": "chat_messages_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_messages_swarm_id_idx": { + "name": "chat_messages_swarm_id_idx", + "columns": [ + { + "expression": "swarm_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_conversation_id_chat_conversations_id_fk": { + "name": "chat_messages_conversation_id_chat_conversations_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_messages_swarm_message_id_unique": { + "name": "chat_messages_swarm_message_id_unique", + "nullsNotDistinct": false, + "columns": [ + "swarm_message_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_typing_indicators": { + "name": "chat_typing_indicators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_handle": { + "name": "user_handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_typing_conversation_idx": { + "name": "chat_typing_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_typing_expires_idx": { + "name": "chat_typing_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_typing_unique": { + "name": "chat_typing_unique", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_typing_indicators_conversation_id_chat_conversations_id_fk": { + "name": "chat_typing_indicators_conversation_id_chat_conversations_id_fk", + "tableFrom": "chat_typing_indicators", + "tableTo": "chat_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ap_id": { + "name": "ap_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pending": { + "name": "pending", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "follows_follower_idx": { + "name": "follows_follower_idx", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "follows_following_idx": { + "name": "follows_following_idx", + "columns": [ + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follows_follower_id_users_id_fk": { + "name": "follows_follower_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follows_following_id_users_id_fk": { + "name": "follows_following_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "follows_ap_id_unique": { + "name": "follows_ap_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ap_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.handle_registry": { + "name": "handle_registry", + "schema": "", + "columns": { + "handle": { + "name": "handle", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_domain": { + "name": "node_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "handle_registry_updated_idx": { + "name": "handle_registry_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.likes": { + "name": "likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ap_id": { + "name": "ap_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "likes_user_post_idx": { + "name": "likes_user_post_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "likes_user_id_users_id_fk": { + "name": "likes_user_id_users_id_fk", + "tableFrom": "likes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "likes_post_id_posts_id_fk": { + "name": "likes_post_id_posts_id_fk", + "tableFrom": "likes", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "likes_ap_id_unique": { + "name": "likes_ap_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ap_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_user_idx": { + "name": "media_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_post_idx": { + "name": "media_post_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_user_id_users_id_fk": { + "name": "media_user_id_users_id_fk", + "tableFrom": "media", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_post_id_posts_id_fk": { + "name": "media_post_id_posts_id_fk", + "tableFrom": "media", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.muted_nodes": { + "name": "muted_nodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "node_domain": { + "name": "node_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "muted_nodes_user_idx": { + "name": "muted_nodes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "muted_nodes_domain_idx": { + "name": "muted_nodes_domain_idx", + "columns": [ + { + "expression": "node_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "muted_nodes_user_id_users_id_fk": { + "name": "muted_nodes_user_id_users_id_fk", + "tableFrom": "muted_nodes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mutes": { + "name": "mutes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "muted_user_id": { + "name": "muted_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mutes_user_idx": { + "name": "mutes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mutes_muted_user_idx": { + "name": "mutes_muted_user_idx", + "columns": [ + { + "expression": "muted_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mutes_user_id_users_id_fk": { + "name": "mutes_user_id_users_id_fk", + "tableFrom": "mutes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mutes_muted_user_id_users_id_fk": { + "name": "mutes_muted_user_id_users_id_fk", + "tableFrom": "mutes", + "tableTo": "users", + "columnsFrom": [ + "muted_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nodes": { + "name": "nodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "long_description": { + "name": "long_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rules": { + "name": "rules", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#FFFFFF'" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_nsfw": { + "name": "is_nsfw", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "turnstile_site_key": { + "name": "turnstile_site_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "turnstile_secret_key": { + "name": "turnstile_secret_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "nodes_domain_unique": { + "name": "nodes_domain_unique", + "nullsNotDistinct": false, + "columns": [ + "domain" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_handle": { + "name": "actor_handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_display_name": { + "name": "actor_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_avatar_url": { + "name": "actor_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_node_domain": { + "name": "actor_node_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "post_content": { + "name": "post_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_idx": { + "name": "notifications_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_idx": { + "name": "notifications_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_actor_id_users_id_fk": { + "name": "notifications_actor_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_post_id_posts_id_fk": { + "name": "notifications_post_id_posts_id_fk", + "tableFrom": "notifications", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reply_to_id": { + "name": "reply_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repost_of_id": { + "name": "repost_of_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "swarm_reply_to_id": { + "name": "swarm_reply_to_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "swarm_reply_to_content": { + "name": "swarm_reply_to_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "swarm_reply_to_author": { + "name": "swarm_reply_to_author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reposts_count": { + "name": "reposts_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "replies_count": { + "name": "replies_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_nsfw": { + "name": "is_nsfw", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_removed": { + "name": "is_removed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "removed_by": { + "name": "removed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "removed_reason": { + "name": "removed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ap_id": { + "name": "ap_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ap_url": { + "name": "ap_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_url": { + "name": "link_preview_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_title": { + "name": "link_preview_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_description": { + "name": "link_preview_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_image": { + "name": "link_preview_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "posts_user_id_idx": { + "name": "posts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_bot_id_idx": { + "name": "posts_bot_id_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_created_at_idx": { + "name": "posts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_reply_to_idx": { + "name": "posts_reply_to_idx", + "columns": [ + { + "expression": "reply_to_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_removed_idx": { + "name": "posts_removed_idx", + "columns": [ + { + "expression": "is_removed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_nsfw_idx": { + "name": "posts_nsfw_idx", + "columns": [ + { + "expression": "is_nsfw", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_user_id_users_id_fk": { + "name": "posts_user_id_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_bot_id_bots_id_fk": { + "name": "posts_bot_id_bots_id_fk", + "tableFrom": "posts", + "tableTo": "bots", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "posts_removed_by_users_id_fk": { + "name": "posts_removed_by_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "columnsFrom": [ + "removed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "posts_ap_id_unique": { + "name": "posts_ap_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ap_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.remote_followers": { + "name": "remote_followers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_url": { + "name": "actor_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inbox_url": { + "name": "inbox_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_inbox_url": { + "name": "shared_inbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "remote_followers_user_idx": { + "name": "remote_followers_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_followers_actor_idx": { + "name": "remote_followers_actor_idx", + "columns": [ + { + "expression": "actor_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_followers_user_actor_unique": { + "name": "remote_followers_user_actor_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "remote_followers_user_id_users_id_fk": { + "name": "remote_followers_user_id_users_id_fk", + "tableFrom": "remote_followers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.remote_follows": { + "name": "remote_follows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_actor_url": { + "name": "target_actor_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inbox_url": { + "name": "inbox_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "remote_follows_follower_idx": { + "name": "remote_follows_follower_idx", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_follows_target_idx": { + "name": "remote_follows_target_idx", + "columns": [ + { + "expression": "target_handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "remote_follows_follower_id_users_id_fk": { + "name": "remote_follows_follower_id_users_id_fk", + "tableFrom": "remote_follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.remote_likes": { + "name": "remote_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_handle": { + "name": "actor_handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_node_domain": { + "name": "actor_node_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "remote_likes_post_idx": { + "name": "remote_likes_post_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_likes_actor_idx": { + "name": "remote_likes_actor_idx", + "columns": [ + { + "expression": "actor_handle", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_node_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_likes_unique": { + "name": "remote_likes_unique", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_handle", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_node_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "remote_likes_post_id_posts_id_fk": { + "name": "remote_likes_post_id_posts_id_fk", + "tableFrom": "remote_likes", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.remote_posts": { + "name": "remote_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ap_id": { + "name": "ap_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_handle": { + "name": "author_handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_actor_url": { + "name": "author_actor_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_display_name": { + "name": "author_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "link_preview_url": { + "name": "link_preview_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_title": { + "name": "link_preview_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_description": { + "name": "link_preview_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_image": { + "name": "link_preview_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media_json": { + "name": "media_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "remote_posts_author_idx": { + "name": "remote_posts_author_idx", + "columns": [ + { + "expression": "author_handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_posts_published_idx": { + "name": "remote_posts_published_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_posts_ap_id_idx": { + "name": "remote_posts_ap_id_idx", + "columns": [ + { + "expression": "ap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "remote_posts_ap_id_unique": { + "name": "remote_posts_ap_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ap_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.remote_reposts": { + "name": "remote_reposts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_handle": { + "name": "actor_handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_node_domain": { + "name": "actor_node_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "remote_reposts_post_idx": { + "name": "remote_reposts_post_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_reposts_actor_idx": { + "name": "remote_reposts_actor_idx", + "columns": [ + { + "expression": "actor_handle", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_node_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_reposts_unique": { + "name": "remote_reposts_unique", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_handle", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_node_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "remote_reposts_post_id_posts_id_fk": { + "name": "remote_reposts_post_id_posts_id_fk", + "tableFrom": "remote_reposts", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_target_idx": { + "name": "reports_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_reporter_idx": { + "name": "reports_reporter_idx", + "columns": [ + { + "expression": "reporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reports_reporter_id_users_id_fk": { + "name": "reports_reporter_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reports_resolved_by_users_id_fk": { + "name": "reports_resolved_by_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_token_idx": { + "name": "sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.swarm_nodes": { + "name": "swarm_nodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_count": { + "name": "user_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "post_count": { + "name": "post_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_nsfw": { + "name": "is_nsfw", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discovered_via": { + "name": "discovered_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "swarm_nodes_domain_idx": { + "name": "swarm_nodes_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "swarm_nodes_active_idx": { + "name": "swarm_nodes_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "swarm_nodes_last_seen_idx": { + "name": "swarm_nodes_last_seen_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "swarm_nodes_trust_idx": { + "name": "swarm_nodes_trust_idx", + "columns": [ + { + "expression": "trust_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "swarm_nodes_nsfw_idx": { + "name": "swarm_nodes_nsfw_idx", + "columns": [ + { + "expression": "is_nsfw", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "swarm_nodes_domain_unique": { + "name": "swarm_nodes_domain_unique", + "nullsNotDistinct": false, + "columns": [ + "domain" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.swarm_seeds": { + "name": "swarm_seeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_contact_at": { + "name": "last_contact_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "swarm_seeds_enabled_idx": { + "name": "swarm_seeds_enabled_idx", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "swarm_seeds_priority_idx": { + "name": "swarm_seeds_priority_idx", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "swarm_seeds_domain_unique": { + "name": "swarm_seeds_domain_unique", + "nullsNotDistinct": false, + "columns": [ + "domain" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.swarm_sync_log": { + "name": "swarm_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "remote_domain": { + "name": "remote_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nodes_received": { + "name": "nodes_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "nodes_sent": { + "name": "nodes_sent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "handles_received": { + "name": "handles_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "handles_sent": { + "name": "handles_sent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "swarm_sync_log_remote_idx": { + "name": "swarm_sync_log_remote_idx", + "columns": [ + { + "expression": "remote_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "swarm_sync_log_created_idx": { + "name": "swarm_sync_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "header_url": { + "name": "header_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_key_encrypted": { + "name": "private_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bot_owner_id": { + "name": "bot_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_nsfw": { + "name": "is_nsfw", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "nsfw_enabled": { + "name": "nsfw_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "age_verified_at": { + "name": "age_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspension_reason": { + "name": "suspension_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_silenced": { + "name": "is_silenced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "silence_reason": { + "name": "silence_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "silenced_at": { + "name": "silenced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "moved_to": { + "name": "moved_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moved_from": { + "name": "moved_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "migrated_at": { + "name": "migrated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "followers_count": { + "name": "followers_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "following_count": { + "name": "following_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "posts_count": { + "name": "posts_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_handle_idx": { + "name": "users_handle_idx", + "columns": [ + { + "expression": "handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_did_idx": { + "name": "users_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_suspended_idx": { + "name": "users_suspended_idx", + "columns": [ + { + "expression": "is_suspended", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_silenced_idx": { + "name": "users_silenced_idx", + "columns": [ + { + "expression": "is_silenced", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_is_bot_idx": { + "name": "users_is_bot_idx", + "columns": [ + { + "expression": "is_bot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_bot_owner_idx": { + "name": "users_bot_owner_idx", + "columns": [ + { + "expression": "bot_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_nsfw_idx": { + "name": "users_nsfw_idx", + "columns": [ + { + "expression": "is_nsfw", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_node_id_nodes_id_fk": { + "name": "users_node_id_nodes_id_fk", + "tableFrom": "users", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_bot_owner_id_users_id_fk": { + "name": "users_bot_owner_id_users_id_fk", + "tableFrom": "users", + "tableTo": "users", + "columnsFrom": [ + "bot_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_did_unique": { + "name": "users_did_unique", + "nullsNotDistinct": false, + "columns": [ + "did" + ] + }, + "users_handle_unique": { + "name": "users_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index af77ea8..84ecbed 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1769446756226, "tag": "0007_kind_agent_zero", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1769453302714, + "tag": "0008_illegal_carlie_cooper", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/swarm/chat/conversations/route.ts b/src/app/api/swarm/chat/conversations/route.ts new file mode 100644 index 0000000..0ace504 --- /dev/null +++ b/src/app/api/swarm/chat/conversations/route.ts @@ -0,0 +1,87 @@ +/** + * Swarm Chat Conversations + * + * GET: List all conversations for the current user + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, chatConversations, chatMessages, users } from '@/db'; +import { eq, desc, and, isNull, sql } from 'drizzle-orm'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: NextRequest) { + try { + if (!db) { + return NextResponse.json({ conversations: [] }); + } + + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get all conversations for this user + const conversations = await db.query.chatConversations.findMany({ + where: eq(chatConversations.participant1Id, session.user.id), + orderBy: [desc(chatConversations.lastMessageAt)], + with: { + messages: { + orderBy: [desc(chatMessages.createdAt)], + limit: 1, + }, + }, + }); + + // Calculate unread count for each conversation + const conversationsWithUnread = await Promise.all( + conversations.map(async (conv) => { + const unreadCount = await db + .select({ count: sql`count(*)` }) + .from(chatMessages) + .where( + and( + eq(chatMessages.conversationId, conv.id), + isNull(chatMessages.readAt), + sql`${chatMessages.senderHandle} != ${session.user.handle}` + ) + ); + + // Parse participant info + const participant2Handle = conv.participant2Handle; + const isRemote = participant2Handle.includes('@'); + + let participant2Info = { + handle: participant2Handle, + displayName: participant2Handle, + avatarUrl: null as string | null, + }; + + // Try to get cached user info + const cachedUser = await db.query.users.findFirst({ + where: eq(users.handle, participant2Handle), + }); + + if (cachedUser) { + participant2Info = { + handle: cachedUser.handle, + displayName: cachedUser.displayName || cachedUser.handle, + avatarUrl: cachedUser.avatarUrl, + }; + } + + return { + ...conv, + participant2: participant2Info, + unreadCount: Number(unreadCount[0]?.count || 0), + }; + }) + ); + + return NextResponse.json({ + conversations: conversationsWithUnread, + }); + } catch (error) { + console.error('List conversations error:', error); + return NextResponse.json({ error: 'Failed to list conversations' }, { status: 500 }); + } +} diff --git a/src/app/api/swarm/chat/inbox/route.ts b/src/app/api/swarm/chat/inbox/route.ts new file mode 100644 index 0000000..a25f0f5 --- /dev/null +++ b/src/app/api/swarm/chat/inbox/route.ts @@ -0,0 +1,115 @@ +/** + * Swarm Chat Inbox + * + * POST: Receives chat messages from other swarm nodes + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, users, chatConversations, chatMessages } from '@/db'; +import { eq, and } from 'drizzle-orm'; +import { z } from 'zod'; +import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types'; + +const chatMessageSchema = z.object({ + messageId: z.string(), + senderHandle: z.string(), + senderDisplayName: z.string().optional(), + senderAvatarUrl: z.string().optional(), + senderNodeDomain: z.string(), + recipientHandle: z.string(), + encryptedContent: z.string(), + timestamp: z.string(), + signature: z.string().optional(), +}); + +export async function POST(request: NextRequest) { + try { + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 503 }); + } + + const body = await request.json(); + const data = chatMessageSchema.parse(body) as SwarmChatMessagePayload; + + // Find the recipient (local user) + const recipient = await db.query.users.findFirst({ + where: eq(users.handle, data.recipientHandle.toLowerCase()), + }); + + if (!recipient) { + return NextResponse.json({ error: 'Recipient not found' }, { status: 404 }); + } + + if (recipient.isSuspended) { + return NextResponse.json({ error: 'Recipient not available' }, { status: 404 }); + } + + // Check if message already exists (prevent duplicates) + const swarmMessageId = `swarm:${data.senderNodeDomain}:${data.messageId}`; + const existingMessage = await db.query.chatMessages.findFirst({ + where: eq(chatMessages.swarmMessageId, swarmMessageId), + }); + + if (existingMessage) { + return NextResponse.json({ + success: true, + message: 'Message already received', + }); + } + + // Get or create conversation + const senderFullHandle = `${data.senderHandle}@${data.senderNodeDomain}`; + let conversation = await db.query.chatConversations.findFirst({ + where: and( + eq(chatConversations.participant1Id, recipient.id), + eq(chatConversations.participant2Handle, senderFullHandle) + ), + }); + + if (!conversation) { + const [newConversation] = await db.insert(chatConversations).values({ + participant1Id: recipient.id, + participant2Handle: senderFullHandle, + lastMessageAt: new Date(data.timestamp), + lastMessagePreview: '[Encrypted message]', + }).returning(); + conversation = newConversation; + } + + // Store the message + const [newMessage] = await db.insert(chatMessages).values({ + conversationId: conversation.id, + senderHandle: senderFullHandle, + senderDisplayName: data.senderDisplayName, + senderAvatarUrl: data.senderAvatarUrl, + senderNodeDomain: data.senderNodeDomain, + encryptedContent: data.encryptedContent, + swarmMessageId, + deliveredAt: new Date(), + createdAt: new Date(data.timestamp), + }).returning(); + + // Update conversation last message + await db.update(chatConversations) + .set({ + lastMessageAt: new Date(data.timestamp), + lastMessagePreview: '[Encrypted message]', + updatedAt: new Date(), + }) + .where(eq(chatConversations.id, conversation.id)); + + console.log(`[Swarm Chat] Received message from ${senderFullHandle} to ${data.recipientHandle}`); + + return NextResponse.json({ + success: true, + message: 'Message received', + messageId: newMessage.id, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 }); + } + console.error('Swarm chat inbox error:', error); + return NextResponse.json({ error: 'Failed to receive message' }, { status: 500 }); + } +} diff --git a/src/app/api/swarm/chat/messages/route.ts b/src/app/api/swarm/chat/messages/route.ts new file mode 100644 index 0000000..cbf38f7 --- /dev/null +++ b/src/app/api/swarm/chat/messages/route.ts @@ -0,0 +1,136 @@ +/** + * Swarm Chat Messages + * + * GET: Get messages for a conversation + * PATCH: Mark messages as read + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, chatConversations, chatMessages, users } from '@/db'; +import { eq, desc, and, lt } from 'drizzle-orm'; +import { getSession } from '@/lib/auth'; +import { decryptMessage } from '@/lib/swarm/chat-crypto'; + +export async function GET(request: NextRequest) { + try { + if (!db) { + return NextResponse.json({ messages: [] }); + } + + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const conversationId = searchParams.get('conversationId'); + const cursor = searchParams.get('cursor'); // For pagination + const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100); + + if (!conversationId) { + return NextResponse.json({ error: 'conversationId required' }, { status: 400 }); + } + + // Verify user has access to this conversation + const conversation = await db.query.chatConversations.findFirst({ + where: and( + eq(chatConversations.id, conversationId), + eq(chatConversations.participant1Id, session.user.id) + ), + }); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }); + } + + // Get user's private key for decryption + const user = await db.query.users.findFirst({ + where: eq(users.id, session.user.id), + }); + + if (!user?.privateKeyEncrypted) { + return NextResponse.json({ error: 'Cannot decrypt messages' }, { status: 500 }); + } + + // Build query with cursor-based pagination + let whereCondition = eq(chatMessages.conversationId, conversationId); + if (cursor) { + whereCondition = and(whereCondition, lt(chatMessages.createdAt, new Date(cursor))); + } + + // Get messages + const messages = await db.query.chatMessages.findMany({ + where: whereCondition, + orderBy: [desc(chatMessages.createdAt)], + limit, + }); + + // Decrypt messages + // Note: In production, you'd decrypt the private key first using a user password/session key + // For now, we'll return encrypted content and decrypt client-side + const messagesWithDecryption = messages.map((msg) => { + const isSentByMe = msg.senderHandle === session.user.handle; + + return { + ...msg, + // Only include encrypted content - client will decrypt + content: undefined, // Remove plaintext + isSentByMe, + }; + }); + + return NextResponse.json({ + messages: messagesWithDecryption.reverse(), // Oldest first for display + nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null, + }); + } catch (error) { + console.error('Get messages error:', error); + return NextResponse.json({ error: 'Failed to get messages' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest) { + try { + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 503 }); + } + + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { conversationId } = await request.json(); + + if (!conversationId) { + return NextResponse.json({ error: 'conversationId required' }, { status: 400 }); + } + + // Verify user has access to this conversation + const conversation = await db.query.chatConversations.findFirst({ + where: and( + eq(chatConversations.id, conversationId), + eq(chatConversations.participant1Id, session.user.id) + ), + }); + + if (!conversation) { + return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }); + } + + // Mark all unread messages as read + await db.update(chatMessages) + .set({ readAt: new Date() }) + .where( + and( + eq(chatMessages.conversationId, conversationId), + eq(chatMessages.readAt, null) + ) + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Mark as read error:', error); + return NextResponse.json({ error: 'Failed to mark as read' }, { status: 500 }); + } +} diff --git a/src/app/api/swarm/chat/send/route.ts b/src/app/api/swarm/chat/send/route.ts new file mode 100644 index 0000000..7557d30 --- /dev/null +++ b/src/app/api/swarm/chat/send/route.ts @@ -0,0 +1,198 @@ +/** + * Swarm Chat Send + * + * POST: Send a chat message to another user (local or remote) + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, users, chatConversations, chatMessages } from '@/db'; +import { eq, and } from 'drizzle-orm'; +import { z } from 'zod'; +import { getSession } from '@/lib/auth'; +import { encryptMessage } from '@/lib/swarm/chat-crypto'; +import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types'; + +const sendMessageSchema = z.object({ + recipientHandle: z.string(), + content: z.string().min(1).max(5000), +}); + +export async function POST(request: NextRequest) { + try { + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 503 }); + } + + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const data = sendMessageSchema.parse(body); + + // Get sender info + const sender = await db.query.users.findFirst({ + where: eq(users.id, session.user.id), + }); + + if (!sender) { + return NextResponse.json({ error: 'Sender not found' }, { status: 404 }); + } + + // Parse recipient handle (could be local or remote) + const recipientHandle = data.recipientHandle.toLowerCase(); + const isRemote = recipientHandle.includes('@'); + + let recipientUser: typeof users.$inferSelect | undefined; + let recipientPublicKey: string; + let recipientNodeDomain: string | null = null; + + if (isRemote) { + // Remote user - need to fetch their public key + const [handle, domain] = recipientHandle.split('@'); + recipientNodeDomain = domain; + + // Try to find cached remote user + recipientUser = await db.query.users.findFirst({ + where: eq(users.handle, recipientHandle), + }); + + if (!recipientUser) { + // Fetch from remote node + try { + const protocol = domain.includes('localhost') ? 'http' : 'https'; + const response = await fetch(`${protocol}://${domain}/api/users/${handle}`); + + if (!response.ok) { + return NextResponse.json({ error: 'Recipient not found' }, { status: 404 }); + } + + const remoteUserData = await response.json(); + recipientPublicKey = remoteUserData.publicKey; + + // Cache the remote user + const [newUser] = await db.insert(users).values({ + did: remoteUserData.did || `did:swarm:${domain}:${handle}`, + handle: recipientHandle, + displayName: remoteUserData.displayName, + avatarUrl: remoteUserData.avatarUrl, + publicKey: recipientPublicKey, + }).returning(); + recipientUser = newUser; + } catch (error) { + console.error('Failed to fetch remote user:', error); + return NextResponse.json({ error: 'Failed to reach recipient node' }, { status: 503 }); + } + } else { + recipientPublicKey = recipientUser.publicKey; + } + } else { + // Local user + recipientUser = await db.query.users.findFirst({ + where: eq(users.handle, recipientHandle), + }); + + if (!recipientUser) { + return NextResponse.json({ error: 'Recipient not found' }, { status: 404 }); + } + + if (recipientUser.isSuspended) { + return NextResponse.json({ error: 'Recipient not available' }, { status: 404 }); + } + + recipientPublicKey = recipientUser.publicKey; + } + + // Encrypt the message with recipient's public key + const encryptedContent = encryptMessage(data.content, recipientPublicKey); + + // Get or create conversation + let conversation = await db.query.chatConversations.findFirst({ + where: and( + eq(chatConversations.participant1Id, sender.id), + eq(chatConversations.participant2Handle, recipientHandle) + ), + }); + + if (!conversation) { + const [newConversation] = await db.insert(chatConversations).values({ + participant1Id: sender.id, + participant2Handle: recipientHandle, + lastMessageAt: new Date(), + lastMessagePreview: data.content.substring(0, 100), + }).returning(); + conversation = newConversation; + } + + // Store the message locally + const messageId = crypto.randomUUID(); + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; + const swarmMessageId = `swarm:${nodeDomain}:${messageId}`; + + const [newMessage] = await db.insert(chatMessages).values({ + conversationId: conversation.id, + senderHandle: sender.handle, + senderDisplayName: sender.displayName, + senderAvatarUrl: sender.avatarUrl, + senderNodeDomain: null, // Local sender + encryptedContent, + swarmMessageId, + deliveredAt: isRemote ? null : new Date(), // Delivered immediately if local + readAt: null, + }).returning(); + + // Update conversation + await db.update(chatConversations) + .set({ + lastMessageAt: new Date(), + lastMessagePreview: data.content.substring(0, 100), + updatedAt: new Date(), + }) + .where(eq(chatConversations.id, conversation.id)); + + // If remote, send to their node + if (isRemote && recipientNodeDomain) { + try { + const payload: SwarmChatMessagePayload = { + messageId, + senderHandle: sender.handle, + senderDisplayName: sender.displayName || undefined, + senderAvatarUrl: sender.avatarUrl || undefined, + senderNodeDomain: nodeDomain, + recipientHandle: recipientHandle.split('@')[0], + encryptedContent, + timestamp: new Date().toISOString(), + }; + + const protocol = recipientNodeDomain.includes('localhost') ? 'http' : 'https'; + const response = await fetch(`${protocol}://${recipientNodeDomain}/api/swarm/chat/inbox`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + // Mark as delivered + await db.update(chatMessages) + .set({ deliveredAt: new Date() }) + .where(eq(chatMessages.id, newMessage.id)); + } + } catch (error) { + console.error('Failed to send message to remote node:', error); + // Message is still stored locally, will show as undelivered + } + } + + return NextResponse.json({ + success: true, + message: newMessage, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 }); + } + console.error('Send message error:', error); + return NextResponse.json({ error: 'Failed to send message' }, { status: 500 }); + } +} diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx new file mode 100644 index 0000000..5968a53 --- /dev/null +++ b/src/app/chat/page.tsx @@ -0,0 +1,644 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useAuth } from '@/lib/contexts/AuthContext'; +import { MessageCircle, Send, ArrowLeft } from 'lucide-react'; +import { formatFullHandle } from '@/lib/utils/handle'; + +interface Conversation { + id: string; + participant2: { + handle: string; + displayName: string; + avatarUrl: string | null; + }; + lastMessageAt: string; + lastMessagePreview: string; + unreadCount: number; +} + +interface Message { + id: string; + senderHandle: string; + senderDisplayName?: string; + senderAvatarUrl?: string; + encryptedContent: string; + isSentByMe: boolean; + deliveredAt?: string; + readAt?: string; + createdAt: string; +} + +export default function ChatPage() { + const { user } = useAuth(); + const [conversations, setConversations] = useState([]); + const [selectedConversation, setSelectedConversation] = useState(null); + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [newChatHandle, setNewChatHandle] = useState(''); + const [showNewChat, setShowNewChat] = useState(false); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const messagesEndRef = useRef(null); + + useEffect(() => { + if (user) { + loadConversations(); + } + }, [user]); + + useEffect(() => { + if (selectedConversation) { + loadMessages(selectedConversation.id); + markAsRead(selectedConversation.id); + } + }, [selectedConversation]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const loadConversations = async () => { + try { + const res = await fetch('/api/swarm/chat/conversations'); + const data = await res.json(); + setConversations(data.conversations || []); + } catch (error) { + console.error('Failed to load conversations:', error); + } finally { + setLoading(false); + } + }; + + const loadMessages = async (conversationId: string) => { + try { + const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`); + const data = await res.json(); + setMessages(data.messages || []); + } catch (error) { + console.error('Failed to load messages:', error); + } + }; + + const markAsRead = async (conversationId: string) => { + try { + await fetch('/api/swarm/chat/messages', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ conversationId }), + }); + // Update unread count locally + setConversations(prev => + prev.map(c => c.id === conversationId ? { ...c, unreadCount: 0 } : c) + ); + } catch (error) { + console.error('Failed to mark as read:', error); + } + }; + + const sendMessage = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newMessage.trim() || !selectedConversation) return; + + setSending(true); + try { + const res = await fetch('/api/swarm/chat/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipientHandle: selectedConversation.participant2.handle, + content: newMessage, + }), + }); + + if (res.ok) { + const data = await res.json(); + setMessages(prev => [...prev, data.message]); + setNewMessage(''); + loadConversations(); // Refresh to update last message + } + } catch (error) { + console.error('Failed to send message:', error); + } finally { + setSending(false); + } + }; + + const startNewChat = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newChatHandle.trim()) return; + + setSending(true); + try { + const res = await fetch('/api/swarm/chat/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipientHandle: newChatHandle, + content: 'Hey! 👋', + }), + }); + + if (res.ok) { + setShowNewChat(false); + setNewChatHandle(''); + loadConversations(); + } + } catch (error) { + console.error('Failed to start chat:', error); + } finally { + setSending(false); + } + }; + + if (!user) { + return ( +
+
+ +

Sign in to use Swarm Chat

+
+
+ ); + } + + return ( +
+
+ {/* Conversations List */} +
+
+

Swarm Chat

+ +
+ + {loading ? ( +
+ Loading... +
+ ) : conversations.length === 0 ? ( +
+ +

No conversations yet

+

+ Start a chat with anyone on the swarm +

+
+ ) : ( +
+ {conversations.map((conv) => ( + + ))} +
+ )} +
+ + {/* Messages View */} +
+ {selectedConversation ? ( + <> +
+ +
+ {selectedConversation.participant2.avatarUrl ? ( + + ) : ( + selectedConversation.participant2.displayName.charAt(0).toUpperCase() + )} +
+
+
+ {selectedConversation.participant2.displayName} +
+
+ {formatFullHandle(selectedConversation.participant2.handle)} +
+
+
+ +
+ {messages.map((msg) => ( +
+ {!msg.isSentByMe && ( +
+ {msg.senderAvatarUrl ? ( + + ) : ( + (msg.senderDisplayName || msg.senderHandle).charAt(0).toUpperCase() + )} +
+ )} +
+
+ {/* Note: In production, decrypt client-side */} +
+ [Encrypted: {msg.encryptedContent.substring(0, 20)}...] +
+
+
+ {new Date(msg.createdAt).toLocaleTimeString()} + {msg.isSentByMe && ( + + {msg.readAt ? '✓✓' : msg.deliveredAt ? '✓' : '○'} + + )} +
+
+
+ ))} +
+
+ +
+ setNewMessage(e.target.value)} + disabled={sending} + /> + +
+ + ) : ( +
+ +

Select a conversation to start chatting

+
+ )} +
+ + {/* New Chat Modal */} + {showNewChat && ( +
setShowNewChat(false)}> +
e.stopPropagation()}> +

Start New Chat

+
+ setNewChatHandle(e.target.value)} + autoFocus + /> +
+ + +
+
+
+
+ )} +
+ + +
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 0dbdc2c..df061b1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -100,6 +100,14 @@ export function Sidebar() { )} )} + {user && ( + + + + + Chat + + )} {user && ( diff --git a/src/db/schema.ts b/src/db/schema.ts index 9d80259..e3f2243 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -864,3 +864,102 @@ export const swarmSyncLog = pgTable('swarm_sync_log', { index('swarm_sync_log_remote_idx').on(table.remoteDomain), index('swarm_sync_log_created_idx').on(table.createdAt), ]); + +// ============================================ +// SWARM CHAT +// ============================================ + +/** + * Chat conversations between users across the swarm. + * Each conversation has a unique ID and tracks participants. + */ +export const chatConversations = pgTable('chat_conversations', { + id: uuid('id').primaryKey().defaultRandom(), + + // Conversation type: 'direct' (1-on-1) or 'group' (future) + type: text('type').default('direct').notNull(), + + // For direct chats, store both participants + participant1Id: uuid('participant1_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + participant2Handle: text('participant2_handle').notNull(), // Can be local or remote (user@domain) + + // Last message info for sorting + lastMessageAt: timestamp('last_message_at'), + lastMessagePreview: text('last_message_preview'), + + // Metadata + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}, (table) => [ + index('chat_conversations_participant1_idx').on(table.participant1Id), + index('chat_conversations_last_message_idx').on(table.lastMessageAt), + // Ensure unique conversation between two users + uniqueIndex('chat_conversations_unique').on(table.participant1Id, table.participant2Handle), +]); + +export const chatConversationsRelations = relations(chatConversations, ({ one, many }) => ({ + participant1: one(users, { + fields: [chatConversations.participant1Id], + references: [users.id], + }), + messages: many(chatMessages), +})); + +/** + * Individual chat messages within conversations. + * Messages are encrypted end-to-end using recipient's public key. + */ +export const chatMessages = pgTable('chat_messages', { + id: uuid('id').primaryKey().defaultRandom(), + + // Which conversation this belongs to + conversationId: uuid('conversation_id').notNull().references(() => chatConversations.id, { onDelete: 'cascade' }), + + // Sender info + senderHandle: text('sender_handle').notNull(), // Can be local or remote + senderDisplayName: text('sender_display_name'), + senderAvatarUrl: text('sender_avatar_url'), + senderNodeDomain: text('sender_node_domain'), // null if local + + // Message content (encrypted for recipient) + encryptedContent: text('encrypted_content').notNull(), + + // Swarm sync info + swarmMessageId: text('swarm_message_id').unique(), // Format: swarm:domain:uuid + + // Status tracking + deliveredAt: timestamp('delivered_at'), + readAt: timestamp('read_at'), + + // Metadata + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => [ + index('chat_messages_conversation_idx').on(table.conversationId), + index('chat_messages_created_idx').on(table.createdAt), + index('chat_messages_swarm_id_idx').on(table.swarmMessageId), +]); + +export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({ + conversation: one(chatConversations, { + fields: [chatMessages.conversationId], + references: [chatConversations.id], + }), +})); + +/** + * Typing indicators for real-time chat UX. + * Short-lived records that expire after 10 seconds. + */ +export const chatTypingIndicators = pgTable('chat_typing_indicators', { + id: uuid('id').primaryKey().defaultRandom(), + + conversationId: uuid('conversation_id').notNull().references(() => chatConversations.id, { onDelete: 'cascade' }), + userHandle: text('user_handle').notNull(), + + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => [ + index('chat_typing_conversation_idx').on(table.conversationId), + index('chat_typing_expires_idx').on(table.expiresAt), + uniqueIndex('chat_typing_unique').on(table.conversationId, table.userHandle), +]); diff --git a/src/lib/swarm/chat-crypto.ts b/src/lib/swarm/chat-crypto.ts new file mode 100644 index 0000000..55af9ac --- /dev/null +++ b/src/lib/swarm/chat-crypto.ts @@ -0,0 +1,83 @@ +/** + * Swarm Chat Cryptography + * + * End-to-end encryption for chat messages using public key cryptography. + */ + +import crypto from 'crypto'; + +/** + * Encrypt a message using the recipient's public key + */ +export function encryptMessage(message: string, recipientPublicKey: string): string { + try { + // Use RSA-OAEP for encryption + const encrypted = crypto.publicEncrypt( + { + key: recipientPublicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, + Buffer.from(message, 'utf8') + ); + + return encrypted.toString('base64'); + } catch (error) { + console.error('Failed to encrypt message:', error); + throw new Error('Encryption failed'); + } +} + +/** + * Decrypt a message using the recipient's private key + */ +export function decryptMessage(encryptedMessage: string, privateKey: string): string { + try { + const decrypted = crypto.privateDecrypt( + { + key: privateKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, + Buffer.from(encryptedMessage, 'base64') + ); + + return decrypted.toString('utf8'); + } catch (error) { + console.error('Failed to decrypt message:', error); + throw new Error('Decryption failed'); + } +} + +/** + * Sign a message payload for authenticity verification + */ +export function signPayload(payload: string, privateKey: string): string { + try { + const sign = crypto.createSign('SHA256'); + sign.update(payload); + sign.end(); + + const signature = sign.sign(privateKey, 'base64'); + return signature; + } catch (error) { + console.error('Failed to sign payload:', error); + throw new Error('Signing failed'); + } +} + +/** + * Verify a signed payload + */ +export function verifySignature(payload: string, signature: string, publicKey: string): boolean { + try { + const verify = crypto.createVerify('SHA256'); + verify.update(payload); + verify.end(); + + return verify.verify(publicKey, signature, 'base64'); + } catch (error) { + console.error('Failed to verify signature:', error); + return false; + } +} diff --git a/src/lib/swarm/chat-types.ts b/src/lib/swarm/chat-types.ts new file mode 100644 index 0000000..46f9f65 --- /dev/null +++ b/src/lib/swarm/chat-types.ts @@ -0,0 +1,66 @@ +/** + * Swarm Chat Types + * + * Type definitions for the swarm chat system. + */ + +export interface SwarmChatMessage { + id: string; + conversationId: string; + senderHandle: string; + senderDisplayName?: string; + senderAvatarUrl?: string; + senderNodeDomain?: string; + encryptedContent: string; + deliveredAt?: string; + readAt?: string; + createdAt: string; +} + +export interface SwarmChatConversation { + id: string; + type: 'direct' | 'group'; + participant1Id: string; + participant2Handle: string; + lastMessageAt?: string; + lastMessagePreview?: string; + unreadCount?: number; + createdAt: string; + updatedAt: string; +} + +/** + * Payload for sending a chat message to a remote node + */ +export interface SwarmChatMessagePayload { + messageId: string; + senderHandle: string; + senderDisplayName?: string; + senderAvatarUrl?: string; + senderNodeDomain: string; + recipientHandle: string; + encryptedContent: string; + timestamp: string; + signature?: string; +} + +/** + * Payload for typing indicator + */ +export interface SwarmChatTypingPayload { + senderHandle: string; + senderNodeDomain: string; + recipientHandle: string; + isTyping: boolean; + timestamp: string; +} + +/** + * Payload for read receipt + */ +export interface SwarmChatReadReceiptPayload { + messageId: string; + readerHandle: string; + readerNodeDomain: string; + timestamp: string; +}