From b46be5c0762161a6f74400505acdf72226324905 Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Wed, 15 Jul 2026 06:40:06 -0700 Subject: [PATCH] Reconcile the accepted safe federation helper with E2EE integration hardening and complete PIN-based encrypted DMs Hop-State: A_06FPC0CGS3F7SJG3BGW0YF0 Hop-Proposal: R_06FPC0BK6YEV7XASNM7C908 Hop-Task: T_06FPAWA3279RBMJAR0BHM10 Hop-Attempt: AT_06FPBZWSBFTB9B5YH9JY9QR --- .env.example | 1 + README.md | 6 + deploy/install.sh | 2 + deploy/update.sh | 10 + docs/e2ee-dms.md | 106 + .../migration.sql | 73 + .../snapshot.json | 7192 +++++++++++++++++ next.config.ts | 29 + scripts/migrate.ts | 35 + src/app/api/account/export/route.test.ts | 439 + src/app/api/account/export/route.ts | 195 +- src/app/api/account/import/route.test.ts | 529 ++ src/app/api/account/import/route.ts | 782 +- src/app/api/chat/receive/route.ts | 650 +- src/app/api/chat/send/route.ts | 664 +- src/app/api/e2ee/keys/[did]/route.ts | 45 + src/app/api/e2ee/keys/resolve/route.ts | 186 + src/app/api/e2ee/vault/route.ts | 248 + src/app/api/e2ee/vault/unlock/route.ts | 129 + src/app/api/swarm/announce/route.ts | 2 +- .../swarm/chat/conversations/[id]/route.ts | 79 +- src/app/api/swarm/chat/conversations/route.ts | 72 +- src/app/api/swarm/chat/messages/route.ts | 43 +- src/app/api/swarm/gossip/route.ts | 2 +- src/app/chat/page.tsx | 1047 ++- src/app/login/page.tsx | 96 +- src/app/settings/migration/page.tsx | 25 +- src/components/E2EEChatGate.tsx | 294 + src/db/schema.ts | 101 +- src/lib/api/signed-fetch.ts | 15 +- src/lib/auth/index.ts | 35 +- src/lib/auth/verify-signature.ts | 90 +- src/lib/crypto/did-key.ts | 125 + src/lib/crypto/signing-flow.test.ts | 108 +- src/lib/crypto/user-signing.ts | 56 +- src/lib/e2ee/bundle-proof.test.ts | 126 + src/lib/e2ee/bundle-proof.ts | 73 + src/lib/e2ee/client-crypto.test.ts | 198 + src/lib/e2ee/client-crypto.ts | 347 + src/lib/e2ee/client.test.ts | 121 + src/lib/e2ee/client.ts | 210 + src/lib/e2ee/local-key-store.ts | 135 + src/lib/e2ee/protocol.ts | 249 + src/lib/e2ee/server-secrets.ts | 56 + src/lib/e2ee/use-e2ee-identity.ts | 196 + src/lib/hooks/useUserIdentity.ts | 69 +- src/lib/rate-limit.ts | 15 + src/lib/swarm/discovery.ts | 6 +- src/lib/swarm/interactions.ts | 101 +- src/lib/swarm/safe-federation-http.test.ts | 18 + src/lib/swarm/safe-federation-http.ts | 4 + src/lib/swarm/signature.ts | 120 +- src/lib/swarm/types.ts | 2 +- src/lib/swarm/user-cache.ts | 44 +- 54 files changed, 14409 insertions(+), 1192 deletions(-) create mode 100644 docs/e2ee-dms.md create mode 100644 drizzle/20260715130251_violet_red_shift/migration.sql create mode 100644 drizzle/20260715130251_violet_red_shift/snapshot.json create mode 100644 src/app/api/account/export/route.test.ts create mode 100644 src/app/api/account/import/route.test.ts create mode 100644 src/app/api/e2ee/keys/[did]/route.ts create mode 100644 src/app/api/e2ee/keys/resolve/route.ts create mode 100644 src/app/api/e2ee/vault/route.ts create mode 100644 src/app/api/e2ee/vault/unlock/route.ts create mode 100644 src/components/E2EEChatGate.tsx create mode 100644 src/lib/crypto/did-key.ts create mode 100644 src/lib/e2ee/bundle-proof.test.ts create mode 100644 src/lib/e2ee/bundle-proof.ts create mode 100644 src/lib/e2ee/client-crypto.test.ts create mode 100644 src/lib/e2ee/client-crypto.ts create mode 100644 src/lib/e2ee/client.test.ts create mode 100644 src/lib/e2ee/client.ts create mode 100644 src/lib/e2ee/local-key-store.ts create mode 100644 src/lib/e2ee/protocol.ts create mode 100644 src/lib/e2ee/server-secrets.ts create mode 100644 src/lib/e2ee/use-e2ee-identity.ts diff --git a/.env.example b/.env.example index 51258ac..87fd653 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ PORT=43821 # Required: used for sessions, node key encryption, and bot API key encryption AUTH_SECRET=replace-with-a-long-random-secret +E2EE_RECOVERY_SECRET=replace-with-a-separate-long-random-secret # Required for admin access. Register with one of these emails locally. ADMIN_EMAILS=admin@example.com diff --git a/README.md b/README.md index cd6a791..bf5dc19 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,12 @@ Stuffbox is the default integration. New installs use `https://stuffbox.xyz`; se User-owned S3-compatible storage remains available as an advanced fallback. Supported providers include AWS S3, Cloudflare R2, Backblaze B2, Wasabi, and Contabo. +## Encrypted direct messages + +New one-to-one text DMs use client-side end-to-end encryption with a separate PIN for new-device recovery. The PIN does not change the normal login flow or ordinary sending experience. Node installs generate a distinct `E2EE_RECOVERY_SECRET`; preserve it in a secret manager or protected operational backup because the database alone cannot restore PIN recovery. + +V1 is intentionally limited and experimental: metadata remains visible, legacy DMs remain plaintext, and it does not provide forward secrecy or protection from a malicious home node that serves modified client code. See [the E2EE DM security and operations contract](docs/e2ee-dms.md) before enabling or describing the feature. + ## Development ```bash diff --git a/deploy/install.sh b/deploy/install.sh index 6994df1..5e0eab0 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -38,12 +38,14 @@ chown -R synapsis:synapsis "$APP_DIR" if [[ ! -e "$ENV_FILE" ]]; then auth_secret="$(openssl rand -base64 48 | tr -d '\n')" + e2ee_recovery_secret="$(openssl rand -base64 48 | tr -d '\n')" port="43821" install -m 0600 -o root -g synapsis /dev/null "$ENV_FILE" { echo "DATABASE_PATH=$DATA_DIR/synapsis.db" echo "PORT=$port" echo "AUTH_SECRET=$auth_secret" + echo "E2EE_RECOVERY_SECRET=$e2ee_recovery_secret" echo "ADMIN_EMAILS=admin@example.com" echo "NEXT_PUBLIC_NODE_DOMAIN=localhost:$port" echo "STUFFBOX_URL=https://stuffbox.xyz" diff --git a/deploy/update.sh b/deploy/update.sh index 0a90bf4..dca8177 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -19,6 +19,16 @@ set -a source "$ENV_FILE" set +a +# Existing installations predate encrypted-message recovery. Enroll them with +# a distinct stable secret before migrations/builds make E2EE available. +if [[ -z "${E2EE_RECOVERY_SECRET:-}" ]]; then + command -v openssl >/dev/null || { echo "Missing required command: openssl" >&2; exit 1; } + E2EE_RECOVERY_SECRET="$(openssl rand -base64 48 | tr -d '\n')" + printf '\nE2EE_RECOVERY_SECRET=%s\n' "$E2EE_RECOVERY_SECRET" >> "$ENV_FILE" + export E2EE_RECOVERY_SECRET + echo "Added E2EE_RECOVERY_SECRET to $ENV_FILE. Back it up separately from the database." +fi + install_update_units() { units_changed=0 for unit in synapsis.service synapsis-maintenance.service synapsis-update.service synapsis-update.timer; do diff --git a/docs/e2ee-dms.md b/docs/e2ee-dms.md new file mode 100644 index 0000000..14ec3dc --- /dev/null +++ b/docs/e2ee-dms.md @@ -0,0 +1,106 @@ +# End-to-end encrypted direct messages (v1) + +This document defines the security and product contract for Synapsis E2EE DMs v1. It is not a security audit or a claim that the feature is suitable for high-risk use. + +## Scope + +V1 encrypts the UTF-8 text body of new one-to-one direct messages. Existing messages are not protected retroactively. Group chats, images, video, audio, files, reactions, edits, calls, and server-generated link previews are not covered. + +Routing metadata remains visible to the participating home nodes: sender and recipient identities and node domains, message and conversation identifiers, key versions, timestamps, delivery/read state, typing state, ciphertext size, request logs, and the social graph. + +## Key and message flow + +Each account has one static X25519 key pair in v1. The browser generates it during encrypted-message setup. Its public key, identifier, monotonic version, and replacement link are signed by the account's existing DID signing key. + +For every message, the sender's browser: + +1. Resolves and verifies the recipient's signed X25519 public-key record. +2. Generates a fresh random content key and XChaCha20-Poly1305 nonce. +3. Encrypts the text locally with authenticated sender, recipient, conversation, message, timestamp, protocol, and key-version context. +4. Seals the content key and a transcript hash independently to the sender and recipient X25519 keys. The sender copy makes sent history available on the sender's enrolled devices. +5. Computes a keyed commitment over the authenticated context, nonce, and ciphertext. +6. Signs the complete encrypted envelope with the existing DID signing key. + +Nodes authorize, route, federate, and store the signed ciphertext envelope. They receive no plaintext body or content key. Server-side previews are generic. The recipient verifies the signature, bindings, commitment, and AEAD authentication before displaying locally decrypted text. + +Delivery wrappers are node-signed and bind the source node, destination node, route, delivery identifier, and expiry. Durable message receipts make retries idempotent and prevent a deleted conversation from being recreated by replaying an old valid envelope. + +Federation requests use bounded, redirect-free HTTPS with public-address DNS validation and per-request DNS pinning. Exact loopback HTTP is allowed only in development. Remote key-cache updates use compare-and-swap continuity checks so concurrent lookups cannot roll a cached key backward. + +## PIN and device experience + +The encrypted-message PIN is separate from login: + +- Setup happens only when Chat is first opened, not during login and not for every message. +- The PIN is 6–12 digits and is processed in the browser. The raw PIN is not sent to the node. +- A recognized browser stores the account key under a non-extractable IndexedDB wrapping key, so ordinary visits do not prompt again. Logout clears active application memory while retaining this protected recognized-device record. +- A new or cleared browser completes normal login, then enters the encrypted-message PIN once to recover the same account key. +- Failed recovery attempts are stored durably. Ten failures lock recovery for one hour. +- A forgotten-PIN reset requires the current account password and creates a new encryption key. In v1, old encrypted history no longer opens after that reset; there is no historical-key recovery UI. + +There is no administrator plaintext-recovery path. Reset does not decrypt or re-encrypt old messages. + +## Recovery service + +The private account key is encrypted by a key derived from both Argon2id PIN material and a random server contribution. The database stores the encrypted vault, an HMAC-protected PIN verifier, an encrypted server contribution, and the attempt state. + +Every node must set `E2EE_RECOVERY_SECRET` to an independent, high-entropy secret. It must not reuse `AUTH_SECRET`, use a `NEXT_PUBLIC_` name, enter source control, appear in logs, or be included in ordinary exports. Recovery fails closed when it is absent or too short. + +This secret is stable node state. Losing or changing it without a deliberate migration makes existing PIN recovery records unusable. A complete node backup needs the database and this secret stored separately in an operational secret manager. + +The v1 counter is ordinary application/database enforcement. It is not a hardware-enforced, HSM-backed, threshold, or multi-operator guess limit. Anyone who compromises the running node and its secrets can test the small PIN space offline. + +## Threat model + +With uncompromised endpoints and the reviewed client build, v1 is intended to keep supported message bodies confidential from: + +- a database or backup reader who does not also have the recovery secret; +- network intermediaries, in addition to the protection provided by TLS; and +- honest participating nodes that run the published code and do not capture endpoint secrets. + +V1 does not protect message bodies from: + +- a malicious home node that serves modified JavaScript capable of reading PINs, keys, or plaintext; +- XSS, a malicious browser extension, endpoint malware, or a compromised operating system; +- a recipient who copies, reports, photographs, or otherwise discloses plaintext; or +- compromise of the static account X25519 private key. + +Nodes can still drop, delay, replay, reorder, or refuse ciphertext. Replay records protect local message state, not availability. Message timing, frequency, participant metadata, and approximate size remain observable. + +When a node enables Cloudflare Turnstile, its third-party login script shares the credential and account-signing-key trust boundary. A hard navigation removes that realm before Chat creates or restores the E2EE account key, but it does not make a compromised login dependency harmless. Removing or isolating third-party login JavaScript is required before making a stronger endpoint-security claim. + +## Legacy history and fail-closed behavior + +Existing plaintext DMs remain legacy messages and are visibly marked as sent before encryption. Their original delivery cannot be made E2EE after the fact. + +New sends have no plaintext fallback. Sending remains disabled when the recipient has no signed encryption key, key resolution fails, a key version changes, the crypto runtime fails, or the protocol is unsupported. The draft remains in the composer and the user receives an actionable error. + +Invalid signatures, altered envelopes, authentication failures, and messages encrypted to an old reset key are displayed as unavailable ciphertext, never as partial or fabricated plaintext. + +## Export and migration limits + +Account export preserves new message envelopes as ciphertext and preserves legacy messages as legacy plaintext. Fresh v1.1 exports sign a canonical digest of the complete payload. A historical v1.0 account can still migrate without that digest, but all unauthenticated DM history is discarded and the destination warns that the remaining legacy payload must be reviewed for tampering. The server does not decrypt E2EE messages for export. + +V1 does not migrate the account encryption private key or recovery enrollment between home nodes. It does migrate the signed public key-continuity anchor. On first opening Chat at the destination, the user sets a PIN once and the browser signs a monotonic replacement key. Preserved old ciphertext remains unreadable on the destination. Copying a node-wide `E2EE_RECOVERY_SECRET` for one account is not acceptable. + +Federated DM relationships do not automatically follow a home-node move in v1. The broader federation layer has no signed handle-move proof that updates a peer's cached `user@node` mapping and existing conversation route. A peer that cached the old full handle can therefore reject the migrated handle even though the DID and encryption-key rotation are valid. The import UI warns about this limitation; seamless federated DM migration requires a separate signed move protocol. + +Cross-node “delete for everyone” is not offered in v1 because a remote node cannot guarantee deletion. Deleting a federated conversation removes only the local copy. + +## Explicit limitations + +- No forward secrecy: compromise of the static account private key exposes retained history encrypted to it. +- No post-compromise security: future messages remain exposed until the account key is reset and peers use the replacement. +- One account key is shared across devices; there are no per-device sessions or independent device revocation. +- No key transparency or out-of-band safety-number verification. +- PIN recovery is not X's Juicebox design and is not equivalent to an HSM-backed threshold service. +- This is not Signal Protocol and has no Double Ratchet. +- This is not X Chat's protocol or implementation; only the low-friction PIN experience is a product inspiration. +- Coverage is text-only. +- A malicious home node or maliciously served web client can steal keys and plaintext. + +## Review gate + +Before broad production enablement or strong security claims, obtain an independent cryptographic and application-security review. It must cover serialization, DID/key binding and rotation, X25519 validation, XChaCha20-Poly1305 and sealed-key usage, nonce generation, authenticated context, replay behavior, local persistence, recovery derivation and limits, federation checks, migrations, XSS/CSP, dependencies, logs, and failure paths. + +Publish interoperable protocol test vectors and resolve high-severity findings before presenting v1 as appropriate for sensitive communication. Until then, label it experimental. diff --git a/drizzle/20260715130251_violet_red_shift/migration.sql b/drizzle/20260715130251_violet_red_shift/migration.sql new file mode 100644 index 0000000..ae53896 --- /dev/null +++ b/drizzle/20260715130251_violet_red_shift/migration.sql @@ -0,0 +1,73 @@ +CREATE TABLE `e2ee_key_bundles` ( + `user_id` text PRIMARY KEY, + `did` text NOT NULL, + `key_id` text NOT NULL, + `key_version` integer NOT NULL, + `public_key` text NOT NULL, + `proof_action` text NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL, + CONSTRAINT `fk_e2ee_key_bundles_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE `e2ee_key_vaults` ( + `user_id` text PRIMARY KEY, + `key_id` text NOT NULL, + `key_version` integer NOT NULL, + `owner_did` text NOT NULL, + `public_key` text NOT NULL, + `ciphertext` text NOT NULL, + `nonce` text NOT NULL, + `salt` text NOT NULL, + `kdf_algorithm` text NOT NULL, + `kdf_ops_limit` integer NOT NULL, + `kdf_mem_limit` integer NOT NULL, + `pin_verifier_mac` text NOT NULL, + `server_share_encrypted` text NOT NULL, + `failed_attempts` integer DEFAULT 0 NOT NULL, + `locked_until` integer, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL, + CONSTRAINT `fk_e2ee_key_vaults_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE `e2ee_message_receipts` ( + `id` text PRIMARY KEY, + `owner_user_id` text NOT NULL, + `sender_did` text NOT NULL, + `message_id` text NOT NULL, + `protocol_version` integer DEFAULT 1 NOT NULL, + `received_at` integer DEFAULT (unixepoch()) NOT NULL, + CONSTRAINT `fk_e2ee_message_receipts_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE `e2ee_remote_key_bundles` ( + `did` text PRIMARY KEY, + `handle` text NOT NULL, + `key_id` text NOT NULL, + `key_version` integer NOT NULL, + `public_key` text NOT NULL, + `proof_action` text NOT NULL, + `signing_public_key` text NOT NULL, + `first_seen_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +ALTER TABLE `chat_conversations` ADD `encryption_mode` text DEFAULT 'legacy' NOT NULL;--> statement-breakpoint +ALTER TABLE `chat_conversations` ADD `e2ee_activated_at` integer;--> statement-breakpoint +ALTER TABLE `chat_messages` ADD `protocol_version` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `chat_messages` ADD `client_message_id` text;--> statement-breakpoint +ALTER TABLE `chat_messages` ADD `encrypted_envelope` text;--> statement-breakpoint +ALTER TABLE `chat_messages` ADD `e2ee_signature` text;--> statement-breakpoint +ALTER TABLE `chat_messages` ADD `e2ee_action_nonce` text;--> statement-breakpoint +ALTER TABLE `chat_messages` ADD `e2ee_action_ts` integer;--> statement-breakpoint +CREATE UNIQUE INDEX `chat_messages_conversation_client_id_unique` ON `chat_messages` (`conversation_id`,`client_message_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `e2ee_key_bundles_did_unique` ON `e2ee_key_bundles` (`did`);--> statement-breakpoint +CREATE UNIQUE INDEX `e2ee_key_bundles_key_id_unique` ON `e2ee_key_bundles` (`key_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `e2ee_key_vaults_key_id_unique` ON `e2ee_key_vaults` (`key_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `e2ee_message_receipts_owner_sender_message_unique` ON `e2ee_message_receipts` (`owner_user_id`,`sender_did`,`message_id`);--> statement-breakpoint +CREATE INDEX `e2ee_message_receipts_received_idx` ON `e2ee_message_receipts` (`received_at`);--> statement-breakpoint +CREATE INDEX `e2ee_remote_key_bundles_key_id_idx` ON `e2ee_remote_key_bundles` (`key_id`);--> statement-breakpoint +CREATE INDEX `e2ee_remote_key_bundles_handle_idx` ON `e2ee_remote_key_bundles` (`handle`);--> statement-breakpoint +CREATE UNIQUE INDEX `users_handle_unique_idx` ON `users` (`handle`);--> statement-breakpoint +CREATE UNIQUE INDEX `users_did_unique_idx` ON `users` (`did`); \ No newline at end of file diff --git a/drizzle/20260715130251_violet_red_shift/snapshot.json b/drizzle/20260715130251_violet_red_shift/snapshot.json new file mode 100644 index 0000000..2bddc98 --- /dev/null +++ b/drizzle/20260715130251_violet_red_shift/snapshot.json @@ -0,0 +1,7192 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "f2dee26d-ac08-4b7b-b708-d5d737b8866c", + "prevIds": [ + "1a33ed5b-1bd9-454e-ad3b-fdcf2fc48b43" + ], + "ddl": [ + { + "name": "blocks", + "entityType": "tables" + }, + { + "name": "bot_activity_logs", + "entityType": "tables" + }, + { + "name": "bot_content_items", + "entityType": "tables" + }, + { + "name": "bot_content_sources", + "entityType": "tables" + }, + { + "name": "bot_mentions", + "entityType": "tables" + }, + { + "name": "bot_rate_limits", + "entityType": "tables" + }, + { + "name": "bots", + "entityType": "tables" + }, + { + "name": "chat_conversations", + "entityType": "tables" + }, + { + "name": "chat_messages", + "entityType": "tables" + }, + { + "name": "chat_typing_indicators", + "entityType": "tables" + }, + { + "name": "e2ee_key_bundles", + "entityType": "tables" + }, + { + "name": "e2ee_key_vaults", + "entityType": "tables" + }, + { + "name": "e2ee_message_receipts", + "entityType": "tables" + }, + { + "name": "e2ee_remote_key_bundles", + "entityType": "tables" + }, + { + "name": "follows", + "entityType": "tables" + }, + { + "name": "handle_registry", + "entityType": "tables" + }, + { + "name": "likes", + "entityType": "tables" + }, + { + "name": "media", + "entityType": "tables" + }, + { + "name": "muted_nodes", + "entityType": "tables" + }, + { + "name": "mutes", + "entityType": "tables" + }, + { + "name": "nodes", + "entityType": "tables" + }, + { + "name": "notifications", + "entityType": "tables" + }, + { + "name": "posts", + "entityType": "tables" + }, + { + "name": "remote_followers", + "entityType": "tables" + }, + { + "name": "remote_follows", + "entityType": "tables" + }, + { + "name": "remote_identity_cache", + "entityType": "tables" + }, + { + "name": "remote_likes", + "entityType": "tables" + }, + { + "name": "remote_posts", + "entityType": "tables" + }, + { + "name": "remote_reposts", + "entityType": "tables" + }, + { + "name": "reports", + "entityType": "tables" + }, + { + "name": "sessions", + "entityType": "tables" + }, + { + "name": "signed_action_dedupe", + "entityType": "tables" + }, + { + "name": "stuffbox_connections", + "entityType": "tables" + }, + { + "name": "swarm_nodes", + "entityType": "tables" + }, + { + "name": "swarm_seeds", + "entityType": "tables" + }, + { + "name": "swarm_sync_log", + "entityType": "tables" + }, + { + "name": "user_swarm_likes", + "entityType": "tables" + }, + { + "name": "user_swarm_reposts", + "entityType": "tables" + }, + { + "name": "users", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "blocks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "blocks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "blocked_user_id", + "entityType": "columns", + "table": "blocks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "blocks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bot_id", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "details", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "success", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "error_message", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_id", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "external_id", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "published_at", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "fetched_at", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_processed", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "processed_at", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "interest_score", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "interest_reason", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bot_id", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "subreddit", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "api_key_encrypted", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_config", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "keywords", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "is_active", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_fetch_at", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "consecutive_errors", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bot_id", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_id", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_processed", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "processed_at", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "response_post_id", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_remote", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "remote_actor_url", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bot_id", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "window_start", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "window_type", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "post_count", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "reply_count", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "personality_config", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "llm_provider", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "llm_model", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "llm_endpoint", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "llm_api_key_encrypted", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "schedule_config", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "autonomous_mode", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "is_active", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_suspended", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "suspension_reason", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "suspended_at", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_post_at", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'direct'", + "generated": null, + "name": "type", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "participant1_id", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "participant2_handle", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_message_at", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_message_preview", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'legacy'", + "generated": null, + "name": "encryption_mode", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "e2ee_activated_at", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "conversation_id", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_handle", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_display_name", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_avatar_url", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_node_domain", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_did", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "protocol_version", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "client_message_id", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "encrypted_envelope", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "e2ee_signature", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "e2ee_action_nonce", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "e2ee_action_ts", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "swarm_message_id", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivered_at", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "read_at", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "chat_typing_indicators" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "conversation_id", + "entityType": "columns", + "table": "chat_typing_indicators" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_handle", + "entityType": "columns", + "table": "chat_typing_indicators" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "chat_typing_indicators" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "chat_typing_indicators" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "did", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key_version", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "proof_action", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key_version", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_did", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ciphertext", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "nonce", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "salt", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kdf_algorithm", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kdf_ops_limit", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kdf_mem_limit", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "pin_verifier_mac", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "server_share_encrypted", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "failed_attempts", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "locked_until", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "e2ee_message_receipts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_user_id", + "entityType": "columns", + "table": "e2ee_message_receipts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_did", + "entityType": "columns", + "table": "e2ee_message_receipts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "e2ee_message_receipts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "1", + "generated": null, + "name": "protocol_version", + "entityType": "columns", + "table": "e2ee_message_receipts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "received_at", + "entityType": "columns", + "table": "e2ee_message_receipts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "did", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "handle", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key_version", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "proof_action", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "signing_public_key", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "first_seen_at", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "follower_id", + "entityType": "columns", + "table": "follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "following_id", + "entityType": "columns", + "table": "follows" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ap_id", + "entityType": "columns", + "table": "follows" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "pending", + "entityType": "columns", + "table": "follows" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "follows" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "handle", + "entityType": "columns", + "table": "handle_registry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "did", + "entityType": "columns", + "table": "handle_registry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "node_domain", + "entityType": "columns", + "table": "handle_registry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "registered_at", + "entityType": "columns", + "table": "handle_registry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "handle_registry" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ap_id", + "entityType": "columns", + "table": "likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_provider", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_asset_id", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "alt_text", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mime_type", + "entityType": "columns", + "table": "media" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "width", + "entityType": "columns", + "table": "media" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "height", + "entityType": "columns", + "table": "media" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "muted_nodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "muted_nodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "node_domain", + "entityType": "columns", + "table": "muted_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "muted_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "mutes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "mutes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "muted_user_id", + "entityType": "columns", + "table": "mutes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "mutes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "domain", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "description", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "long_description", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rules", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banner_url", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logo_url", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "favicon_url", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logo_data", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "favicon_data", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'#FFFFFF'", + "generated": null, + "name": "accent_color", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "private_key_encrypted", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_nsfw", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "turnstile_site_key", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "turnstile_secret_key", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_id", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_handle", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_display_name", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_avatar_url", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_node_domain", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_handle", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_display_name", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_avatar_url", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_node_domain", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_is_bot", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_content", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "read_at", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bot_id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reply_to_id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repost_of_id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "swarm_reply_to_id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "swarm_reply_to_content", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "swarm_reply_to_author", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "likes_count", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "reposts_count", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "replies_count", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_nsfw", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_removed", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "removed_at", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "removed_by", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "removed_reason", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ap_id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ap_url", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_url", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_title", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_description", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_image", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_type", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_video_url", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_media_json", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_url", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "inbox_url", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "shared_inbox_url", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "handle", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "activity_id", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "follower_id", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_handle", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_actor_url", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "inbox_url", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "activity_id", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "display_name", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bio", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "avatar_url", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "did", + "entityType": "columns", + "table": "remote_identity_cache" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "remote_identity_cache" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fetched_at", + "entityType": "columns", + "table": "remote_identity_cache" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "remote_identity_cache" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "remote_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "remote_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_handle", + "entityType": "columns", + "table": "remote_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_node_domain", + "entityType": "columns", + "table": "remote_likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "remote_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ap_id", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_handle", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_actor_url", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_display_name", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_avatar_url", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "published_at", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_url", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_title", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_description", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_image", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_type", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_video_url", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_media_json", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "media_json", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "fetched_at", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "remote_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "remote_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_handle", + "entityType": "columns", + "table": "remote_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_node_domain", + "entityType": "columns", + "table": "remote_reposts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "remote_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reporter_id", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_type", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_id", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reason", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'open'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "reports" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resolved_at", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resolved_by", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resolution_note", + "entityType": "columns", + "table": "reports" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action_id", + "entityType": "columns", + "table": "signed_action_dedupe" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "did", + "entityType": "columns", + "table": "signed_action_dedupe" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "nonce", + "entityType": "columns", + "table": "signed_action_dedupe" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ts", + "entityType": "columns", + "table": "signed_action_dedupe" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "signed_action_dedupe" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "base_url", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token_encrypted", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token_expires_at", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token_encrypted", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token_expires_at", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scopes", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "connected_at", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "domain", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "description", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logo_url", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "software_version", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_count", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_count", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_nsfw", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "discovered_via", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "discovered_at", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "last_seen_at", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_sync_at", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "consecutive_failures", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "is_active", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "50", + "generated": null, + "name": "trust_score", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_blocked", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "block_reason", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "blocked_at", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "capabilities", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "domain", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "100", + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "is_enabled", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_contact_at", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "consecutive_failures", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "remote_domain", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "direction", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "nodes_received", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "nodes_sent", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "handles_received", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "handles_sent", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "success", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "error_message", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "duration_ms", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "node_domain", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "original_post_id", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_handle", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_display_name", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_avatar_url", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_created_at", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "likes_count", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "reposts_count", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "replies_count", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_url", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_title", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_description", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_image", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_type", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_video_url", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_media_json", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "media_json", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "liked_at", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "node_domain", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "original_post_id", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_handle", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_display_name", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_avatar_url", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_created_at", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "likes_count", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "reposts_count", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "replies_count", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_url", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_title", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_description", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_image", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_type", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_video_url", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_media_json", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "media_json", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "reposted_at", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "did", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "handle", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password_hash", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "display_name", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bio", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "avatar_url", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "header_url", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "private_key_encrypted", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "node_id", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_bot", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bot_owner_id", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_nsfw", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "nsfw_enabled", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "age_verified_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_suspended", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "suspension_reason", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "suspended_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_silenced", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "silence_reason", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "silenced_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "moved_to", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "moved_from", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "migrated_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_provider", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_endpoint", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_public_base_url", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_region", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_bucket", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_access_key_encrypted", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_secret_key_encrypted", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "followers_count", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "following_count", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "posts_count", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "website", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'everyone'", + "generated": null, + "name": "dm_privacy", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "users" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_blocks_user_id_users_id_fk", + "entityType": "fks", + "table": "blocks" + }, + { + "columns": [ + "blocked_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_blocks_blocked_user_id_users_id_fk", + "entityType": "fks", + "table": "blocks" + }, + { + "columns": [ + "bot_id" + ], + "tableTo": "bots", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bot_activity_logs_bot_id_bots_id_fk", + "entityType": "fks", + "table": "bot_activity_logs" + }, + { + "columns": [ + "source_id" + ], + "tableTo": "bot_content_sources", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bot_content_items_source_id_bot_content_sources_id_fk", + "entityType": "fks", + "table": "bot_content_items" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_bot_content_items_post_id_posts_id_fk", + "entityType": "fks", + "table": "bot_content_items" + }, + { + "columns": [ + "bot_id" + ], + "tableTo": "bots", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bot_content_sources_bot_id_bots_id_fk", + "entityType": "fks", + "table": "bot_content_sources" + }, + { + "columns": [ + "bot_id" + ], + "tableTo": "bots", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bot_mentions_bot_id_bots_id_fk", + "entityType": "fks", + "table": "bot_mentions" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bot_mentions_post_id_posts_id_fk", + "entityType": "fks", + "table": "bot_mentions" + }, + { + "columns": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_bot_mentions_author_id_users_id_fk", + "entityType": "fks", + "table": "bot_mentions" + }, + { + "columns": [ + "response_post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_bot_mentions_response_post_id_posts_id_fk", + "entityType": "fks", + "table": "bot_mentions" + }, + { + "columns": [ + "bot_id" + ], + "tableTo": "bots", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bot_rate_limits_bot_id_bots_id_fk", + "entityType": "fks", + "table": "bot_rate_limits" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bots_user_id_users_id_fk", + "entityType": "fks", + "table": "bots" + }, + { + "columns": [ + "owner_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bots_owner_id_users_id_fk", + "entityType": "fks", + "table": "bots" + }, + { + "columns": [ + "participant1_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_chat_conversations_participant1_id_users_id_fk", + "entityType": "fks", + "table": "chat_conversations" + }, + { + "columns": [ + "conversation_id" + ], + "tableTo": "chat_conversations", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_chat_messages_conversation_id_chat_conversations_id_fk", + "entityType": "fks", + "table": "chat_messages" + }, + { + "columns": [ + "conversation_id" + ], + "tableTo": "chat_conversations", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_chat_typing_indicators_conversation_id_chat_conversations_id_fk", + "entityType": "fks", + "table": "chat_typing_indicators" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_e2ee_key_bundles_user_id_users_id_fk", + "entityType": "fks", + "table": "e2ee_key_bundles" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_e2ee_key_vaults_user_id_users_id_fk", + "entityType": "fks", + "table": "e2ee_key_vaults" + }, + { + "columns": [ + "owner_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_e2ee_message_receipts_owner_user_id_users_id_fk", + "entityType": "fks", + "table": "e2ee_message_receipts" + }, + { + "columns": [ + "follower_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_follows_follower_id_users_id_fk", + "entityType": "fks", + "table": "follows" + }, + { + "columns": [ + "following_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_follows_following_id_users_id_fk", + "entityType": "fks", + "table": "follows" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_likes_user_id_users_id_fk", + "entityType": "fks", + "table": "likes" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_likes_post_id_posts_id_fk", + "entityType": "fks", + "table": "likes" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_media_user_id_users_id_fk", + "entityType": "fks", + "table": "media" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_media_post_id_posts_id_fk", + "entityType": "fks", + "table": "media" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_muted_nodes_user_id_users_id_fk", + "entityType": "fks", + "table": "muted_nodes" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_mutes_user_id_users_id_fk", + "entityType": "fks", + "table": "mutes" + }, + { + "columns": [ + "muted_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_mutes_muted_user_id_users_id_fk", + "entityType": "fks", + "table": "mutes" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_notifications_user_id_users_id_fk", + "entityType": "fks", + "table": "notifications" + }, + { + "columns": [ + "actor_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_notifications_actor_id_users_id_fk", + "entityType": "fks", + "table": "notifications" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_notifications_post_id_posts_id_fk", + "entityType": "fks", + "table": "notifications" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_posts_user_id_users_id_fk", + "entityType": "fks", + "table": "posts" + }, + { + "columns": [ + "bot_id" + ], + "tableTo": "bots", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_posts_bot_id_bots_id_fk", + "entityType": "fks", + "table": "posts" + }, + { + "columns": [ + "removed_by" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_posts_removed_by_users_id_fk", + "entityType": "fks", + "table": "posts" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_remote_followers_user_id_users_id_fk", + "entityType": "fks", + "table": "remote_followers" + }, + { + "columns": [ + "follower_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_remote_follows_follower_id_users_id_fk", + "entityType": "fks", + "table": "remote_follows" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_remote_likes_post_id_posts_id_fk", + "entityType": "fks", + "table": "remote_likes" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_remote_reposts_post_id_posts_id_fk", + "entityType": "fks", + "table": "remote_reposts" + }, + { + "columns": [ + "reporter_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_reports_reporter_id_users_id_fk", + "entityType": "fks", + "table": "reports" + }, + { + "columns": [ + "resolved_by" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_reports_resolved_by_users_id_fk", + "entityType": "fks", + "table": "reports" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_sessions_user_id_users_id_fk", + "entityType": "fks", + "table": "sessions" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_stuffbox_connections_user_id_users_id_fk", + "entityType": "fks", + "table": "stuffbox_connections" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_user_swarm_likes_user_id_users_id_fk", + "entityType": "fks", + "table": "user_swarm_likes" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_user_swarm_reposts_user_id_users_id_fk", + "entityType": "fks", + "table": "user_swarm_reposts" + }, + { + "columns": [ + "node_id" + ], + "tableTo": "nodes", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_users_node_id_nodes_id_fk", + "entityType": "fks", + "table": "users" + }, + { + "columns": [ + "bot_owner_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": true, + "name": "users_bot_owner_id_users_id_fk", + "entityType": "fks", + "table": "users" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blocks_pk", + "table": "blocks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "bot_activity_logs_pk", + "table": "bot_activity_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "bot_content_items_pk", + "table": "bot_content_items", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "bot_content_sources_pk", + "table": "bot_content_sources", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "bot_mentions_pk", + "table": "bot_mentions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "bot_rate_limits_pk", + "table": "bot_rate_limits", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "bots_pk", + "table": "bots", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "chat_conversations_pk", + "table": "chat_conversations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "chat_messages_pk", + "table": "chat_messages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "chat_typing_indicators_pk", + "table": "chat_typing_indicators", + "entityType": "pks" + }, + { + "columns": [ + "user_id" + ], + "nameExplicit": false, + "name": "e2ee_key_bundles_pk", + "table": "e2ee_key_bundles", + "entityType": "pks" + }, + { + "columns": [ + "user_id" + ], + "nameExplicit": false, + "name": "e2ee_key_vaults_pk", + "table": "e2ee_key_vaults", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "e2ee_message_receipts_pk", + "table": "e2ee_message_receipts", + "entityType": "pks" + }, + { + "columns": [ + "did" + ], + "nameExplicit": false, + "name": "e2ee_remote_key_bundles_pk", + "table": "e2ee_remote_key_bundles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "follows_pk", + "table": "follows", + "entityType": "pks" + }, + { + "columns": [ + "handle" + ], + "nameExplicit": false, + "name": "handle_registry_pk", + "table": "handle_registry", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "likes_pk", + "table": "likes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "media_pk", + "table": "media", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "muted_nodes_pk", + "table": "muted_nodes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "mutes_pk", + "table": "mutes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "nodes_pk", + "table": "nodes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "notifications_pk", + "table": "notifications", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "posts_pk", + "table": "posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "remote_followers_pk", + "table": "remote_followers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "remote_follows_pk", + "table": "remote_follows", + "entityType": "pks" + }, + { + "columns": [ + "did" + ], + "nameExplicit": false, + "name": "remote_identity_cache_pk", + "table": "remote_identity_cache", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "remote_likes_pk", + "table": "remote_likes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "remote_posts_pk", + "table": "remote_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "remote_reposts_pk", + "table": "remote_reposts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "reports_pk", + "table": "reports", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sessions_pk", + "table": "sessions", + "entityType": "pks" + }, + { + "columns": [ + "action_id" + ], + "nameExplicit": false, + "name": "signed_action_dedupe_pk", + "table": "signed_action_dedupe", + "entityType": "pks" + }, + { + "columns": [ + "user_id" + ], + "nameExplicit": false, + "name": "stuffbox_connections_pk", + "table": "stuffbox_connections", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "swarm_nodes_pk", + "table": "swarm_nodes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "swarm_seeds_pk", + "table": "swarm_seeds", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "swarm_sync_log_pk", + "table": "swarm_sync_log", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_swarm_likes_pk", + "table": "user_swarm_likes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_swarm_reposts_pk", + "table": "user_swarm_reposts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "users_pk", + "table": "users", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "blocks_user_idx", + "entityType": "indexes", + "table": "blocks" + }, + { + "columns": [ + { + "value": "blocked_user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "blocks_blocked_user_idx", + "entityType": "indexes", + "table": "blocks" + }, + { + "columns": [ + { + "value": "bot_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_activity_logs_bot_idx", + "entityType": "indexes", + "table": "bot_activity_logs" + }, + { + "columns": [ + { + "value": "action", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_activity_logs_action_idx", + "entityType": "indexes", + "table": "bot_activity_logs" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_activity_logs_created_idx", + "entityType": "indexes", + "table": "bot_activity_logs" + }, + { + "columns": [ + { + "value": "source_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_content_items_source_idx", + "entityType": "indexes", + "table": "bot_content_items" + }, + { + "columns": [ + { + "value": "is_processed", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_content_items_processed_idx", + "entityType": "indexes", + "table": "bot_content_items" + }, + { + "columns": [ + { + "value": "external_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_content_items_external_idx", + "entityType": "indexes", + "table": "bot_content_items" + }, + { + "columns": [ + { + "value": "bot_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_content_sources_bot_idx", + "entityType": "indexes", + "table": "bot_content_sources" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_content_sources_type_idx", + "entityType": "indexes", + "table": "bot_content_sources" + }, + { + "columns": [ + { + "value": "bot_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_mentions_bot_idx", + "entityType": "indexes", + "table": "bot_mentions" + }, + { + "columns": [ + { + "value": "is_processed", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_mentions_processed_idx", + "entityType": "indexes", + "table": "bot_mentions" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_mentions_created_idx", + "entityType": "indexes", + "table": "bot_mentions" + }, + { + "columns": [ + { + "value": "bot_id", + "isExpression": false + }, + { + "value": "window_start", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_rate_limits_bot_window_idx", + "entityType": "indexes", + "table": "bot_rate_limits" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bots_user_id_idx", + "entityType": "indexes", + "table": "bots" + }, + { + "columns": [ + { + "value": "owner_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bots_owner_id_idx", + "entityType": "indexes", + "table": "bots" + }, + { + "columns": [ + { + "value": "is_active", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bots_active_idx", + "entityType": "indexes", + "table": "bots" + }, + { + "columns": [ + { + "value": "participant1_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_conversations_participant1_idx", + "entityType": "indexes", + "table": "chat_conversations" + }, + { + "columns": [ + { + "value": "last_message_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_conversations_last_message_idx", + "entityType": "indexes", + "table": "chat_conversations" + }, + { + "columns": [ + { + "value": "participant1_id", + "isExpression": false + }, + { + "value": "participant2_handle", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "chat_conversations_unique", + "entityType": "indexes", + "table": "chat_conversations" + }, + { + "columns": [ + { + "value": "conversation_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_messages_conversation_idx", + "entityType": "indexes", + "table": "chat_messages" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_messages_created_idx", + "entityType": "indexes", + "table": "chat_messages" + }, + { + "columns": [ + { + "value": "swarm_message_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_messages_swarm_id_idx", + "entityType": "indexes", + "table": "chat_messages" + }, + { + "columns": [ + { + "value": "conversation_id", + "isExpression": false + }, + { + "value": "client_message_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "chat_messages_conversation_client_id_unique", + "entityType": "indexes", + "table": "chat_messages" + }, + { + "columns": [ + { + "value": "conversation_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_typing_conversation_idx", + "entityType": "indexes", + "table": "chat_typing_indicators" + }, + { + "columns": [ + { + "value": "expires_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_typing_expires_idx", + "entityType": "indexes", + "table": "chat_typing_indicators" + }, + { + "columns": [ + { + "value": "conversation_id", + "isExpression": false + }, + { + "value": "user_handle", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "chat_typing_unique", + "entityType": "indexes", + "table": "chat_typing_indicators" + }, + { + "columns": [ + { + "value": "did", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "e2ee_key_bundles_did_unique", + "entityType": "indexes", + "table": "e2ee_key_bundles" + }, + { + "columns": [ + { + "value": "key_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "e2ee_key_bundles_key_id_unique", + "entityType": "indexes", + "table": "e2ee_key_bundles" + }, + { + "columns": [ + { + "value": "key_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "e2ee_key_vaults_key_id_unique", + "entityType": "indexes", + "table": "e2ee_key_vaults" + }, + { + "columns": [ + { + "value": "owner_user_id", + "isExpression": false + }, + { + "value": "sender_did", + "isExpression": false + }, + { + "value": "message_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "e2ee_message_receipts_owner_sender_message_unique", + "entityType": "indexes", + "table": "e2ee_message_receipts" + }, + { + "columns": [ + { + "value": "received_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "e2ee_message_receipts_received_idx", + "entityType": "indexes", + "table": "e2ee_message_receipts" + }, + { + "columns": [ + { + "value": "key_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "e2ee_remote_key_bundles_key_id_idx", + "entityType": "indexes", + "table": "e2ee_remote_key_bundles" + }, + { + "columns": [ + { + "value": "handle", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "e2ee_remote_key_bundles_handle_idx", + "entityType": "indexes", + "table": "e2ee_remote_key_bundles" + }, + { + "columns": [ + { + "value": "follower_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "follows_follower_idx", + "entityType": "indexes", + "table": "follows" + }, + { + "columns": [ + { + "value": "following_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "follows_following_idx", + "entityType": "indexes", + "table": "follows" + }, + { + "columns": [ + { + "value": "updated_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "handle_registry_updated_idx", + "entityType": "indexes", + "table": "handle_registry" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + }, + { + "value": "post_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "likes_user_post_idx", + "entityType": "indexes", + "table": "likes" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "media_user_idx", + "entityType": "indexes", + "table": "media" + }, + { + "columns": [ + { + "value": "post_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "media_post_idx", + "entityType": "indexes", + "table": "media" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "muted_nodes_user_idx", + "entityType": "indexes", + "table": "muted_nodes" + }, + { + "columns": [ + { + "value": "node_domain", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "muted_nodes_domain_idx", + "entityType": "indexes", + "table": "muted_nodes" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "mutes_user_idx", + "entityType": "indexes", + "table": "mutes" + }, + { + "columns": [ + { + "value": "muted_user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "mutes_muted_user_idx", + "entityType": "indexes", + "table": "mutes" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "notifications_user_idx", + "entityType": "indexes", + "table": "notifications" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "notifications_created_idx", + "entityType": "indexes", + "table": "notifications" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "posts_user_id_idx", + "entityType": "indexes", + "table": "posts" + }, + { + "columns": [ + { + "value": "bot_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "posts_bot_id_idx", + "entityType": "indexes", + "table": "posts" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "posts_created_at_idx", + "entityType": "indexes", + "table": "posts" + }, + { + "columns": [ + { + "value": "reply_to_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "posts_reply_to_idx", + "entityType": "indexes", + "table": "posts" + }, + { + "columns": [ + { + "value": "is_removed", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "posts_removed_idx", + "entityType": "indexes", + "table": "posts" + }, + { + "columns": [ + { + "value": "is_nsfw", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "posts_nsfw_idx", + "entityType": "indexes", + "table": "posts" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_followers_user_idx", + "entityType": "indexes", + "table": "remote_followers" + }, + { + "columns": [ + { + "value": "actor_url", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_followers_actor_idx", + "entityType": "indexes", + "table": "remote_followers" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + }, + { + "value": "actor_url", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "remote_followers_user_actor_unique", + "entityType": "indexes", + "table": "remote_followers" + }, + { + "columns": [ + { + "value": "follower_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_follows_follower_idx", + "entityType": "indexes", + "table": "remote_follows" + }, + { + "columns": [ + { + "value": "target_handle", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_follows_target_idx", + "entityType": "indexes", + "table": "remote_follows" + }, + { + "columns": [ + { + "value": "post_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_likes_post_idx", + "entityType": "indexes", + "table": "remote_likes" + }, + { + "columns": [ + { + "value": "actor_handle", + "isExpression": false + }, + { + "value": "actor_node_domain", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_likes_actor_idx", + "entityType": "indexes", + "table": "remote_likes" + }, + { + "columns": [ + { + "value": "post_id", + "isExpression": false + }, + { + "value": "actor_handle", + "isExpression": false + }, + { + "value": "actor_node_domain", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "remote_likes_unique", + "entityType": "indexes", + "table": "remote_likes" + }, + { + "columns": [ + { + "value": "author_handle", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_posts_author_idx", + "entityType": "indexes", + "table": "remote_posts" + }, + { + "columns": [ + { + "value": "published_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_posts_published_idx", + "entityType": "indexes", + "table": "remote_posts" + }, + { + "columns": [ + { + "value": "ap_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_posts_ap_id_idx", + "entityType": "indexes", + "table": "remote_posts" + }, + { + "columns": [ + { + "value": "post_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_reposts_post_idx", + "entityType": "indexes", + "table": "remote_reposts" + }, + { + "columns": [ + { + "value": "actor_handle", + "isExpression": false + }, + { + "value": "actor_node_domain", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_reposts_actor_idx", + "entityType": "indexes", + "table": "remote_reposts" + }, + { + "columns": [ + { + "value": "post_id", + "isExpression": false + }, + { + "value": "actor_handle", + "isExpression": false + }, + { + "value": "actor_node_domain", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "remote_reposts_unique", + "entityType": "indexes", + "table": "remote_reposts" + }, + { + "columns": [ + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "reports_status_idx", + "entityType": "indexes", + "table": "reports" + }, + { + "columns": [ + { + "value": "target_type", + "isExpression": false + }, + { + "value": "target_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "reports_target_idx", + "entityType": "indexes", + "table": "reports" + }, + { + "columns": [ + { + "value": "reporter_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "reports_reporter_idx", + "entityType": "indexes", + "table": "reports" + }, + { + "columns": [ + { + "value": "token", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "sessions_token_idx", + "entityType": "indexes", + "table": "sessions" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "sessions_user_idx", + "entityType": "indexes", + "table": "sessions" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "signed_action_dedupe_created_idx", + "entityType": "indexes", + "table": "signed_action_dedupe" + }, + { + "columns": [ + { + "value": "domain", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_nodes_domain_idx", + "entityType": "indexes", + "table": "swarm_nodes" + }, + { + "columns": [ + { + "value": "is_active", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_nodes_active_idx", + "entityType": "indexes", + "table": "swarm_nodes" + }, + { + "columns": [ + { + "value": "last_seen_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_nodes_last_seen_idx", + "entityType": "indexes", + "table": "swarm_nodes" + }, + { + "columns": [ + { + "value": "trust_score", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_nodes_trust_idx", + "entityType": "indexes", + "table": "swarm_nodes" + }, + { + "columns": [ + { + "value": "is_nsfw", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_nodes_nsfw_idx", + "entityType": "indexes", + "table": "swarm_nodes" + }, + { + "columns": [ + { + "value": "is_blocked", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_nodes_blocked_idx", + "entityType": "indexes", + "table": "swarm_nodes" + }, + { + "columns": [ + { + "value": "is_enabled", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_seeds_enabled_idx", + "entityType": "indexes", + "table": "swarm_seeds" + }, + { + "columns": [ + { + "value": "priority", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_seeds_priority_idx", + "entityType": "indexes", + "table": "swarm_seeds" + }, + { + "columns": [ + { + "value": "remote_domain", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_sync_log_remote_idx", + "entityType": "indexes", + "table": "swarm_sync_log" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_sync_log_created_idx", + "entityType": "indexes", + "table": "swarm_sync_log" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + }, + { + "value": "liked_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "user_swarm_likes_user_idx", + "entityType": "indexes", + "table": "user_swarm_likes" + }, + { + "columns": [ + { + "value": "node_domain", + "isExpression": false + }, + { + "value": "original_post_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "user_swarm_likes_post_idx", + "entityType": "indexes", + "table": "user_swarm_likes" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + }, + { + "value": "node_domain", + "isExpression": false + }, + { + "value": "original_post_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "user_swarm_likes_unique", + "entityType": "indexes", + "table": "user_swarm_likes" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + }, + { + "value": "reposted_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "user_swarm_reposts_user_idx", + "entityType": "indexes", + "table": "user_swarm_reposts" + }, + { + "columns": [ + { + "value": "node_domain", + "isExpression": false + }, + { + "value": "original_post_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "user_swarm_reposts_post_idx", + "entityType": "indexes", + "table": "user_swarm_reposts" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + }, + { + "value": "node_domain", + "isExpression": false + }, + { + "value": "original_post_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "user_swarm_reposts_unique", + "entityType": "indexes", + "table": "user_swarm_reposts" + }, + { + "columns": [ + { + "value": "handle", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_handle_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "did", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_did_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "handle", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "users_handle_unique_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "did", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "users_did_unique_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "is_suspended", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_suspended_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "is_silenced", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_silenced_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "is_bot", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_is_bot_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "bot_owner_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_bot_owner_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "is_nsfw", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_nsfw_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + "ap_id" + ], + "nameExplicit": false, + "name": "follows_ap_id_unique", + "entityType": "uniques", + "table": "follows" + }, + { + "columns": [ + "ap_id" + ], + "nameExplicit": false, + "name": "likes_ap_id_unique", + "entityType": "uniques", + "table": "likes" + }, + { + "columns": [ + "domain" + ], + "nameExplicit": false, + "name": "nodes_domain_unique", + "entityType": "uniques", + "table": "nodes" + }, + { + "columns": [ + "ap_id" + ], + "nameExplicit": false, + "name": "posts_ap_id_unique", + "entityType": "uniques", + "table": "posts" + }, + { + "columns": [ + "domain" + ], + "nameExplicit": false, + "name": "swarm_seeds_domain_unique", + "entityType": "uniques", + "table": "swarm_seeds" + }, + { + "columns": [ + "email" + ], + "nameExplicit": false, + "name": "users_email_unique", + "entityType": "uniques", + "table": "users" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 7eb05d7..2911ac0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -27,6 +27,21 @@ function getBuildCommitCount(): string { } } +const contentSecurityPolicy = [ + "default-src 'self'", + "base-uri 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + "form-action 'self'", + `script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''} https://challenges.cloudflare.com`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + "connect-src 'self' https: wss:", + "frame-src https://challenges.cloudflare.com", + "worker-src 'self' blob:", +].join('; '); + const nextConfig: NextConfig = { env: { APP_COMMIT: getBuildCommit(), @@ -41,6 +56,20 @@ const nextConfig: NextConfig = { turbopack: { root: process.cwd(), }, + + async headers() { + return [{ + source: '/:path*', + headers: [ + { key: 'Content-Security-Policy', value: contentSecurityPolicy }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { key: 'Cross-Origin-Opener-Policy', value: 'same-origin-allow-popups' }, + ], + }]; + }, }; export default nextConfig; diff --git a/scripts/migrate.ts b/scripts/migrate.ts index cd784d7..daf0b8f 100644 --- a/scripts/migrate.ts +++ b/scripts/migrate.ts @@ -2,8 +2,43 @@ import { sql } from 'drizzle-orm'; import { migrate } from 'drizzle-orm/tursodatabase/migrator'; import { closeDb, db } from '../src/db'; +interface DuplicateIdentityRow { + field: 'did' | 'handle'; + value: string; + duplicateCount: number; +} + +async function assertIdentityUniqueness(): Promise { + const tables = await db.all<{ name: string }>(sql.raw( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users' LIMIT 1", + )); + if (tables.length === 0) return; + + const duplicates = await db.all(sql.raw(` + SELECT 'did' AS field, did AS value, COUNT(*) AS duplicateCount + FROM users + GROUP BY did + HAVING COUNT(*) > 1 + UNION ALL + SELECT 'handle' AS field, handle AS value, COUNT(*) AS duplicateCount + FROM users + GROUP BY handle + HAVING COUNT(*) > 1 + LIMIT 20 + `)); + if (duplicates.length === 0) return; + + const sample = duplicates + .map((row) => `${row.field}=${JSON.stringify(row.value)} (${row.duplicateCount} rows)`) + .join(', '); + throw new Error( + `Database identity preflight failed: duplicate users must be resolved before migration: ${sample}`, + ); +} + async function main() { try { + await assertIdentityUniqueness(); const result = await migrate(db, { migrationsFolder: './drizzle' }); if (result) { diff --git a/src/app/api/account/export/route.test.ts b/src/app/api/account/export/route.test.ts new file mode 100644 index 0000000..cda767c --- /dev/null +++ b/src/app/api/account/export/route.test.ts @@ -0,0 +1,439 @@ +import { + createHash, + createSign, + createVerify, + generateKeyPairSync, + randomBytes, + randomUUID, +} from 'node:crypto'; + +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { generateDID } from '@/lib/crypto/did-key'; +import { canonicalize } from '@/lib/crypto/user-signing'; +import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key'; +import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof'; +import { + E2EE_CIPHER_SUITE, + E2EE_KEY_BUNDLE_ACTION, + E2EE_PROTOCOL, +} from '@/lib/e2ee/protocol'; + +const mocks = vi.hoisted(() => ({ + tables: { + users: { table: 'users' }, + posts: { table: 'posts' }, + media: { table: 'media' }, + follows: { table: 'follows' }, + remoteFollows: { table: 'remoteFollows' }, + chatConversations: { table: 'chatConversations' }, + chatMessages: { table: 'chatMessages' }, + e2eeKeyBundles: { table: 'e2eeKeyBundles' }, + e2eeMessageReceipts: { table: 'e2eeMessageReceipts' }, + bots: { table: 'bots' }, + botContentSources: { table: 'botContentSources' }, + botActivityLogs: { table: 'botActivityLogs' }, + }, + requireAuth: vi.fn(), + verifyPassword: vi.fn(), + hashPassword: vi.fn(), + createSession: vi.fn(), + findPosts: vi.fn(), + findFollows: vi.fn(), + findRemoteFollows: vi.fn(), + findConversations: vi.fn(), + findE2EEKeyBundle: vi.fn(), + findBots: vi.fn(), + findUsers: vi.fn(), + findNodes: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + transaction: vi.fn(), + insertedRows: [] as Array<{ table: unknown; values: Record }>, + safeFederationRequest: vi.fn(), +})); + +vi.mock('@/lib/auth', () => ({ + requireAuth: mocks.requireAuth, + verifyPassword: mocks.verifyPassword, + hashPassword: mocks.hashPassword, + createSession: mocks.createSession, +})); +vi.mock('@/db', () => ({ + ...mocks.tables, + db: { + insert: mocks.insert, + update: mocks.update, + transaction: mocks.transaction, + query: { + users: { findFirst: mocks.findUsers }, + nodes: { findFirst: mocks.findNodes }, + posts: { findMany: mocks.findPosts }, + follows: { findMany: mocks.findFollows }, + remoteFollows: { findMany: mocks.findRemoteFollows }, + chatConversations: { findMany: mocks.findConversations }, + e2eeKeyBundles: { findFirst: mocks.findE2EEKeyBundle }, + bots: { findMany: mocks.findBots }, + }, + }, +})); +vi.mock('drizzle-orm', async (importOriginal) => ({ + ...await importOriginal(), + eq: vi.fn(() => ({})), +})); +vi.mock('@/lib/bots/encryption', () => ({ + decryptApiKey: vi.fn(), + deserializeEncryptedData: vi.fn(), + encryptApiKey: vi.fn(), + serializeEncryptedData: vi.fn(), +})); +vi.mock('@/lib/federation/handles', () => ({ upsertHandleEntries: vi.fn() })); +vi.mock('@/lib/swarm/safe-federation-http', () => ({ + safeFederationRequest: mocks.safeFederationRequest, +})); + +import { POST } from './route'; +import { POST as importAccount } from '../import/route'; + +interface ExportResponseBody { + export: { + manifest: { + version: '1.1'; + did: string; + handle: string; + sourceNode: string; + exportedAt: string; + expiresAt: string; + publicKey: string; + privateKeyEncrypted: string; + payloadDigestAlgorithm: 'sha256'; + payloadDigest: string; + signature: string; + }; + profile: unknown; + posts: unknown[]; + following: unknown[]; + dms: unknown[]; + bots: unknown[]; + e2eeKeyBundle: unknown | null; + }; +} + +describe('account export manifest integrity', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.insertedRows.length = 0; + vi.stubEnv('NEXT_PUBLIC_NODE_DOMAIN', 'export.synapsis.social'); + mocks.verifyPassword.mockResolvedValue(true); + mocks.hashPassword.mockResolvedValue('imported-password-hash'); + mocks.createSession.mockResolvedValue('imported-session-token'); + mocks.findPosts.mockResolvedValue([]); + mocks.findFollows.mockResolvedValue([]); + mocks.findRemoteFollows.mockResolvedValue([]); + mocks.findConversations.mockResolvedValue([]); + mocks.findE2EEKeyBundle.mockResolvedValue(undefined); + mocks.findBots.mockResolvedValue([]); + mocks.findUsers.mockResolvedValue(undefined); + mocks.findNodes.mockResolvedValue(undefined); + mocks.safeFederationRequest.mockResolvedValue({ status: 204 }); + mocks.update.mockImplementation(() => ({ + set: () => ({ + where: () => Promise.resolve(), + }), + })); + mocks.insert.mockImplementation((table: unknown) => ({ + values: (values: Record) => { + mocks.insertedRows.push({ table, values }); + const returnedRows = table === mocks.tables.users + ? [{ id: 'imported-user-1', ...values }] + : table === mocks.tables.chatConversations + ? [{ id: 'imported-conversation-1', ...values }] + : []; + return Object.assign(Promise.resolve(), { + returning: vi.fn(async () => returnedRows), + onConflictDoNothing: vi.fn(async () => undefined), + }); + }, + })); + mocks.transaction.mockImplementation(async ( + callback: (tx: { insert: typeof mocks.insert }) => Promise, + ) => callback({ insert: mocks.insert })); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('decrypts the stored key to sign v1.1 while exporting its encrypted blob unchanged', async () => { + const password = 'correct horse battery staple'; + const { privateKey, publicKey } = generateKeyPairSync('ec', { + namedCurve: 'P-256', + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }); + const storedPrivateKey = serializeEncryptedKey(encryptPrivateKey(privateKey, password)); + const ownerDid = generateDID(publicKey); + const peerDid = 'did:synapsis:export-peer'; + const envelopeCreatedAt = Math.floor(Date.now() / 1_000) * 1_000 + 537; + const storedCreatedAt = new Date(Math.floor(envelopeCreatedAt / 1_000) * 1_000); + const messageId = randomUUID(); + const senderKeyId = `k1_${randomBytes(12).toString('base64url')}`; + const recipientKeyId = `k1_${randomBytes(12).toString('base64url')}`; + const envelope = { + protocol: E2EE_PROTOCOL, + cipherSuite: E2EE_CIPHER_SUITE, + messageId, + conversationId: `dm1_${randomBytes(12).toString('base64url')}`, + senderDid: ownerDid, + senderHandle: 'alice', + recipientDid: peerDid, + recipientHandle: 'bob', + createdAt: envelopeCreatedAt, + senderKeyId, + senderKeyVersion: 1, + recipientKeyId, + recipientKeyVersion: 1, + nonce: randomBytes(24).toString('base64url'), + ciphertext: randomBytes(17).toString('base64url'), + keyCommitment: randomBytes(32).toString('base64url'), + keyEnvelopes: [ + { + did: ownerDid, + keyId: senderKeyId, + keyVersion: 1, + sealedKey: randomBytes(112).toString('base64url'), + }, + { + did: peerDid, + keyId: recipientKeyId, + keyVersion: 1, + sealedKey: randomBytes(112).toString('base64url'), + }, + ], + }; + const actionNonce = randomBytes(16).toString('base64url'); + const actionPayload = { + action: 'chat_e2ee', + data: envelope, + did: ownerDid, + handle: 'alice', + nonce: actionNonce, + ts: envelopeCreatedAt, + }; + const actionSigner = createSign('sha256'); + actionSigner.update(canonicalize(actionPayload)); + const actionSignature = actionSigner.sign({ + key: privateKey, + dsaEncoding: 'ieee-p1363', + }).toString('base64url'); + + const encryptionPublicKey = randomBytes(32).toString('base64url'); + const continuityKeyId = await encryptionKeyIdFromPublicKey(encryptionPublicKey); + if (!continuityKeyId) throw new Error('Could not derive test encryption key ID'); + const continuityCreatedAt = Date.now(); + const continuityBundle = { + protocol: E2EE_PROTOCOL, + keyId: continuityKeyId, + version: 1, + publicKey: encryptionPublicKey, + createdAt: continuityCreatedAt, + recoveryCommitment: randomBytes(32).toString('base64url'), + }; + const continuityProofPayload = { + action: E2EE_KEY_BUNDLE_ACTION, + data: continuityBundle, + did: ownerDid, + handle: 'alice', + nonce: randomBytes(16).toString('base64url'), + ts: continuityCreatedAt, + }; + const continuitySigner = createSign('sha256'); + continuitySigner.update(canonicalize(continuityProofPayload)); + const continuityProof = { + ...continuityProofPayload, + sig: continuitySigner.sign({ + key: privateKey, + dsaEncoding: 'ieee-p1363', + }).toString('base64url'), + }; + + mocks.findConversations.mockResolvedValue([{ + id: 'conversation-1', + type: 'direct', + participant2Handle: 'bob', + lastMessageAt: storedCreatedAt, + lastMessagePreview: 'Encrypted message', + encryptionMode: 'e2ee', + e2eeActivatedAt: storedCreatedAt, + messages: [{ + senderHandle: 'alice', + senderDisplayName: 'Alice', + senderAvatarUrl: null, + senderNodeDomain: null, + senderDid: ownerDid, + content: null, + protocolVersion: 1, + clientMessageId: messageId, + encryptedEnvelope: JSON.stringify(envelope), + e2eeSignature: actionSignature, + e2eeActionNonce: actionNonce, + e2eeActionTs: envelopeCreatedAt, + deliveredAt: null, + readAt: null, + createdAt: storedCreatedAt, + }], + }]); + mocks.requireAuth.mockResolvedValue({ + id: 'user-1', + did: ownerDid, + handle: 'alice', + displayName: 'Alice', + bio: null, + avatarUrl: null, + headerUrl: null, + passwordHash: 'password-hash', + publicKey, + privateKeyEncrypted: storedPrivateKey, + movedTo: null, + }); + mocks.findE2EEKeyBundle.mockResolvedValue({ + did: ownerDid, + keyId: continuityKeyId, + keyVersion: 1, + publicKey: encryptionPublicKey, + proofAction: JSON.stringify(continuityProof), + }); + + const response = await POST(new NextRequest('https://export.synapsis.social/api/account/export', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password }), + })); + const body = await response.json() as ExportResponseBody; + + expect(response.status).toBe(200); + expect(body.export.manifest.version).toBe('1.1'); + expect(body.export.manifest.privateKeyEncrypted).toBe(storedPrivateKey); + expect(body.export.manifest).not.toHaveProperty('salt'); + expect(body.export.manifest).not.toHaveProperty('iv'); + expect(body.export.e2eeKeyBundle).toEqual({ + did: ownerDid, + keyId: continuityKeyId, + keyVersion: 1, + publicKey: encryptionPublicKey, + proofAction: continuityProof, + }); + + const { signature, ...manifestData } = body.export.manifest; + const verifier = createVerify('sha256'); + verifier.update(canonicalize(manifestData)); + expect(verifier.verify(publicKey, signature, 'base64')).toBe(true); + + const payload = { + profile: body.export.profile, + posts: body.export.posts, + following: body.export.following, + dms: body.export.dms, + bots: body.export.bots, + e2eeKeyBundle: body.export.e2eeKeyBundle, + }; + expect(body.export.manifest.payloadDigest).toBe( + createHash('sha256').update(canonicalize(payload)).digest('hex'), + ); + + // The exported message carries millisecond-authenticated envelope + // time while its DB timestamp is second-precision. A fresh importer + // must accept the round-trip and preserve account login plus the + // signed public continuity anchor (but never a PIN vault). + const importResponse = await importAccount(new NextRequest('https://new.example/api/account/import', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + exportData: body.export, + password, + destinationEmail: ' Alice@New.Example ', + newHandle: 'alice_new', + acceptedCompliance: true, + }), + })); + expect(importResponse.status).toBe(200); + await expect(importResponse.json()).resolves.toMatchObject({ + success: true, + stats: { dmsImported: 1 }, + warnings: expect.arrayContaining([ + expect.stringContaining('decryption key is not portable yet'), + ]), + }); + expect(mocks.hashPassword).toHaveBeenCalledWith(password); + + const importedUser = mocks.insertedRows.find(({ table }) => table === mocks.tables.users); + expect(importedUser?.values).toMatchObject({ + did: ownerDid, + email: 'alice@new.example', + passwordHash: 'imported-password-hash', + privateKeyEncrypted: storedPrivateKey, + }); + expect(mocks.createSession).toHaveBeenCalledOnce(); + expect(mocks.createSession).toHaveBeenCalledWith('imported-user-1'); + const importedContinuity = mocks.insertedRows.find( + ({ table }) => table === mocks.tables.e2eeKeyBundles, + ); + expect(importedContinuity?.values).toMatchObject({ + userId: 'imported-user-1', + did: ownerDid, + keyId: continuityKeyId, + keyVersion: 1, + publicKey: encryptionPublicKey, + }); + expect(JSON.parse(String(importedContinuity?.values.proofAction))).toEqual(continuityProof); + expect(importedContinuity?.values).not.toHaveProperty('ciphertext'); + expect(importedContinuity?.values).not.toHaveProperty('pinVerifierMac'); + expect(mocks.safeFederationRequest).toHaveBeenCalledWith( + 'https://export.synapsis.social/api/account/moved', + expect.objectContaining({ method: 'POST', timeoutMs: 5_000 }), + ); + + mocks.findUsers + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ id: 'existing-email-user' }); + const duplicateEmailResponse = await importAccount(new NextRequest('https://new.example/api/account/import', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + exportData: body.export, + password, + destinationEmail: 'alice@new.example', + newHandle: 'alice_other', + acceptedCompliance: true, + }), + })); + expect(duplicateEmailResponse.status).toBe(409); + await expect(duplicateEmailResponse.json()).resolves.toMatchObject({ + error: 'Email is already registered on this node', + }); + expect(mocks.createSession).toHaveBeenCalledOnce(); + + mocks.findUsers.mockResolvedValue(undefined); + mocks.createSession.mockRejectedValueOnce(new Error('session store unavailable')); + const sessionFailureResponse = await importAccount(new NextRequest('https://new.example/api/account/import', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + exportData: body.export, + password, + destinationEmail: 'alice-session-fallback@new.example', + newHandle: 'alice_retry', + acceptedCompliance: true, + }), + })); + expect(sessionFailureResponse.status).toBe(200); + await expect(sessionFailureResponse.json()).resolves.toMatchObject({ + success: true, + warnings: expect.arrayContaining([ + expect.stringContaining('automatic sign-in failed'), + ]), + }); + expect(mocks.createSession).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/app/api/account/export/route.ts b/src/app/api/account/export/route.ts index e8ff53b..14a3c9f 100644 --- a/src/app/api/account/export/route.ts +++ b/src/app/api/account/export/route.ts @@ -7,25 +7,36 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, verifyPassword } from '@/lib/auth'; -import { db, posts, media, follows, users, remoteFollows, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db'; -import { eq } from 'drizzle-orm'; +import { db } from '@/db'; import * as crypto from 'crypto'; import { decryptApiKey, deserializeEncryptedData } from '@/lib/bots/encryption'; +import { canonicalize, verifySignedActionSignature } from '@/lib/crypto/user-signing'; +import { + decryptPrivateKey as decryptStoredPrivateKey, + deserializeEncryptedKey, +} from '@/lib/crypto/private-key'; +import { + E2EE_KEY_BUNDLE_ACTION, + e2eeKeyBundleSchema, + signedUserActionSchema, + type SignedUserAction, +} from '@/lib/e2ee/protocol'; +import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof'; // We'll use a simple in-memory zip approach // For production, consider using a streaming zip library interface ExportManifest { - version: string; + version: '1.1'; did: string; handle: string; sourceNode: string; exportedAt: string; expiresAt: string; // Export expiration timestamp publicKey: string; - privateKeyEncrypted: string; // Encrypted with user's password - salt: string; // For key derivation - iv: string; // For AES encryption + privateKeyEncrypted: string; // Original serialized AES-GCM key blob + payloadDigestAlgorithm: 'sha256'; + payloadDigest: string; // Canonical digest of every non-manifest export field signature: string; // Proof of ownership } @@ -59,6 +70,8 @@ interface ExportDMConversation { participant2Handle: string; lastMessageAt: string | null; lastMessagePreview: string | null; + encryptionMode: string; + e2eeActivatedAt: string | null; messages: ExportDMMessage[]; } @@ -69,11 +82,25 @@ interface ExportDMMessage { senderNodeDomain: string | null; senderDid: string | null; content: string | null; + protocolVersion: number; + clientMessageId: string | null; + encryptedEnvelope: string | null; + e2eeSignature: string | null; + e2eeActionNonce: string | null; + e2eeActionTs: number | null; deliveredAt: string | null; readAt: string | null; createdAt: string; } +interface ExportE2EEContinuityAnchor { + did: string; + keyId: string; + keyVersion: number; + publicKey: string; + proofAction: SignedUserAction; +} + interface ExportBot { id: string; name: string; @@ -81,55 +108,91 @@ interface ExportBot { bio: string | null; avatarUrl: string | null; headerUrl: string | null; - personalityConfig: any; + personalityConfig: unknown; llmProvider: string; llmModel: string; llmApiKey: string; // Decrypted botPrivateKey: string; // Decrypted publicKey: string; - scheduleConfig: any; + scheduleConfig: unknown; autonomousMode: boolean; isActive: boolean; - sources: any[]; - activityLogs: any[]; + sources: unknown[]; + activityLogs: unknown[]; } -/** - * Encrypt the private key with user's password using AES-256-GCM - */ -function encryptPrivateKey(privateKey: string, password: string): { encrypted: string; salt: string; iv: string } { - const salt = crypto.randomBytes(32); - const iv = crypto.randomBytes(16); - - // Derive key from password - const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256'); - - // Encrypt - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); - let encrypted = cipher.update(privateKey, 'utf8', 'base64'); - encrypted += cipher.final('base64'); - const authTag = cipher.getAuthTag(); - - // Combine encrypted data with auth tag - const combined = Buffer.concat([Buffer.from(encrypted, 'base64'), authTag]).toString('base64'); - - return { - encrypted: combined, - salt: salt.toString('base64'), - iv: iv.toString('base64'), - }; +interface ExportPayload { + profile: ExportProfile; + posts: ExportPost[]; + following: ExportFollowing[]; + dms: ExportDMConversation[]; + bots: ExportBot[]; + e2eeKeyBundle: ExportE2EEContinuityAnchor | null; } /** * Sign the manifest to prove ownership */ function signManifest(manifest: Omit, privateKey: string): string { - const data = JSON.stringify(manifest); + const data = canonicalize(manifest); const sign = crypto.createSign('sha256'); sign.update(data); return sign.sign(privateKey, 'base64'); } +function digestExportPayload(payload: ExportPayload): string { + return crypto.createHash('sha256').update(canonicalize(payload)).digest('hex'); +} + +function signingKeyMatchesPublicKey(privateKey: string, publicKey: string): boolean { + try { + const derivedPublicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' }); + const expectedPublicKey = crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'der' }); + return derivedPublicKey.length === expectedPublicKey.length + && crypto.timingSafeEqual(derivedPublicKey, expectedPublicKey); + } catch { + return false; + } +} + +async function exportE2EEContinuityAnchor( + row: { + did: string; + keyId: string; + keyVersion: number; + publicKey: string; + proofAction: string; + } | undefined, + user: { did: string; handle: string; publicKey: string }, +): Promise { + if (!row) return null; + + const proof = signedUserActionSchema.parse(JSON.parse(row.proofAction)); + const bundle = e2eeKeyBundleSchema.parse(proof.data); + if (proof.action !== E2EE_KEY_BUNDLE_ACTION + || proof.did !== user.did + || proof.handle.toLowerCase() !== user.handle.toLowerCase() + || row.did !== proof.did + || row.keyId !== bundle.keyId + || row.keyVersion !== bundle.version + || row.publicKey !== bundle.publicKey + || Math.abs(bundle.createdAt - proof.ts) > 5 * 60 * 1_000 + || Buffer.from(bundle.publicKey, 'base64url').length !== 32 + || Buffer.from(bundle.recoveryCommitment, 'base64url').length !== 32 + || await encryptionKeyIdFromPublicKey(bundle.publicKey) !== bundle.keyId + || !await verifySignedActionSignature(proof, user.publicKey)) { + throw new Error('Stored E2EE continuity proof is invalid'); + } + + return { + did: row.did, + keyId: row.keyId, + keyVersion: row.keyVersion, + publicKey: row.publicKey, + proofAction: proof, + }; +} + export async function POST(req: NextRequest) { try { const user = await requireAuth(); @@ -156,6 +219,23 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'This account has already been migrated' }, { status: 400 }); } + if (!user.privateKeyEncrypted) { + return NextResponse.json({ error: 'Account signing key is unavailable' }, { status: 500 }); + } + + let signingPrivateKey: string; + try { + signingPrivateKey = decryptStoredPrivateKey( + deserializeEncryptedKey(user.privateKeyEncrypted), + password, + ); + } catch { + return NextResponse.json({ error: 'Account signing key could not be unlocked' }, { status: 500 }); + } + if (!signingKeyMatchesPublicKey(signingPrivateKey, user.publicKey)) { + return NextResponse.json({ error: 'Account signing key does not match this account' }, { status: 500 }); + } + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821'; // Fetch user's posts @@ -187,6 +267,11 @@ export async function POST(req: NextRequest) { } }); + const currentE2EEKeyBundle = await db.query.e2eeKeyBundles.findFirst({ + where: { userId: user.id }, + }); + const e2eeKeyBundle = await exportE2EEContinuityAnchor(currentE2EEKeyBundle, user); + // Fetch Bots const userBots = await db.query.bots.findMany({ where: { ownerId: user.id }, @@ -248,13 +333,21 @@ export async function POST(req: NextRequest) { participant2Handle: conv.participant2Handle, lastMessageAt: conv.lastMessageAt?.toISOString() || null, lastMessagePreview: conv.lastMessagePreview, + encryptionMode: conv.encryptionMode, + e2eeActivatedAt: conv.e2eeActivatedAt?.toISOString() || null, messages: conv.messages.map(msg => ({ senderHandle: msg.senderHandle, senderDisplayName: msg.senderDisplayName, senderAvatarUrl: msg.senderAvatarUrl, senderNodeDomain: msg.senderNodeDomain, senderDid: msg.senderDid, - content: msg.content, + content: msg.protocolVersion === 0 ? msg.content : null, + protocolVersion: msg.protocolVersion, + clientMessageId: msg.clientMessageId, + encryptedEnvelope: msg.encryptedEnvelope, + e2eeSignature: msg.e2eeSignature, + e2eeActionNonce: msg.e2eeActionNonce, + e2eeActionTs: msg.e2eeActionTs, deliveredAt: msg.deliveredAt?.toISOString() || null, readAt: msg.readAt?.toISOString() || null, createdAt: msg.createdAt.toISOString() @@ -311,39 +404,41 @@ export async function POST(req: NextRequest) { }; }); - // Encrypt private key - const privateKey = user.privateKeyEncrypted || ''; - const { encrypted, salt, iv } = encryptPrivateKey(privateKey, password); + const exportPayload: ExportPayload = { + profile, + posts: exportPosts, + following: exportFollowing, + dms: exportDMs, + bots: exportBots, + e2eeKeyBundle, + }; - // Build manifest (without signature first) + // Version 1.1 fails closed on older importers and binds a canonical + // digest of every non-manifest field into the signed manifest. const exportedAt = new Date(); const expiresAt = new Date(exportedAt.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days const manifestData: Omit = { - version: '1.0', + version: '1.1', did: user.did, handle: user.handle, sourceNode: nodeDomain, exportedAt: exportedAt.toISOString(), expiresAt: expiresAt.toISOString(), publicKey: user.publicKey, - privateKeyEncrypted: encrypted, - salt, - iv, + privateKeyEncrypted: user.privateKeyEncrypted, + payloadDigestAlgorithm: 'sha256', + payloadDigest: digestExportPayload(exportPayload), }; // Sign the manifest - const signature = signManifest(manifestData, privateKey); + const signature = signManifest(manifestData, signingPrivateKey); const manifest: ExportManifest = { ...manifestData, signature }; // Build the export package as JSON (ZIP would require additional library) // For MVP, we'll use a JSON format that can be easily converted to ZIP later const exportPackage = { manifest, - profile, - posts: exportPosts, - following: exportFollowing, - dms: exportDMs, - bots: exportBots, + ...exportPayload, }; return NextResponse.json({ diff --git a/src/app/api/account/import/route.test.ts b/src/app/api/account/import/route.test.ts new file mode 100644 index 0000000..1b4598a --- /dev/null +++ b/src/app/api/account/import/route.test.ts @@ -0,0 +1,529 @@ +import { generateKeyPairSync, createHash, createSign, randomBytes, randomUUID } from 'node:crypto'; + +import { NextRequest } from 'next/server'; +import { describe, expect, it } from 'vitest'; + +import { canonicalize } from '@/lib/crypto/user-signing'; +import { generateDID } from '@/lib/crypto/did-key'; +import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key'; +import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof'; +import { + E2EE_CIPHER_SUITE, + E2EE_KEY_BUNDLE_ACTION, + E2EE_PROTOCOL, +} from '@/lib/e2ee/protocol'; +import { POST } from './route'; + +const peerDid = 'did:synapsis:account-import-peer'; +const { privateKey, publicKey } = generateKeyPairSync('ec', { + namedCurve: 'P-256', + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, +}); +const ownerDid = generateDID(publicKey); +const storedPrivateKey = serializeEncryptedKey(encryptPrivateKey(privateKey, 'correct-password')); + +function basePayload(dms: unknown[]) { + return { + profile: { + displayName: 'Alice', + bio: null, + avatarUrl: null, + headerUrl: null, + }, + posts: [], + following: [], + dms, + bots: [], + }; +} + +function signedExport(payload: ReturnType, integrityProtected = true) { + const manifestData = { + version: '1.0' as const, + did: ownerDid, + handle: 'alice', + sourceNode: 'old.synapsis.social', + exportedAt: new Date(Date.now() - 1_000).toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + publicKey, + privateKeyEncrypted: 'AA==', + salt: randomBytes(32).toString('base64'), + iv: randomBytes(16).toString('base64'), + ...(integrityProtected ? { + payloadDigestAlgorithm: 'sha256' as const, + payloadDigest: createHash('sha256').update(canonicalize(payload)).digest('hex'), + } : {}), + }; + const signer = createSign('sha256'); + signer.update(JSON.stringify(manifestData)); + + return { + manifest: { + ...manifestData, + signature: signer.sign(privateKey, 'base64'), + }, + ...payload, + }; +} + +function signedV11Export( + payload: ReturnType, + options: { + did?: string; + sourceNode?: string; + e2eeKeyBundle?: unknown; + } = {}, +) { + const signedPayload = { + ...payload, + e2eeKeyBundle: options.e2eeKeyBundle ?? null, + }; + const manifestData = { + version: '1.1' as const, + did: options.did ?? ownerDid, + handle: 'alice', + sourceNode: options.sourceNode ?? 'old.synapsis.social', + exportedAt: new Date(Date.now() - 1_000).toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + publicKey, + privateKeyEncrypted: storedPrivateKey, + payloadDigestAlgorithm: 'sha256' as const, + payloadDigest: createHash('sha256').update(canonicalize(signedPayload)).digest('hex'), + }; + const signer = createSign('sha256'); + signer.update(canonicalize(manifestData)); + + return { + manifest: { + ...manifestData, + signature: signer.sign(privateKey, 'base64'), + }, + ...signedPayload, + }; +} + +async function continuityAnchor() { + const encryptionPublicKey = randomBytes(32).toString('base64url'); + const keyId = await encryptionKeyIdFromPublicKey(encryptionPublicKey); + if (!keyId) throw new Error('Could not derive test encryption key ID'); + + const createdAt = Date.now(); + const bundle = { + protocol: E2EE_PROTOCOL, + keyId, + version: 1, + publicKey: encryptionPublicKey, + createdAt, + recoveryCommitment: randomBytes(32).toString('base64url'), + }; + const proofPayload = { + action: E2EE_KEY_BUNDLE_ACTION, + data: bundle, + did: ownerDid, + handle: 'alice', + nonce: randomBytes(16).toString('base64url'), + ts: createdAt, + }; + const proofSigner = createSign('sha256'); + proofSigner.update(canonicalize(proofPayload)); + + return { + did: ownerDid, + keyId, + keyVersion: 1, + publicKey: encryptionPublicKey, + proofAction: { + ...proofPayload, + sig: proofSigner.sign({ + key: privateKey, + dsaEncoding: 'ieee-p1363', + }).toString('base64url'), + }, + }; +} + +async function importResponse( + exportData: unknown, + requestOverrides: Record = {}, +) { + return POST(new NextRequest('http://localhost/api/account/import', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + exportData, + password: 'wrong-on-purpose', + destinationEmail: 'alice@new.example', + newHandle: 'alice_new', + acceptedCompliance: true, + ...requestOverrides, + }), + })); +} + +function commonMessage(createdAt: string) { + return { + senderHandle: 'alice', + senderDisplayName: 'Alice', + senderAvatarUrl: null, + senderNodeDomain: null, + senderDid: ownerDid, + deliveredAt: null, + readAt: null, + createdAt, + }; +} + +function modernConversation(messages: unknown[], overrides: Record = {}) { + return { + id: randomUUID(), + type: 'direct', + participant2Handle: 'bob', + lastMessageAt: null, + lastMessagePreview: null, + encryptionMode: 'legacy', + e2eeActivatedAt: null, + messages, + ...overrides, + }; +} + +function encryptedMessage(createdAtMs: number) { + const messageId = randomUUID(); + const senderKeyId = `k1_${randomBytes(12).toString('base64url')}`; + const recipientKeyId = `k1_${randomBytes(12).toString('base64url')}`; + const envelope = { + protocol: E2EE_PROTOCOL, + cipherSuite: E2EE_CIPHER_SUITE, + messageId, + conversationId: `dm1_${randomBytes(12).toString('base64url')}`, + senderDid: ownerDid, + senderHandle: 'alice', + recipientDid: peerDid, + recipientHandle: 'bob', + createdAt: createdAtMs, + senderKeyId, + senderKeyVersion: 1, + recipientKeyId, + recipientKeyVersion: 1, + nonce: randomBytes(24).toString('base64url'), + ciphertext: randomBytes(17).toString('base64url'), + keyCommitment: randomBytes(32).toString('base64url'), + keyEnvelopes: [ + { + did: ownerDid, + keyId: senderKeyId, + keyVersion: 1, + sealedKey: randomBytes(112).toString('base64url'), + }, + { + did: peerDid, + keyId: recipientKeyId, + keyVersion: 1, + sealedKey: randomBytes(112).toString('base64url'), + }, + ], + }; + + const actionNonce = randomBytes(16).toString('base64url'); + const actionPayload = { + action: 'chat_e2ee', + data: envelope, + did: ownerDid, + handle: 'alice', + nonce: actionNonce, + ts: createdAtMs, + }; + const actionSigner = createSign('sha256'); + actionSigner.update(canonicalize(actionPayload)); + + return { + // SQLite timestamp columns retain seconds, while the authenticated + // envelope retains the original millisecond value. + ...commonMessage(new Date(Math.floor(createdAtMs / 1_000) * 1_000).toISOString()), + content: null, + protocolVersion: 1, + clientMessageId: messageId, + encryptedEnvelope: JSON.stringify(envelope), + e2eeSignature: actionSigner.sign({ + key: privateKey, + dsaEncoding: 'ieee-p1363', + }).toString('base64url'), + e2eeActionNonce: actionNonce, + e2eeActionTs: createdAtMs, + }; +} + +describe('account import DM integrity', () => { + it('requires a valid destination email before importing', async () => { + const response = await importResponse( + signedV11Export(basePayload([])), + { destinationEmail: 'not-an-email' }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: 'Enter a valid destination email address', + }); + }); + + it('accepts a v1.1 export with no E2EE continuity anchor', async () => { + const response = await importResponse(signedV11Export(basePayload([]))); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: 'Invalid password' }); + }); + + it('accepts a valid account-signed E2EE continuity anchor', async () => { + const anchor = await continuityAnchor(); + const response = await importResponse(signedV11Export(basePayload([]), { + e2eeKeyBundle: anchor, + })); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: 'Invalid password' }); + }); + + it('rejects a continuity anchor with a forged proof signature', async () => { + const anchor = await continuityAnchor(); + anchor.proofAction.sig = randomBytes(64).toString('base64url'); + const response = await importResponse(signedV11Export(basePayload([]), { + e2eeKeyBundle: anchor, + })); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining('continuity anchor signature is invalid'), + }); + }); + + it('rejects continuity row fields that do not match the signed bundle', async () => { + const anchor = await continuityAnchor(); + anchor.keyVersion = 2; + const response = await importResponse(signedV11Export(basePayload([]), { + e2eeKeyBundle: anchor, + })); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining('does not match its signed proof'), + }); + }); + + it('rejects a self-certifying DID claimed by a different signing key', async () => { + const otherKeys = generateKeyPairSync('ec', { + namedCurve: 'P-256', + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }); + const squattedDid = generateDID(otherKeys.publicKey); + const response = await importResponse(signedV11Export(basePayload([]), { + did: squattedDid, + })); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: 'Export identity must be a self-certifying did:key that matches its signing key', + }); + }); + + it('rejects legacy identity methods that are not self-certifying', async () => { + const response = await importResponse(signedV11Export(basePayload([]), { + did: 'did:synapsis:legacy-account', + })); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: 'Export identity must be a self-certifying did:key that matches its signing key', + }); + }); + + it.each([ + '127.0.0.1', + 'localhost:43821', + '10.0.0.8', + 'https://old.synapsis.social', + 'old.synapsis.social/api/account/moved', + 'user@old.synapsis.social', + ])('rejects unsafe migration source node %s', async (sourceNode) => { + const response = await importResponse(signedV11Export(basePayload([]), { + sourceNode, + })); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: 'Export source node is unsafe or invalid', + }); + }); + + it('rejects edits to any signed v1.1 export payload field', async () => { + const exportData = signedV11Export(basePayload([])); + exportData.profile.displayName = 'Mallory'; + + const response = await importResponse(exportData); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: 'Export payload integrity check failed', + }); + }); + + it('also verifies the digest when a legacy v1.0 manifest carries one', async () => { + const exportData = signedExport(basePayload([])); + exportData.profile.displayName = 'Mallory'; + + const response = await importResponse(exportData); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: 'Export payload integrity check failed', + }); + }); + + it('rejects unknown protocol versions instead of treating them as plaintext', async () => { + const createdAt = new Date().toISOString(); + const unknownMessage = { + ...commonMessage(createdAt), + content: 'must not be imported', + protocolVersion: 2, + clientMessageId: null, + encryptedEnvelope: null, + e2eeSignature: null, + e2eeActionNonce: null, + e2eeActionTs: null, + }; + const response = await importResponse(signedV11Export(basePayload([ + modernConversation([unknownMessage]), + ]))); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining('Unsupported DM protocol version'), + }); + }); + + it('rejects plaintext at or after a conversation E2EE cutover', async () => { + const cutoverMs = Math.floor(Date.now() / 1_000) * 1_000 + 789; + const cutover = new Date(cutoverMs); + const legacyMessage = { + ...commonMessage(new Date(Math.floor(cutoverMs / 1_000) * 1_000).toISOString()), + content: 'late plaintext', + protocolVersion: 0, + clientMessageId: null, + encryptedEnvelope: null, + e2eeSignature: null, + e2eeActionNonce: null, + e2eeActionTs: null, + }; + const response = await importResponse(signedV11Export(basePayload([ + modernConversation([legacyMessage], { + encryptionMode: 'e2ee', + e2eeActivatedAt: cutover.toISOString(), + }), + ]))); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining('plaintext at or after its E2EE cutover'), + }); + }); + + it('allows legacy plaintext from the second before the E2EE cutover', async () => { + const cutoverMs = Math.floor(Date.now() / 1_000) * 1_000 + 789; + const legacyMessage = { + ...commonMessage(new Date(Math.floor(cutoverMs / 1_000) * 1_000 - 1_000).toISOString()), + content: 'plaintext before cutover', + protocolVersion: 0, + clientMessageId: null, + encryptedEnvelope: null, + e2eeSignature: null, + e2eeActionNonce: null, + e2eeActionTs: null, + }; + const response = await importResponse(signedV11Export(basePayload([ + modernConversation([legacyMessage], { + encryptionMode: 'e2ee', + e2eeActivatedAt: new Date(cutoverMs).toISOString(), + }), + ]))); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: 'Invalid password' }); + }); + + it('strictly validates an encrypted envelope and its stored signature metadata', async () => { + const createdAt = Math.floor(Date.now() / 1_000) * 1_000 + 537; + const validMessage = encryptedMessage(createdAt); + const validResponse = await importResponse(signedV11Export(basePayload([ + modernConversation([validMessage], { + encryptionMode: 'e2ee', + e2eeActivatedAt: new Date(createdAt).toISOString(), + }), + ]))); + expect(validResponse.status).toBe(401); + + const invalidMessage = { ...validMessage, senderHandle: 'mallory' }; + const invalidResponse = await importResponse(signedV11Export(basePayload([ + modernConversation([invalidMessage], { + encryptionMode: 'e2ee', + e2eeActivatedAt: new Date(createdAt).toISOString(), + }), + ]))); + expect(invalidResponse.status).toBe(400); + await expect(invalidResponse.json()).resolves.toMatchObject({ + error: expect.stringContaining('sender handle does not match'), + }); + + const invalidSignature = { + ...validMessage, + e2eeSignature: randomBytes(64).toString('base64url'), + }; + const invalidSignatureResponse = await importResponse(signedV11Export(basePayload([ + modernConversation([invalidSignature], { + encryptionMode: 'e2ee', + e2eeActivatedAt: new Date(createdAt).toISOString(), + }), + ]))); + expect(invalidSignatureResponse.status).toBe(400); + await expect(invalidSignatureResponse.json()).resolves.toMatchObject({ + error: expect.stringContaining('signature is invalid'), + }); + }); + + it('rejects digest-less historical exports before importing unauthenticated payloads', async () => { + const createdAt = new Date().toISOString(); + const historicalMessage = { + ...commonMessage(createdAt), + content: 'historical plaintext', + }; + const historicalConversation = { + id: randomUUID(), + type: 'direct', + participant2Handle: 'bob', + lastMessageAt: createdAt, + lastMessagePreview: 'historical plaintext', + messages: [historicalMessage], + }; + const historical = await importResponse(signedExport( + basePayload([historicalConversation]), + false, + )); + expect(historical.status).toBe(400); + await expect(historical.json()).resolves.toMatchObject({ + error: 'Historical exports without an authenticated payload are not supported. Create a fresh export from your old node.', + }); + + const injectedConversation = { + ...historicalConversation, + messages: [{ ...historicalMessage, protocolVersion: 2 }], + }; + const injected = await importResponse(signedExport( + basePayload([injectedConversation]), + false, + )); + expect(injected.status).toBe(400); + await expect(injected.json()).resolves.toMatchObject({ + error: 'Historical exports without an authenticated payload are not supported. Create a fresh export from your old node.', + }); + }); +}); diff --git a/src/app/api/account/import/route.ts b/src/app/api/account/import/route.ts index 76df398..617733d 100644 --- a/src/app/api/account/import/route.ts +++ b/src/app/api/account/import/route.ts @@ -6,25 +6,101 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, users, posts, media, follows, remoteFollows, nodes, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db'; +import { db, users, posts, media, follows, remoteFollows, chatConversations, chatMessages, e2eeKeyBundles, e2eeMessageReceipts, bots, botContentSources, botActivityLogs } from '@/db'; import { eq, sql } from 'drizzle-orm'; import * as crypto from 'crypto'; +import { z } from 'zod'; import { encryptApiKey, serializeEncryptedData } from '@/lib/bots/encryption'; import { v4 as uuid } from 'uuid'; import { upsertHandleEntries } from '@/lib/federation/handles'; +import { canonicalize, verifySignedActionSignature } from '@/lib/crypto/user-signing'; +import { + decryptPrivateKey as decryptStoredPrivateKey, + deserializeEncryptedKey, + serializeEncryptedKey, +} from '@/lib/crypto/private-key'; +import { didKeyMatchesPublicKey } from '@/lib/crypto/did-key'; +import { createSession, hashPassword } from '@/lib/auth'; +import { + E2EE_CHAT_ACTION, + E2EE_KEY_BUNDLE_ACTION, + e2eeKeyBundleSchema, + e2eeMessageEnvelopeSchema, + signedUserActionSchema, + validateMessageBindings, +} from '@/lib/e2ee/protocol'; +import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof'; +import { getPublicSwarmDomain, normalizeNodeDomain } from '@/lib/swarm/node-domain'; +import { safeFederationRequest } from '@/lib/swarm/safe-federation-http'; -interface ImportManifest { - version: string; - did: string; - handle: string; - sourceNode: string; - exportedAt: string; - expiresAt?: string; // Optional for backward compatibility - publicKey: string; - privateKeyEncrypted: string; - salt: string; - iv: string; - signature: string; +const isoTimestampSchema = z.iso.datetime({ offset: true }); +const didSchema = z.string().min(8).max(2_048).regex(/^did:/); +const base64Schema = z.string().min(1).max(32_768).regex(/^[A-Za-z0-9+/=_-]+$/); +const destinationEmailSchema = z.string().trim().email().max(320); +const serializedEncryptedKeySchema = z.string().min(2).max(65_536).refine((value) => { + try { + const parsed = JSON.parse(value) as Record; + return Object.keys(parsed).length === 3 + && typeof parsed.encrypted === 'string' && base64Schema.safeParse(parsed.encrypted).success + && typeof parsed.salt === 'string' && base64Schema.max(256).safeParse(parsed.salt).success + && typeof parsed.iv === 'string' && base64Schema.max(256).safeParse(parsed.iv).success; + } catch { + return false; + } +}, { message: 'Invalid encrypted private key' }); + +// The property order of the 1.0 schema intentionally matches the historical +// exporter because those manifests were signed with JSON.stringify(). +const importManifestV10Schema = z.strictObject({ + version: z.literal('1.0'), + did: didSchema, + handle: z.string().min(1).max(320), + sourceNode: z.string().min(1).max(320), + exportedAt: isoTimestampSchema, + expiresAt: isoTimestampSchema.optional(), + publicKey: z.string().min(1).max(16_384), + privateKeyEncrypted: base64Schema, + salt: base64Schema.max(256), + iv: base64Schema.max(256), + payloadDigestAlgorithm: z.literal('sha256').optional(), + payloadDigest: z.string().regex(/^[a-f0-9]{64}$/).optional(), + signature: base64Schema, +}).superRefine((manifest, context) => { + if ((manifest.payloadDigestAlgorithm === undefined) !== (manifest.payloadDigest === undefined)) { + context.addIssue({ + code: 'custom', + message: 'Payload digest fields must be provided together', + }); + } +}); + +const importManifestV11Schema = z.strictObject({ + version: z.literal('1.1'), + did: didSchema, + handle: z.string().min(1).max(320), + sourceNode: z.string().min(1).max(320), + exportedAt: isoTimestampSchema, + expiresAt: isoTimestampSchema, + publicKey: z.string().min(1).max(16_384), + privateKeyEncrypted: serializedEncryptedKeySchema, + payloadDigestAlgorithm: z.literal('sha256'), + payloadDigest: z.string().regex(/^[a-f0-9]{64}$/), + signature: base64Schema, +}); + +const importManifestSchema = z.union([ + importManifestV10Schema, + importManifestV11Schema, +]); + +type ImportManifest = z.infer; + +function hasPayloadDigest(manifest: ImportManifest): manifest is ImportManifest & { + payloadDigestAlgorithm: 'sha256'; + payloadDigest: string; +} { + return manifest.payloadDigestAlgorithm === 'sha256' + && typeof manifest.payloadDigest === 'string'; } interface ImportProfile { @@ -56,6 +132,8 @@ interface ImportDMConversation { participant2Handle: string; lastMessageAt: string | null; lastMessagePreview: string | null; + encryptionMode: 'legacy' | 'e2ee'; + e2eeActivatedAt: string | null; messages: ImportDMMessage[]; } @@ -66,6 +144,12 @@ interface ImportDMMessage { senderNodeDomain: string | null; senderDid: string | null; content: string | null; + protocolVersion: 0 | 1; + clientMessageId: string | null; + encryptedEnvelope: string | null; + e2eeSignature: string | null; + e2eeActionNonce: string | null; + e2eeActionTs: number | null; deliveredAt: string | null; readAt: string | null; createdAt: string; @@ -78,32 +162,379 @@ interface ImportBot { bio: string | null; avatarUrl: string | null; headerUrl: string | null; - personalityConfig: any; + personalityConfig: unknown; llmProvider: string; llmModel: string; llmApiKey: string; botPrivateKey: string; publicKey: string; - scheduleConfig: any; + scheduleConfig: unknown; autonomousMode: boolean; isActive: boolean; - sources: any[]; - activityLogs: any[]; + sources: ImportBotSource[]; + activityLogs: ImportBotActivityLog[]; } -interface ImportPackage { - manifest: ImportManifest; - profile: ImportProfile; - posts: ImportPost[]; - following: ImportFollowing[]; - dms: ImportDMConversation[]; - bots: ImportBot[]; +interface ImportBotSource { + type: string; + url: string; + subreddit?: string | null; + apiKeyEncrypted?: string | null; + sourceConfig?: unknown; + keywords?: unknown; + isActive: boolean; +} + +interface ImportBotActivityLog { + action: string; + details: unknown; + success: boolean; + errorMessage?: string | null; + createdAt: string; +} + +const dmMessageCommonSchema = { + senderHandle: z.string().min(1).max(640), + senderDisplayName: z.string().max(1_000).nullable(), + senderAvatarUrl: z.string().max(16_384).nullable(), + senderNodeDomain: z.string().max(320).nullable(), + deliveredAt: isoTimestampSchema.nullable(), + readAt: isoTimestampSchema.nullable(), + createdAt: isoTimestampSchema, +}; + +const historicalLegacyDMMessageSchema = z.strictObject({ + ...dmMessageCommonSchema, + senderDid: didSchema.nullable(), + content: z.string().max(100_000).nullable(), +}); + +const legacyDMMessageSchema = z.strictObject({ + ...dmMessageCommonSchema, + senderDid: didSchema.nullable(), + content: z.string().max(100_000).nullable(), + protocolVersion: z.literal(0), + clientMessageId: z.null(), + encryptedEnvelope: z.null(), + e2eeSignature: z.null(), + e2eeActionNonce: z.null(), + e2eeActionTs: z.null(), +}); + +const encryptedDMMessageSchema = z.strictObject({ + ...dmMessageCommonSchema, + senderDid: didSchema, + content: z.null(), + protocolVersion: z.literal(1), + clientMessageId: z.string().uuid(), + encryptedEnvelope: z.string().min(2).max(32_768), + e2eeSignature: z.string().min(1).max(512).regex(/^[A-Za-z0-9_-]+$/), + e2eeActionNonce: z.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/), + e2eeActionTs: z.number().int().positive(), +}); + +const e2eeContinuityAnchorSchema = z.strictObject({ + did: didSchema, + keyId: z.string().min(12).max(96).regex(/^k1_[A-Za-z0-9_-]+$/), + keyVersion: z.number().int().positive().max(1_000_000), + publicKey: z.string().min(1).max(64).regex(/^[A-Za-z0-9_-]+$/), + proofAction: signedUserActionSchema, +}); + +interface ImportedE2EEContinuityAnchor { + did: string; + keyId: string; + keyVersion: number; + publicKey: string; + proofAction: z.infer; + createdAt: number; +} + +const dmConversationCommonSchema = { + id: z.string().min(1).max(128), + type: z.string().min(1).max(32), + participant2Handle: z.string().min(1).max(640), + lastMessageAt: isoTimestampSchema.nullable(), + lastMessagePreview: z.string().max(100_000).nullable(), + messages: z.array(z.unknown()).max(100_000), +}; + +const dmConversationV10Schema = z.strictObject({ + ...dmConversationCommonSchema, +}); + +const dmConversationV11Schema = z.strictObject({ + ...dmConversationCommonSchema, + encryptionMode: z.enum(['legacy', 'e2ee']), + e2eeActivatedAt: isoTimestampSchema.nullable(), +}); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isExactDevelopmentLoopbackNode(value: string): boolean { + const match = value.match(/^(?:localhost|127\.0\.0\.1|\[::1\])(?::(\d{1,5}))?$/); + if (!match) return false; + if (!match[1]) return true; + const port = Number(match[1]); + return Number.isInteger(port) && port >= 1 && port <= 65_535; +} + +function validatedSourceNode(value: string): string | null { + if (value !== value.trim()) return null; + const normalized = value.toLowerCase(); + if (!normalized || normalizeNodeDomain(normalized) !== normalized) return null; + + if (process.env.NODE_ENV === 'development' && isExactDevelopmentLoopbackNode(normalized)) { + return normalized; + } + + const publicDomain = getPublicSwarmDomain(normalized); + return publicDomain === normalized ? publicDomain : null; +} + +function sourceNodeProtocol(sourceNode: string): 'http' | 'https' { + return process.env.NODE_ENV === 'development' + && isExactDevelopmentLoopbackNode(sourceNode) + ? 'http' + : 'https'; +} + +async function validateE2EEContinuityAnchor( + rawAnchor: unknown, + manifest: ImportManifest, +): Promise { + const anchor = e2eeContinuityAnchorSchema.parse(rawAnchor); + const proof = anchor.proofAction; + const bundle = e2eeKeyBundleSchema.parse(proof.data); + + if (proof.action !== E2EE_KEY_BUNDLE_ACTION + || proof.did !== manifest.did + || proof.handle.toLowerCase() !== manifest.handle.toLowerCase() + || anchor.did !== manifest.did + || anchor.did !== proof.did + || anchor.keyId !== bundle.keyId + || anchor.keyVersion !== bundle.version + || anchor.publicKey !== bundle.publicKey + || Math.abs(bundle.createdAt - proof.ts) > 5 * 60 * 1_000) { + throw new Error('E2EE continuity anchor does not match its signed proof'); + } + if (Buffer.from(bundle.publicKey, 'base64url').length !== 32 + || Buffer.from(bundle.recoveryCommitment, 'base64url').length !== 32 + || await encryptionKeyIdFromPublicKey(bundle.publicKey) !== bundle.keyId) { + throw new Error('E2EE continuity anchor key is invalid'); + } + if (!await verifySignedActionSignature(proof, manifest.publicKey)) { + throw new Error('E2EE continuity anchor signature is invalid'); + } + + return { + did: anchor.did, + keyId: anchor.keyId, + keyVersion: anchor.keyVersion, + publicKey: anchor.publicKey, + proofAction: proof, + createdAt: bundle.createdAt, + }; +} + +function digestImportPayload(exportData: Record): string { + const payload = Object.fromEntries( + Object.entries(exportData).filter(([key]) => key !== 'manifest'), + ); + return crypto.createHash('sha256').update(canonicalize(payload)).digest('hex'); +} + +function payloadDigestMatches(expected: string, actual: string): boolean { + const expectedBytes = Buffer.from(expected, 'hex'); + const actualBytes = Buffer.from(actual, 'hex'); + return expectedBytes.length === actualBytes.length + && expectedBytes.length === 32 + && crypto.timingSafeEqual(expectedBytes, actualBytes); +} + +function sqliteTimestampSecond(timestamp: number): number { + return Math.floor(timestamp / 1_000); +} + +function validateCiphertextSizes(envelope: z.infer): void { + if (Buffer.from(envelope.nonce, 'base64url').length !== 24 + || Buffer.from(envelope.ciphertext, 'base64url').length < 17 + || Buffer.from(envelope.ciphertext, 'base64url').length > 8_192 + || Buffer.from(envelope.keyCommitment, 'base64url').length !== 32 + || envelope.keyEnvelopes.some((item) => Buffer.from(item.sealedKey, 'base64url').length !== 112)) { + throw new Error('Encrypted DM has invalid field sizes'); + } +} + +async function normalizeEncryptedDMMessage( + rawMessage: unknown, + ownerDid: string, + ownerPublicKey: string, +): Promise { + const message = encryptedDMMessageSchema.parse(rawMessage); + let rawEnvelope: unknown; + try { + rawEnvelope = JSON.parse(message.encryptedEnvelope); + } catch { + throw new Error('Encrypted DM envelope is not valid JSON'); + } + + const envelope = e2eeMessageEnvelopeSchema.parse(rawEnvelope); + const storedSenderHandle = message.senderHandle.toLowerCase().replace(/^@/, ''); + const signedSenderHandle = envelope.senderHandle.toLowerCase().replace(/^@/, ''); + if (storedSenderHandle !== signedSenderHandle + && !storedSenderHandle.startsWith(`${signedSenderHandle}@`)) { + throw new Error('Encrypted DM sender handle does not match its envelope'); + } + const signedAction = signedUserActionSchema.parse({ + action: E2EE_CHAT_ACTION, + data: envelope, + did: message.senderDid, + handle: envelope.senderHandle, + ts: message.e2eeActionTs, + nonce: message.e2eeActionNonce, + sig: message.e2eeSignature, + }); + validateMessageBindings(envelope, signedAction); + validateCiphertextSizes(envelope); + + // The export carries the imported account's signing key, so messages sent + // by that account can and must retain a valid original action signature. + // Incoming peer signatures remain structurally validated here and are + // verified against resolved peer keys when displayed by the chat client. + if (envelope.senderDid === ownerDid + && !await verifySignedActionSignature(signedAction, ownerPublicKey)) { + throw new Error('Encrypted DM signature is invalid'); + } + + if (message.clientMessageId !== envelope.messageId) { + throw new Error('Encrypted DM message ID does not match its envelope'); + } + if (sqliteTimestampSecond(Date.parse(message.createdAt)) + !== sqliteTimestampSecond(envelope.createdAt)) { + throw new Error('Encrypted DM timestamp does not match its envelope'); + } + if (envelope.senderDid !== ownerDid && envelope.recipientDid !== ownerDid) { + throw new Error('Encrypted DM does not include the imported account'); + } + if (envelope.senderDid === envelope.recipientDid || envelope.keyEnvelopes.length !== 2) { + throw new Error('Encrypted DM participants are invalid'); + } + + return { + ...message, + encryptedEnvelope: JSON.stringify(envelope), + }; +} + +function normalizeLegacyDMMessage( + rawMessage: unknown, + integrityProtected: boolean, +): ImportDMMessage { + const message = integrityProtected + ? legacyDMMessageSchema.parse(rawMessage) + : historicalLegacyDMMessageSchema.parse(rawMessage); + return { + ...message, + protocolVersion: 0, + clientMessageId: null, + encryptedEnvelope: null, + e2eeSignature: null, + e2eeActionNonce: null, + e2eeActionTs: null, + }; +} + +async function normalizeImportedDMs( + rawConversations: unknown[], + manifest: ImportManifest, + payloadIntegrityVerified: boolean, +): Promise { + const integrityProtected = payloadIntegrityVerified; + return Promise.all(rawConversations.map(async (rawConversation, conversationIndex) => { + const protectedConversation = integrityProtected + ? dmConversationV11Schema.parse(rawConversation) + : null; + const conversation = protectedConversation + ?? dmConversationV10Schema.parse(rawConversation); + const messages: ImportDMMessage[] = []; + let sawEncrypted = false; + + for (const [messageIndex, rawMessage] of conversation.messages.entries()) { + const rawVersion = isRecord(rawMessage) ? rawMessage.protocolVersion : undefined; + + try { + if (!integrityProtected) { + // Historical 1.0 exports predate encrypted DM fields. The + // strict historical schema rejects injected protocol + // markers instead of guessing that they are plaintext. + messages.push(normalizeLegacyDMMessage(rawMessage, false)); + } else if (rawVersion === 0) { + messages.push(normalizeLegacyDMMessage(rawMessage, true)); + } else if (rawVersion === 1) { + messages.push(await normalizeEncryptedDMMessage( + rawMessage, + manifest.did, + manifest.publicKey, + )); + sawEncrypted = true; + } else { + throw new Error('Unsupported DM protocol version'); + } + } catch (error) { + const reason = error instanceof Error ? error.message : 'invalid message'; + throw new Error(`Invalid DM ${conversationIndex + 1}.${messageIndex + 1}: ${reason}`); + } + } + + let encryptionMode: 'legacy' | 'e2ee' = 'legacy'; + let activatedAt: number | null = null; + if (protectedConversation) { + encryptionMode = protectedConversation.encryptionMode; + activatedAt = protectedConversation.e2eeActivatedAt + ? Date.parse(protectedConversation.e2eeActivatedAt) + : null; + if (encryptionMode === 'legacy' && activatedAt !== null) { + throw new Error(`Legacy DM conversation ${conversationIndex + 1} has an E2EE activation time`); + } + if (encryptionMode === 'legacy' && sawEncrypted) { + throw new Error(`Legacy DM conversation ${conversationIndex + 1} contains encrypted messages`); + } + if (encryptionMode === 'e2ee' && activatedAt === null) { + throw new Error(`Encrypted DM conversation ${conversationIndex + 1} has no activation time`); + } + } + + if (activatedAt !== null) { + const legacyAfterCutover = messages.some((message) => ( + message.protocolVersion === 0 + && sqliteTimestampSecond(Date.parse(message.createdAt)) + >= sqliteTimestampSecond(activatedAt!) + )); + if (legacyAfterCutover) { + throw new Error(`DM conversation ${conversationIndex + 1} contains plaintext at or after its E2EE cutover`); + } + } + + return { + id: conversation.id, + type: conversation.type, + participant2Handle: conversation.participant2Handle, + lastMessageAt: conversation.lastMessageAt, + lastMessagePreview: conversation.lastMessagePreview, + encryptionMode, + e2eeActivatedAt: activatedAt === null ? null : new Date(activatedAt).toISOString(), + messages, + }; + })); } /** * Decrypt the private key using the user's password */ -function decryptPrivateKey(encrypted: string, password: string, salt: string, iv: string): string { +function decryptLegacyExportPrivateKey(encrypted: string, password: string, salt: string, iv: string): string { const saltBuffer = Buffer.from(salt, 'base64'); const ivBuffer = Buffer.from(iv, 'base64'); const encryptedBuffer = Buffer.from(encrypted, 'base64'); @@ -125,13 +556,26 @@ function decryptPrivateKey(encrypted: string, password: string, salt: string, iv return decrypted.toString('utf8'); } +function signingKeyMatchesPublicKey(privateKey: string, publicKey: string): boolean { + try { + const derivedPublicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' }); + const expectedPublicKey = crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'der' }); + return derivedPublicKey.length === expectedPublicKey.length + && crypto.timingSafeEqual(derivedPublicKey, expectedPublicKey); + } catch { + return false; + } +} + /** * Verify the manifest signature */ function verifyManifestSignature(manifest: ImportManifest): boolean { try { const { signature, ...manifestData } = manifest; - const data = JSON.stringify(manifestData); + const data = manifest.version === '1.1' + ? canonicalize(manifestData) + : JSON.stringify(manifestData); const verify = crypto.createVerify('sha256'); verify.update(data); @@ -145,31 +589,39 @@ function verifyManifestSignature(manifest: ImportManifest): boolean { export async function POST(req: NextRequest) { try { - const body = await req.json(); - const { exportData, password, newHandle, acceptedCompliance } = body as { - exportData: ImportPackage; - password: string; - newHandle: string; - acceptedCompliance: boolean; - }; + const body: unknown = await req.json(); + if (!isRecord(body)) { + return NextResponse.json({ error: 'Invalid import request' }, { status: 400 }); + } + const { exportData, password, newHandle, destinationEmail, acceptedCompliance } = body; // Validate required fields - if (!exportData || !password || !newHandle) { + if (!isRecord(exportData) + || typeof password !== 'string' || password.length === 0 + || typeof newHandle !== 'string' || newHandle.length === 0 + || typeof destinationEmail !== 'string' || destinationEmail.length === 0) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } - if (!acceptedCompliance) { + const destinationEmailResult = destinationEmailSchema.safeParse(destinationEmail); + if (!destinationEmailResult.success) { + return NextResponse.json({ + error: 'Enter a valid destination email address', + }, { status: 400 }); + } + const destinationEmailClean = destinationEmailResult.data.toLowerCase(); + + if (acceptedCompliance !== true) { return NextResponse.json({ error: 'You must accept the content compliance agreement' }, { status: 400 }); } - const { manifest, profile, posts: importPosts, following } = exportData; - - // Validate manifest version - if (manifest.version !== '1.0') { - return NextResponse.json({ error: 'Unsupported export version' }, { status: 400 }); + const manifestResult = importManifestSchema.safeParse(exportData.manifest); + if (!manifestResult.success) { + return NextResponse.json({ error: 'Invalid or unsupported export manifest' }, { status: 400 }); } + const manifest = manifestResult.data; // Check if export has expired if (manifest.expiresAt) { @@ -181,25 +633,103 @@ export async function POST(req: NextRequest) { } } - const { dms: importDMs = [], bots: importBots = [] } = exportData; - // Verify signature if (!verifyManifestSignature(manifest)) { return NextResponse.json({ error: 'Invalid export signature' }, { status: 400 }); } + if (!didKeyMatchesPublicKey(manifest.did, manifest.publicKey)) { + return NextResponse.json({ + error: 'Export identity must be a self-certifying did:key that matches its signing key', + }, { status: 400 }); + } + const sourceNode = validatedSourceNode(manifest.sourceNode); + if (!sourceNode) { + return NextResponse.json({ error: 'Export source node is unsafe or invalid' }, { status: 400 }); + } + + if (!hasPayloadDigest(manifest)) { + return NextResponse.json({ + error: 'Historical exports without an authenticated payload are not supported. Create a fresh export from your old node.', + }, { status: 400 }); + } + const actualDigest = digestImportPayload(exportData); + if (!payloadDigestMatches(manifest.payloadDigest, actualDigest)) { + return NextResponse.json({ error: 'Export payload integrity check failed' }, { status: 400 }); + } + + const { profile: rawProfile, posts: rawPosts, following: rawFollowing } = exportData; + if (!isRecord(rawProfile) || !Array.isArray(rawPosts) || !Array.isArray(rawFollowing)) { + return NextResponse.json({ error: 'Invalid export payload' }, { status: 400 }); + } + const rawDMs = exportData.dms === undefined ? [] : exportData.dms; + const rawBots = exportData.bots === undefined ? [] : exportData.bots; + if (!Array.isArray(rawDMs) || !Array.isArray(rawBots)) { + return NextResponse.json({ error: 'Invalid export payload' }, { status: 400 }); + } + let importedE2EEKeyBundle: ImportedE2EEContinuityAnchor | null = null; + if (manifest.version === '1.1' && exportData.e2eeKeyBundle != null) { + try { + importedE2EEKeyBundle = await validateE2EEContinuityAnchor( + exportData.e2eeKeyBundle, + manifest, + ); + } catch (error) { + const reason = error instanceof Error ? error.message : 'invalid E2EE continuity anchor'; + return NextResponse.json({ error: `Invalid E2EE continuity anchor: ${reason}` }, { status: 400 }); + } + } + + let importDMs: ImportDMConversation[]; + try { + importDMs = await normalizeImportedDMs(rawDMs, manifest, true); + } catch (error) { + const reason = error instanceof Error ? error.message : 'invalid direct-message history'; + return NextResponse.json({ error: `Invalid direct-message history: ${reason}` }, { status: 400 }); + } + const encryptedDMImportWarning = importDMs.some((conversation) => ( + conversation.messages.some((message) => message.protocolVersion === 1) + )) + ? 'Encrypted DM records were preserved, but their decryption key is not portable yet. This history cannot be opened on this node.' + : null; + const federatedMoveWarning = importedE2EEKeyBundle + ? 'Federated DM relationships do not automatically follow a home-node move. Peers that cached your old full handle may reject the new handle until signed account-move support is implemented.' + : null; + + const profile = rawProfile as unknown as ImportProfile; + const importPosts = rawPosts as ImportPost[]; + const following = rawFollowing as ImportFollowing[]; + const importBots = rawBots as ImportBot[]; // Decrypt private key to verify password is correct let privateKey: string; + let privateKeyEncryptedForStorage: string; try { - privateKey = decryptPrivateKey( - manifest.privateKeyEncrypted, - password, - manifest.salt, - manifest.iv - ); - } catch (error) { + if (manifest.version === '1.1') { + privateKey = decryptStoredPrivateKey( + deserializeEncryptedKey(manifest.privateKeyEncrypted), + password, + ); + privateKeyEncryptedForStorage = manifest.privateKeyEncrypted; + } else { + privateKey = decryptLegacyExportPrivateKey( + manifest.privateKeyEncrypted, + password, + manifest.salt, + manifest.iv, + ); + privateKeyEncryptedForStorage = serializeEncryptedKey({ + encrypted: manifest.privateKeyEncrypted, + salt: manifest.salt, + iv: manifest.iv, + }); + } + } catch { return NextResponse.json({ error: 'Invalid password' }, { status: 401 }); } + if (!signingKeyMatchesPublicKey(privateKey, manifest.publicKey)) { + return NextResponse.json({ error: 'Export private key does not match its signing key' }, { status: 400 }); + } + const importedPasswordHash = await hashPassword(password); // Check if DID already exists on this node const existingDid = await db.query.users.findFirst({ @@ -212,6 +742,16 @@ export async function POST(req: NextRequest) { }, { status: 409 }); } + const existingEmail = await db.query.users.findFirst({ + where: { email: destinationEmailClean }, + }); + + if (existingEmail) { + return NextResponse.json({ + error: 'Email is already registered on this node', + }, { status: 409 }); + } + // Validate handle format const handleClean = newHandle.toLowerCase().replace(/^@/, '').trim(); if (!/^[a-zA-Z0-9_]{3,20}$/.test(handleClean)) { @@ -233,23 +773,43 @@ export async function POST(req: NextRequest) { } const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821'; - const oldActorUrl = `https://${manifest.sourceNode}/users/${manifest.handle}`; + const oldActorUrl = `${sourceNodeProtocol(sourceNode)}://${sourceNode}/users/${manifest.handle}`; const newActorUrl = `https://${nodeDomain}/users/${handleClean}`; - // Create the user with the same DID - const [newUser] = await db.insert(users).values({ - did: manifest.did, - handle: handleClean, - displayName: profile.displayName, - bio: profile.bio, - avatarUrl: profile.avatarUrl, // Note: URLs from old node might need re-uploading - headerUrl: profile.headerUrl, - publicKey: manifest.publicKey, - privateKeyEncrypted: privateKey, - movedFrom: oldActorUrl, - migratedAt: new Date(), - postsCount: importPosts.length, - }).returning(); + // The identity and its public continuity anchor are one unit. A key-ID + // conflict must not leave behind an account that cannot be retried. + const newUser = await db.transaction(async (tx) => { + const [createdUser] = await tx.insert(users).values({ + did: manifest.did, + handle: handleClean, + email: destinationEmailClean, + displayName: profile.displayName, + bio: profile.bio, + avatarUrl: profile.avatarUrl, // Note: URLs from old node might need re-uploading + headerUrl: profile.headerUrl, + publicKey: manifest.publicKey, + privateKeyEncrypted: privateKeyEncryptedForStorage, + passwordHash: importedPasswordHash, + movedFrom: oldActorUrl, + migratedAt: new Date(), + postsCount: importPosts.length, + }).returning(); + + if (importedE2EEKeyBundle) { + await tx.insert(e2eeKeyBundles).values({ + userId: createdUser.id, + did: importedE2EEKeyBundle.did, + keyId: importedE2EEKeyBundle.keyId, + keyVersion: importedE2EEKeyBundle.keyVersion, + publicKey: importedE2EEKeyBundle.publicKey, + proofAction: JSON.stringify(importedE2EEKeyBundle.proofAction), + createdAt: new Date(importedE2EEKeyBundle.createdAt), + updatedAt: new Date(), + }); + } + + return createdUser; + }); // Check if this is an NSFW node and auto-enable NSFW settings const node = await db.query.nodes.findFirst({ @@ -346,31 +906,53 @@ export async function POST(req: NextRequest) { .set({ followingCount: importedFollowing }) .where(eq(users.id, newUser.id)); - // Import DMs + // Import each conversation and all of its messages atomically. A + // failed message cannot leave a partial conversation behind. + let importedDMs = 0; for (const conv of importDMs) { try { - const [newConv] = await db.insert(chatConversations).values({ - participant1Id: newUser.id, - participant2Handle: conv.participant2Handle, - type: conv.type, - lastMessageAt: conv.lastMessageAt ? new Date(conv.lastMessageAt) : null, - lastMessagePreview: conv.lastMessagePreview - }).returning(); + await db.transaction(async (tx) => { + const [newConv] = await tx.insert(chatConversations).values({ + participant1Id: newUser.id, + participant2Handle: conv.participant2Handle, + type: conv.type, + lastMessageAt: conv.lastMessageAt ? new Date(conv.lastMessageAt) : null, + lastMessagePreview: conv.encryptionMode === 'e2ee' + ? 'Encrypted message' + : conv.lastMessagePreview, + encryptionMode: conv.encryptionMode, + e2eeActivatedAt: conv.e2eeActivatedAt ? new Date(conv.e2eeActivatedAt) : null, + }).returning(); - for (const msg of conv.messages) { - await db.insert(chatMessages).values({ - conversationId: newConv.id, - senderHandle: msg.senderHandle, - senderDisplayName: msg.senderDisplayName, - senderAvatarUrl: msg.senderAvatarUrl, - senderNodeDomain: msg.senderNodeDomain, - senderDid: msg.senderDid, - content: msg.content, - deliveredAt: msg.deliveredAt ? new Date(msg.deliveredAt) : null, - readAt: msg.readAt ? new Date(msg.readAt) : null, - createdAt: new Date(msg.createdAt) - }); - } + for (const msg of conv.messages) { + await tx.insert(chatMessages).values({ + conversationId: newConv.id, + senderHandle: msg.senderHandle, + senderDisplayName: msg.senderDisplayName, + senderAvatarUrl: msg.senderAvatarUrl, + senderNodeDomain: msg.senderNodeDomain, + senderDid: msg.senderDid, + content: msg.protocolVersion === 0 ? msg.content : null, + protocolVersion: msg.protocolVersion, + clientMessageId: msg.clientMessageId, + encryptedEnvelope: msg.encryptedEnvelope, + e2eeSignature: msg.e2eeSignature, + e2eeActionNonce: msg.e2eeActionNonce, + e2eeActionTs: msg.e2eeActionTs, + deliveredAt: msg.deliveredAt ? new Date(msg.deliveredAt) : null, + readAt: msg.readAt ? new Date(msg.readAt) : null, + createdAt: new Date(msg.createdAt), + }); + if (msg.protocolVersion === 1 && msg.clientMessageId && msg.senderDid) { + await tx.insert(e2eeMessageReceipts).values({ + ownerUserId: newUser.id, + senderDid: msg.senderDid, + messageId: msg.clientMessageId, + }).onConflictDoNothing(); + } + } + }); + importedDMs += 1; } catch (error) { console.error('Failed to import DM conversation:', error); } @@ -447,12 +1029,23 @@ export async function POST(req: NextRequest) { // Notify old node about the migration try { - await notifyOldNode(manifest.sourceNode, manifest.handle, newActorUrl, manifest.did, privateKey); + await notifyOldNode(sourceNode, manifest.handle, newActorUrl, manifest.did, privateKey); } catch (error) { console.error('Failed to notify old node:', error); // Don't fail the import if notification fails } + // Match registration/login behavior so the successful import can + // redirect directly into the authenticated app. The imported email + // and password hash also make ordinary email login work thereafter. + let sessionWarning: string | null = null; + try { + await createSession(newUser.id); + } catch (error) { + console.error('Account imported but automatic sign-in failed:', error); + sessionWarning = 'The account was imported, but automatic sign-in failed. Sign in with the destination email and import password.'; + } + return NextResponse.json({ success: true, user: { @@ -464,10 +1057,12 @@ export async function POST(req: NextRequest) { stats: { postsImported: importedPosts, followingImported: importedFollowing, - dmsImported: importDMs.length, + dmsImported: importedDMs, botsImported: importBots.length, }, - message: 'Account imported successfully. Your followers on other Synapsis nodes will be automatically migrated.', + warnings: [encryptedDMImportWarning, federatedMoveWarning, sessionWarning] + .filter((warning): warning is string => warning !== null), + message: 'Account imported successfully. The old node was notified of the move when reachable.', }); } catch (error) { @@ -498,7 +1093,8 @@ async function notifyOldNode( sign.update(JSON.stringify(payload)); const signature = sign.sign(privateKey, 'base64'); - const response = await fetch(`https://${sourceNode}/api/account/moved`, { + const response = await safeFederationRequest( + `${sourceNodeProtocol(sourceNode)}://${sourceNode}/api/account/moved`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -507,9 +1103,13 @@ async function notifyOldNode( ...payload, signature, }), + timeoutMs: 5_000, + maxResponseBytes: 64 * 1024, }); - if (!response.ok) { + // The safe requester never follows redirects. Treat every non-2xx, + // including redirects, as a failed best-effort notification. + if (response.status < 200 || response.status >= 300) { throw new Error(`Old node returned ${response.status}`); } } diff --git a/src/app/api/chat/receive/route.ts b/src/app/api/chat/receive/route.ts index aac7f34..cf98d7c 100644 --- a/src/app/api/chat/receive/route.ts +++ b/src/app/api/chat/receive/route.ts @@ -1,313 +1,361 @@ import { NextRequest, NextResponse } from 'next/server'; -import { db } from '@/db'; -import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema'; -import { eq, and } from 'drizzle-orm'; -import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature'; -import { verifySwarmRequest } from '@/lib/swarm/signature'; -import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache'; +import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; + +import { + chatConversations, + chatMessages, + db, + e2eeMessageReceipts, + handleRegistry, +} from '@/db'; +import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature'; +import { normalizeSigningPublicKey } from '@/lib/crypto/did-key'; +import { signingPublicKeyFromDid } from '@/lib/e2ee/bundle-proof'; +import { + E2EE_PROTOCOL_VERSION, + e2eeMessageEnvelopeSchema, + signedUserActionSchema, + validateMessageBindings, +} from '@/lib/e2ee/protocol'; import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist'; +import { safeFederationRequest } from '@/lib/swarm/safe-federation-http'; +import { verifySwarmRequest } from '@/lib/swarm/signature'; +import { isRateLimited } from '@/lib/rate-limit'; -const signedChatActionSchema = z.object({ - action: z.string().min(1), - did: z.string().regex(/^did:/, 'Must be a valid DID'), - handle: z.string().min(3).max(30), - ts: z.number(), - nonce: z.string().min(1), - sig: z.string().min(1), - data: z.object({ - recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'), - content: z.string().min(1).max(5000), - }), +const federatedEnvelopeSchema = z.strictObject({ + userAction: signedUserActionSchema, + fullSenderHandle: z.string().min(3).max(640), + sourceDomain: z.string().min(1).max(253), + destinationDomain: z.string().min(1).max(253), + route: z.literal('/api/chat/receive'), + deliveryId: z.string().min(12).max(512), + ts: z.number().int().positive(), + expiresAt: z.number().int().positive(), }); -// Backward compatibility for older nodes that sent legacy field names. -const legacyChatActionSchema = z.object({ - did: z.string().regex(/^did:/, 'Must be a valid DID'), - handle: z.string().min(3).max(30), - data: z.object({ - recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'), - content: z.string().min(1).max(5000), - }), - signature: z.string().min(1), - timestamp: z.number(), -}); +const remoteProfileResponseSchema = z.object({ + user: z.object({ + did: z.string(), + publicKey: z.string(), + displayName: z.string().nullish(), + avatarUrl: z.string().nullish(), + isBot: z.boolean().optional(), + }).passthrough(), +}).passthrough(); -const incomingChatActionSchema = z.union([signedChatActionSchema, legacyChatActionSchema]); - -const federatedEnvelopeSchema = z.object({ - userAction: incomingChatActionSchema, - fullSenderHandle: z.string().min(3).max(60), - sourceDomain: z.string().min(1), - ts: z.number(), -}); - -function normalizeSignedAction(action: z.infer): SignedAction { - if ('sig' in action) { - return action; - } - - return { - action: 'chat', - data: action.data, - did: action.did, - handle: action.handle, - ts: action.timestamp, - nonce: `legacy:${action.did}:${action.timestamp}`, - sig: action.signature, - }; +class E2EEIdentityContinuityError extends Error { + constructor() { + super('Sender identity continuity check failed'); + this.name = 'E2EEIdentityContinuityError'; + } } -/** - * POST /api/chat/receive - * Endpoint for receiving federated chat messages from other nodes. - * Expects either: - * 1. A SignedAction payload from the sender (legacy, for backward compatibility) - * 2. A federated envelope with node's signature and user's signed action - */ +const MAX_CONCURRENT_NODE_VERIFICATIONS = 32; +let activeNodeVerifications = 0; + export async function POST(request: NextRequest) { - try { - const body = await request.json(); - - // Check if this is a federated envelope (node-signed) - const swarmSignature = request.headers.get('X-Swarm-Signature'); - const sourceDomain = request.headers.get('X-Swarm-Source-Domain'); - - let signedAction: SignedAction; - let fullSenderHandle: string | null = null; - - if (swarmSignature && sourceDomain && body.userAction) { - const normalizedSourceDomain = normalizeNodeDomain(sourceDomain); - if (await isNodeBlocked(normalizedSourceDomain)) { - return NextResponse.json({ error: 'Blocked node' }, { status: 403 }); - } - - // Federated envelope format - validate and verify node signature - const envelopeValidation = federatedEnvelopeSchema.safeParse(body); - if (!envelopeValidation.success) { - return NextResponse.json( - { error: 'Invalid envelope payload', details: envelopeValidation.error.issues }, - { status: 400 } - ); - } - - const isValidNodeSig = await verifySwarmRequest( - { userAction: body.userAction, fullSenderHandle: body.fullSenderHandle, sourceDomain: body.sourceDomain, ts: body.ts }, - swarmSignature, - sourceDomain - ); - - if (!isValidNodeSig) { - console.error('[Chat Receive] Invalid node signature from:', sourceDomain); - return NextResponse.json({ error: 'Invalid node signature' }, { status: 403 }); - } - - // Extract user's signed action and full handle from envelope - signedAction = normalizeSignedAction(body.userAction); - fullSenderHandle = body.fullSenderHandle; - console.log(`[Chat Receive] Federated envelope from node: ${sourceDomain}, full handle: ${fullSenderHandle}`); - } else { - // Legacy format - direct user signed action - const actionValidation = incomingChatActionSchema.safeParse(body); - if (!actionValidation.success) { - return NextResponse.json( - { error: 'Invalid action payload', details: actionValidation.error.issues }, - { status: 400 } - ); - } - signedAction = normalizeSignedAction(actionValidation.data); - } - - const { did, handle, data } = signedAction; - const { recipientDid, content } = data || {}; - - // Use full handle if provided in envelope, otherwise fall back to signed handle - const senderHandle = fullSenderHandle || handle; - const senderDomainFromHandle = senderHandle.includes('@') - ? normalizeNodeDomain(senderHandle.split('@').pop() || '') - : null; - if (senderDomainFromHandle && await isNodeBlocked(senderDomainFromHandle)) { - return NextResponse.json({ error: 'Blocked node' }, { status: 403 }); - } - console.log(`[Chat Receive] From: ${senderHandle} (DID: ${did}), To: ${recipientDid}`); - - // 1. Resolve Sender Public Key - let senderUser = await db.query.users.findFirst({ - where: { did: did } - }); - - let publicKey = senderUser?.publicKey; - let senderDisplayName = senderUser?.displayName || senderHandle; - let senderAvatarUrl = senderUser?.avatarUrl; - let senderNodeDomain: string | null = null; - - if (!senderUser) { - // Unknown user - likely remote. We need to fetch their profile to get the public key. - // Derive domain from full sender handle if possible - if (senderHandle.includes('@')) { - const parts = senderHandle.split('@'); - senderNodeDomain = normalizeNodeDomain(parts[parts.length - 1]); - } else { - // Try to get from header first - const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain'); - if (sourceDomainHeader) { - senderNodeDomain = normalizeNodeDomain(sourceDomainHeader); - } else { - // Try handle registry (though we likely don't have it if we don't have the user) - const registryEntry = await db.query.handleRegistry.findFirst({ - where: { did: did } - }); - if (registryEntry) senderNodeDomain = normalizeNodeDomain(registryEntry.nodeDomain); - } - } - - if (senderNodeDomain && await isNodeBlocked(senderNodeDomain)) { - return NextResponse.json({ error: 'Blocked node' }, { status: 403 }); - } - - if (senderNodeDomain) { - try { - const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https'; - const remoteHandle = senderHandle.includes('@') ? senderHandle.split('@')[0] : senderHandle; - - // Fetch public key with TOFU validation - const { publicKey: cachedOrFreshKey, fromCache, keyChanged } = await fetchAndCacheRemoteKey( - did, - senderHandle, - senderNodeDomain, - async () => { - // Fetch profile from remote node - const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`); - if (!res.ok) return null; - const profileData = await res.json(); - const remoteProfile = profileData.user; - if (remoteProfile?.did !== did) { - console.error('[Chat Receive] DID mismatch for remote user'); - return null; - } - return remoteProfile?.publicKey || null; - } - ); - - if (cachedOrFreshKey) { - publicKey = cachedOrFreshKey; - console.log(`[Chat Receive] Using ${fromCache ? 'cached' : 'fetched'} public key for ${senderHandle}${keyChanged ? ' (KEY CHANGED!)' : ''}`); - - // Also fetch display name/avatar if not from cache (or if we need fresh data) - if (!fromCache || keyChanged) { - const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`); - if (res.ok) { - const profileData = await res.json(); - const remoteProfile = profileData.user; - senderDisplayName = remoteProfile?.displayName || senderHandle; - senderAvatarUrl = remoteProfile?.avatarUrl; - - // CACHE: Upsert the remote user into our local database - const { upsertRemoteUser } = await import('@/lib/swarm/user-cache'); - await upsertRemoteUser({ - handle: senderHandle, // use full handle (user@domain) - displayName: senderDisplayName, - avatarUrl: senderAvatarUrl || null, - did: did || '', - isBot: remoteProfile.isBot || false - }); - } - } - } else { - console.error('Failed to fetch remote profile: no key returned'); - } - } catch (e) { - console.error('Remote profile fetch failed:', e); - } - } - } - - if (!publicKey) { - return NextResponse.json({ error: 'Could not resolve sender public key' }, { status: 401 }); - } - - // 2. Verify Signature - const isValid = await verifyActionSignature(signedAction, publicKey); - if (!isValid) { - return NextResponse.json({ error: 'Invalid signature' }, { status: 403 }); - } - - // 3. Find Local Recipient - const recipientUser = await db.query.users.findFirst({ - where: { did: recipientDid } - }); - - if (!recipientUser) { - return NextResponse.json({ error: 'Recipient not found on this node' }, { status: 404 }); - } - - // 4. Find or Create Conversation - // For the RECIPIENT, the conversation is with the SENDER - // Use full handle from envelope if available, otherwise construct from handle + domain - const computedFullSenderHandle = senderHandle.includes('@') ? senderHandle : (senderNodeDomain ? `${senderHandle}@${senderNodeDomain}` : senderHandle); - - let conversation = await db.query.chatConversations.findFirst({ - where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: computedFullSenderHandle }] } - }); - - if (!conversation) { - const [newConv] = await db.insert(chatConversations).values({ - participant1Id: recipientUser.id, - participant2Handle: computedFullSenderHandle, - lastMessageAt: new Date(), - lastMessagePreview: content.slice(0, 50) - }).returning(); - conversation = newConv; - } else { - // Update preview - await db.update(chatConversations) - .set({ - lastMessageAt: new Date(), - lastMessagePreview: content.slice(0, 50), - updatedAt: new Date() - }) - .where(eq(chatConversations.id, conversation.id)); - } - - // 5. Store Message - await db.insert(chatMessages).values({ - conversationId: conversation.id, - senderHandle: computedFullSenderHandle, - senderDisplayName: senderDisplayName, - senderAvatarUrl: senderAvatarUrl, - senderNodeDomain: senderNodeDomain, - senderDid: did, - content: content, - deliveredAt: new Date(), - }); - - // 6. Update Registry (to ensure we can reply efficiently) - if (senderNodeDomain) { - await db.insert(handleRegistry).values({ - handle: computedFullSenderHandle, // user@domain - did: did, - nodeDomain: senderNodeDomain - }).onConflictDoUpdate({ - target: handleRegistry.handle, - set: { - did: did, - nodeDomain: senderNodeDomain - } - }); - } - - return NextResponse.json({ success: true }); - - } catch (error: any) { - console.error('Federated Receive Failed:', error); - - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid input', details: error.issues }, - { status: 400 } - ); - } - - return NextResponse.json({ error: error.message }, { status: 500 }); + try { + const swarmSignature = request.headers.get('X-Swarm-Signature'); + const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain'); + if (!swarmSignature || !sourceDomainHeader) { + return NextResponse.json({ + error: 'Encrypted federated messages require a node-signed envelope', + code: 'E2EE_REQUIRED', + }, { status: 426 }); } + + const body = federatedEnvelopeSchema.parse(await request.json()); + const sourceDomain = normalizeNodeDomain(sourceDomainHeader); + if (normalizeNodeDomain(body.sourceDomain) !== sourceDomain) { + return NextResponse.json({ error: 'Source node mismatch' }, { status: 403 }); + } + const localDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || ''); + if (!localDomain || normalizeNodeDomain(body.destinationDomain) !== localDomain) { + return NextResponse.json({ error: 'Destination node mismatch' }, { status: 403 }); + } + if (await isNodeBlocked(sourceDomain)) { + return NextResponse.json({ error: 'Blocked node' }, { status: 403 }); + } + if (Math.abs(Date.now() - body.ts) > 5 * 60 * 1000 || body.expiresAt < Date.now() + || body.expiresAt - body.ts > 5 * 60 * 1000) { + return NextResponse.json({ error: 'Federated envelope is stale' }, { status: 400 }); + } + if (isRateLimited('e2ee-federation-preauth-global', 1_200, 60 * 1000) + || activeNodeVerifications >= MAX_CONCURRENT_NODE_VERIFICATIONS) { + return NextResponse.json({ + error: 'Federated encrypted-message verification is busy', + code: 'E2EE_REMOTE_RATE_LIMITED', + }, { status: 429, headers: { 'Retry-After': '60' } }); + } + activeNodeVerifications += 1; + let nodeSignatureValid = false; + try { + nodeSignatureValid = await verifySwarmRequest(body, swarmSignature, sourceDomain); + } finally { + activeNodeVerifications -= 1; + } + if (!nodeSignatureValid) { + return NextResponse.json({ error: 'Invalid node signature' }, { status: 403 }); + } + if (isRateLimited(`e2ee-federation-node:${sourceDomain}`, 600, 60 * 1000)) { + return NextResponse.json({ + error: 'This node is sending encrypted messages too quickly', + code: 'E2EE_REMOTE_RATE_LIMITED', + }, { status: 429 }); + } + + const signedAction = body.userAction as SignedAction; + const envelope = e2eeMessageEnvelopeSchema.parse(signedAction.data); + validateMessageBindings(envelope, signedAction); + if (Math.abs(Date.now() - signedAction.ts) > 5 * 60 * 1000) { + return NextResponse.json({ error: 'Encrypted message action is stale' }, { status: 400 }); + } + if (body.deliveryId !== `${envelope.messageId}:${normalizeNodeDomain(body.destinationDomain)}`) { + return NextResponse.json({ error: 'Federated delivery identity mismatch' }, { status: 403 }); + } + + const senderHandle = body.fullSenderHandle.toLowerCase().replace(/^@/, ''); + const senderParts = senderHandle.split('@'); + if (senderParts.length !== 2 + || senderParts[0] !== signedAction.handle.toLowerCase() + || normalizeNodeDomain(senderParts[1]) !== sourceDomain) { + return NextResponse.json({ error: 'Federated sender handle mismatch' }, { status: 403 }); + } + if (Buffer.from(envelope.nonce, 'base64url').length !== 24 + || Buffer.from(envelope.ciphertext, 'base64url').length < 17 + || Buffer.from(envelope.ciphertext, 'base64url').length > 8_192 + || Buffer.from(envelope.keyCommitment, 'base64url').length !== 32 + || envelope.keyEnvelopes.some((item) => Buffer.from(item.sealedKey, 'base64url').length !== 112)) { + return NextResponse.json({ error: 'Invalid encrypted message sizes' }, { status: 400 }); + } + + // Reject nonexistent, unwilling, or stale-key recipients before resolving + // or persisting any attacker-controlled remote user profile. + const recipient = await db.query.users.findFirst({ where: { did: envelope.recipientDid } }); + if (!recipient || recipient.handle.includes('@') || recipient.id.startsWith('swarm:')) { + return NextResponse.json({ error: 'Recipient not found on this node' }, { status: 404 }); + } + const envelopeRecipientHandle = envelope.recipientHandle.toLowerCase().replace(/^@/, ''); + const recipientHandleParts = envelopeRecipientHandle.split('@'); + const recipientHandleMatches = recipientHandleParts.length === 1 + ? recipientHandleParts[0] === recipient.handle.toLowerCase() + : recipientHandleParts.length === 2 + && recipientHandleParts[0] === recipient.handle.toLowerCase() + && normalizeNodeDomain(recipientHandleParts[1]) === localDomain; + if (!recipientHandleMatches) { + return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 403 }); + } + if (recipient.isBot || recipient.dmPrivacy === 'none') { + return NextResponse.json({ error: 'Recipient does not accept direct messages' }, { status: 403 }); + } + if (recipient.dmPrivacy === 'following') { + const followsSender = await db.query.remoteFollows.findFirst({ + where: { AND: [{ followerId: recipient.id }, { targetHandle: senderHandle }] }, + }); + if (!followsSender) { + return NextResponse.json({ error: 'Recipient only accepts messages from accounts they follow' }, { status: 403 }); + } + } + + const [recipientKey, recipientVault] = await Promise.all([ + db.query.e2eeKeyBundles.findFirst({ where: { userId: recipient.id } }), + db.query.e2eeKeyVaults.findFirst({ where: { userId: recipient.id } }), + ]); + if (!recipientKey || !recipientVault) { + return NextResponse.json({ + error: recipientKey + ? 'Recipient needs to finish encrypted message setup on this node' + : 'Recipient has not set up encrypted messages', + code: 'E2EE_NOT_CONFIGURED', + }, { status: 409 }); + } + if (recipientKey.keyId !== recipientVault.keyId + || recipientKey.keyVersion !== recipientVault.keyVersion + || recipientKey.publicKey !== recipientVault.publicKey + || recipientVault.ownerDid !== recipient.did + || recipientKey.keyId !== envelope.recipientKeyId + || recipientKey.keyVersion !== envelope.recipientKeyVersion) { + return NextResponse.json({ + error: 'Recipient encryption key changed', + code: 'E2EE_RECIPIENT_KEY_STALE', + }, { status: 409 }); + } + + let senderUser = await db.query.users.findFirst({ where: { did: signedAction.did } }); + if (senderUser && !senderUser.handle.includes('@') && !senderUser.id.startsWith('swarm:')) { + return NextResponse.json({ error: 'A remote node cannot claim a local identity' }, { status: 403 }); + } + if (senderUser && senderUser.handle !== senderHandle) { + return NextResponse.json({ error: 'Sender identity continuity check failed' }, { status: 403 }); + } + + const didPublicKey = signingPublicKeyFromDid(signedAction.did); + let signingPublicKey = didPublicKey || senderUser?.publicKey || null; + let senderDisplayName = senderUser?.displayName || signedAction.handle; + let senderAvatarUrl = senderUser?.avatarUrl || null; + let resolvedProfile: z.infer['user'] | null = null; + let signatureVerified = !!signingPublicKey + && await verifyActionSignature(signedAction, signingPublicKey); + + if (didPublicKey && !signatureVerified) { + return NextResponse.json({ error: 'Invalid sender signature' }, { status: 403 }); + } + + if (!senderUser || !signatureVerified) { + const isDevelopmentLoopback = process.env.NODE_ENV === 'development' + && /^localhost(?::\d{1,5})?$/i.test(sourceDomain); + const protocol = isDevelopmentLoopback ? 'http' : 'https'; + const profileResponse = await safeFederationRequest(`${protocol}://${sourceDomain}/api/users/${encodeURIComponent(signedAction.handle)}`, { + headers: { Accept: 'application/json' }, + maxResponseBytes: 64 * 1024, + }); + if (profileResponse.status < 200 || profileResponse.status >= 300) { + return NextResponse.json({ error: 'Could not resolve sender identity' }, { status: 401 }); + } + const profileData = remoteProfileResponseSchema.parse(profileResponse.json()); + const profile = profileData.user; + if (profile?.did !== signedAction.did || !profile.publicKey) { + return NextResponse.json({ error: 'Sender identity does not match their node profile' }, { status: 403 }); + } + const profileSigningPublicKey = normalizeSigningPublicKey(profile.publicKey); + if (!profileSigningPublicKey) { + return NextResponse.json({ error: 'Sender profile contains an invalid signing key' }, { status: 403 }); + } + if (didPublicKey && didPublicKey !== profileSigningPublicKey) { + return NextResponse.json({ error: 'Sender signing key does not match their DID' }, { status: 403 }); + } + const resolvedSigningPublicKey = didPublicKey || profileSigningPublicKey; + signingPublicKey = resolvedSigningPublicKey; + signatureVerified = await verifyActionSignature(signedAction, resolvedSigningPublicKey); + if (!signatureVerified) { + return NextResponse.json({ error: 'Invalid sender signature' }, { status: 403 }); + } + senderDisplayName = profile.displayName || signedAction.handle; + senderAvatarUrl = profile.avatarUrl || null; + resolvedProfile = profile; + } + + if (!signingPublicKey || !signatureVerified) { + return NextResponse.json({ error: 'Invalid sender signature' }, { status: 403 }); + } + + if (isRateLimited(`e2ee-federation-sender:${sourceDomain}:${signedAction.did}`, 120, 60 * 1000)) { + return NextResponse.json({ + error: 'This sender is sending encrypted messages too quickly', + code: 'E2EE_REMOTE_RATE_LIMITED', + }, { status: 429 }); + } + if (senderUser) { + const blocked = await db.query.blocks.findFirst({ + where: { AND: [{ userId: recipient.id }, { blockedUserId: senderUser.id }] }, + }); + if (blocked) return NextResponse.json({ error: 'Message not permitted' }, { status: 403 }); + } + + if (resolvedProfile) { + const { upsertRemoteUser } = await import('@/lib/swarm/user-cache'); + await upsertRemoteUser({ + handle: senderHandle, + displayName: senderDisplayName, + avatarUrl: senderAvatarUrl, + did: signedAction.did, + isBot: resolvedProfile.isBot || false, + publicKey: normalizeSigningPublicKey(resolvedProfile.publicKey)!, + }); + senderUser = await db.query.users.findFirst({ where: { did: signedAction.did } }); + } + + const createdAt = new Date(envelope.createdAt); + await db.transaction(async (tx) => { + const [receipt] = await tx.insert(e2eeMessageReceipts).values({ + ownerUserId: recipient.id, + senderDid: signedAction.did, + messageId: envelope.messageId, + }).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id }); + if (!receipt) return; + + const [insertedRegistryEntry] = await tx.insert(handleRegistry).values({ + handle: senderHandle, + did: signedAction.did, + nodeDomain: sourceDomain, + }).onConflictDoNothing().returning({ + did: handleRegistry.did, + nodeDomain: handleRegistry.nodeDomain, + }); + const [registryEntry] = insertedRegistryEntry + ? [insertedRegistryEntry] + : await tx.select({ + did: handleRegistry.did, + nodeDomain: handleRegistry.nodeDomain, + }).from(handleRegistry).where(eq(handleRegistry.handle, senderHandle)).limit(1); + if (!registryEntry + || registryEntry.did !== signedAction.did + || normalizeNodeDomain(registryEntry.nodeDomain) !== sourceDomain) { + throw new E2EEIdentityContinuityError(); + } + + let [conversation] = await tx.select().from(chatConversations).where(and( + eq(chatConversations.participant1Id, recipient.id), + eq(chatConversations.participant2Handle, senderHandle), + )).limit(1); + if (!conversation) { + [conversation] = await tx.insert(chatConversations).values({ + participant1Id: recipient.id, + participant2Handle: senderHandle, + lastMessageAt: createdAt, + lastMessagePreview: 'Encrypted message', + encryptionMode: 'e2ee', + e2eeActivatedAt: createdAt, + }).returning(); + } else { + await tx.update(chatConversations).set({ + lastMessageAt: conversation.lastMessageAt && conversation.lastMessageAt > createdAt + ? conversation.lastMessageAt + : createdAt, + lastMessagePreview: 'Encrypted message', + encryptionMode: 'e2ee', + e2eeActivatedAt: conversation.e2eeActivatedAt ?? createdAt, + updatedAt: new Date(), + }).where(eq(chatConversations.id, conversation.id)); + } + if (!conversation) throw new Error('Failed to create encrypted conversation'); + + await tx.insert(chatMessages).values({ + conversationId: conversation.id, + senderHandle, + senderDisplayName, + senderAvatarUrl, + senderNodeDomain: sourceDomain, + senderDid: signedAction.did, + content: null, + protocolVersion: E2EE_PROTOCOL_VERSION, + clientMessageId: envelope.messageId, + encryptedEnvelope: JSON.stringify(envelope), + e2eeSignature: signedAction.sig, + e2eeActionNonce: signedAction.nonce, + e2eeActionTs: signedAction.ts, + deliveredAt: new Date(), + createdAt, + }); + }); + + return NextResponse.json({ success: true, messageId: envelope.messageId }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ + error: 'This node only accepts encrypted message envelopes', + code: 'E2EE_REQUIRED', + details: error.issues, + }, { status: 426 }); + } + if (error instanceof E2EEIdentityContinuityError) { + return NextResponse.json({ + error: error.message, + code: 'E2EE_IDENTITY_KEY_CHANGED', + }, { status: 409 }); + } + console.error('[E2EE Chat] Federated receive failed:', error); + return NextResponse.json({ error: error instanceof Error ? error.message : 'Receive failed' }, { status: 500 }); + } } diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 077fdf4..356793e 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1,263 +1,415 @@ import { NextRequest, NextResponse } from 'next/server'; -import { db } from '@/db'; -import { chatConversations, chatMessages, users, handleRegistry, follows } from '@/db/schema'; -import { requireSignedAction } from '@/lib/auth/verify-signature'; -import { eq, and } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; + +import { + chatConversations, + chatMessages, + db, + e2eeMessageReceipts, +} from '@/db'; +import { requireSignedAction, SignedActionError, type SignedAction } from '@/lib/auth/verify-signature'; +import { + E2EE_PROTOCOL_VERSION, + e2eeMessageEnvelopeSchema, + signedUserActionSchema, + validateMessageBindings, +} from '@/lib/e2ee/protocol'; import { createSignedPayload } from '@/lib/swarm/signature'; -import { federatedHandleSchema } from '@/lib/utils/federation'; import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist'; +import { getPublicSwarmDomain } from '@/lib/swarm/node-domain'; +import { safeFederationRequest } from '@/lib/swarm/safe-federation-http'; -const chatSendSchema = z.object({ - recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'), - recipientHandle: federatedHandleSchema, - content: z.string().min(1).max(5000), -}); - -/** - * POST /api/chat/send - * Send a signed chat message (verified with DID) - * Stores plain text content. - */ -export async function POST(request: NextRequest) { - try { - // Parse the signed action from the request body - const signedAction = await request.json(); - - // Strictly verify the signature and get the user - const user = await requireSignedAction(signedAction); - - // Extract message data - const data = chatSendSchema.parse(signedAction.data); - const { recipientDid, recipientHandle, content } = data; - - // Check if recipient is local - const recipientUser = await db.query.users.findFirst({ - where: { did: recipientDid } - }); - - // Check if recipient is a local user (not remote/swarm cached) - // Remote users have handles with @domain or IDs starting with "swarm:" - const isRemoteUser = recipientUser && ( - recipientUser.handle.includes('@') || - recipientUser.id.startsWith('swarm:') - ); - - if (recipientUser && !isRemoteUser) { - // Reject if recipient is a bot - if (recipientUser.isBot) { - return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 }); - } - - // Check DM privacy settings - if (recipientUser.dmPrivacy === 'none') { - return NextResponse.json({ error: 'This user does not accept direct messages' }, { status: 403 }); - } else if (recipientUser.dmPrivacy === 'following') { - // Check if recipient follows the sender - const isFollowingSender = await db.query.follows.findFirst({ - where: { AND: [{ followerId: recipientUser.id }, { followingId: user.id }] } - }); - if (!isFollowingSender) { - return NextResponse.json({ error: 'This user only accepts messages from accounts they follow' }, { status: 403 }); - } - } - // LOCAL RECIPIENT - - // Ensure conversations exist for both sides - // 1. Recipient's Inbox (Recipient -> User) - let recipientConv = await db.query.chatConversations.findFirst({ - where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: user.handle }] } - }); - - if (!recipientConv) { - const [newConv] = await db.insert(chatConversations).values({ - participant1Id: recipientUser.id, - participant2Handle: user.handle, - lastMessageAt: new Date(), - lastMessagePreview: content.slice(0, 50) - }).returning(); - recipientConv = newConv; - } else { - // Update preview - await db.update(chatConversations) - .set({ - lastMessageAt: new Date(), - lastMessagePreview: content.slice(0, 50), - updatedAt: new Date() - }) - .where(eq(chatConversations.id, recipientConv.id)); - } - - // 2. Sender's Sent Box (User -> Recipient) - let senderConv = await db.query.chatConversations.findFirst({ - where: { AND: [{ participant1Id: user.id }, { participant2Handle: recipientUser.handle }] } - }); - - if (!senderConv) { - const [newConv] = await db.insert(chatConversations).values({ - participant1Id: user.id, - participant2Handle: recipientUser.handle, - lastMessageAt: new Date(), - lastMessagePreview: content.slice(0, 50) - }).returning(); - senderConv = newConv; - } else { - await db.update(chatConversations) - .set({ - lastMessageAt: new Date(), - lastMessagePreview: content.slice(0, 50), - updatedAt: new Date() - }) - .where(eq(chatConversations.id, senderConv.id)); - } - - // Create message for recipient (Inbox) - await db.insert(chatMessages).values({ - conversationId: recipientConv.id, - senderHandle: user.handle, - senderDisplayName: user.displayName, - senderAvatarUrl: user.avatarUrl, - senderNodeDomain: null, - senderDid: user.did, - content: content, - deliveredAt: new Date(), - }); - - // Create message for sender (Sent) - await db.insert(chatMessages).values({ - conversationId: senderConv.id, - senderHandle: user.handle, - senderDisplayName: user.displayName, - senderAvatarUrl: user.avatarUrl, - senderNodeDomain: null, - senderDid: user.did, - content: content, - deliveredAt: new Date(), - readAt: new Date() // Sender has read their own message - }); - - return NextResponse.json({ success: true }); - } else { - // REMOTE RECIPIENT - - // 1. Resolve recipient node - const registryEntry = await db.query.handleRegistry.findFirst({ - where: { did: recipientDid } - }); - - // If not in registry, try to parse from handle if it has domain - let targetDomain: string | null = registryEntry?.nodeDomain || null; - let targetHandle = recipientHandle; - - if (!targetDomain && recipientHandle.includes('@')) { - const parts = recipientHandle.split('@'); - targetDomain = parts[parts.length - 1]; - } - - if (!targetDomain) { - console.error('Recipient node domain not found for:', recipientHandle); - return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 }); - } - - targetDomain = normalizeNodeDomain(targetDomain); - if (await isNodeBlocked(targetDomain)) { - return NextResponse.json({ error: 'This node is blocked by the server administrator' }, { status: 403 }); - } - - console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`); - - // 2. Send to Remote Node (Forward the Signed Action) - try { - const protocol = targetDomain.includes('localhost') ? 'http' : 'https'; - const sourceDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || ''; - - // Construct full sender handle for federation - const fullSenderHandle = `${user.handle}@${sourceDomain}`; - - console.log(`[Remote Send] Debug:`, { - targetDomain, - targetUrl: `${protocol}://${targetDomain}/api/chat/receive`, - sourceDomainEnv: sourceDomain, - fullSenderHandle, - }); - - // Create a federated envelope with node's signature - // This wraps the user's signed action with the full handle - const federatedPayload = { - userAction: signedAction, - fullSenderHandle, - sourceDomain, - ts: Date.now(), - }; - - const { payload, signature } = await createSignedPayload(federatedPayload); - - const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Swarm-Source-Domain': sourceDomain, - 'X-Swarm-Signature': signature, - }, - body: JSON.stringify(payload) - }); - - if (!res.ok) { - const errText = await res.text(); - console.error(`[Remote Send] Remote node rejected chat (${res.status}):`, errText); - return NextResponse.json({ error: `Remote delivery failed: ${res.statusText} - ${errText}` }, { status: 502 }); - } - } catch (err: any) { - console.error('[Remote Send] Failed to contact remote node:', err); - return NextResponse.json({ error: 'Failed to contact remote node' }, { status: 504 }); - } - - // 3. Store "Sent" copy locally - // Ensure conversation exists locally - let senderConv = await db.query.chatConversations.findFirst({ - where: { AND: [{ participant1Id: user.id }, { participant2Handle: recipientHandle }] } - }); - - if (!senderConv) { - const [newConv] = await db.insert(chatConversations).values({ - participant1Id: user.id, - participant2Handle: recipientHandle, - lastMessageAt: new Date(), - lastMessagePreview: content.slice(0, 50) - }).returning(); - senderConv = newConv; - } else { - await db.update(chatConversations) - .set({ - lastMessageAt: new Date(), - lastMessagePreview: content.slice(0, 50), - updatedAt: new Date() - }) - .where(eq(chatConversations.id, senderConv.id)); - } - - await db.insert(chatMessages).values({ - conversationId: senderConv.id, - senderHandle: user.handle, - senderDisplayName: user.displayName, - senderAvatarUrl: user.avatarUrl, - senderNodeDomain: null, - senderDid: user.did, - content: content, - deliveredAt: new Date(), - readAt: new Date() - }); - - return NextResponse.json({ success: true }); - } - - } catch (error: any) { - console.error('Send chat failed:', error); - - if (error instanceof z.ZodError) { - return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 }); - } - - return NextResponse.json({ error: error.message || 'Failed to send message' }, { status: 500 }); +function validateCiphertextLengths(envelope: z.infer): void { + if (Buffer.from(envelope.nonce, 'base64url').length !== 24) throw new Error('Invalid message nonce'); + const ciphertextLength = Buffer.from(envelope.ciphertext, 'base64url').length; + if (ciphertextLength < 17 || ciphertextLength > 8_192) throw new Error('Invalid encrypted message'); + if (Buffer.from(envelope.keyCommitment, 'base64url').length !== 32) { + throw new Error('Invalid encrypted message key commitment'); + } + for (const wrappedKey of envelope.keyEnvelopes) { + if (Buffer.from(wrappedKey.sealedKey, 'base64url').length !== 112) { + throw new Error('Invalid encrypted message key'); } + } +} + +function messageValues(input: { + conversationId: string; + senderHandle: string; + senderDisplayName: string | null; + senderAvatarUrl: string | null; + senderDid: string; + envelope: z.infer; + signedAction: SignedAction; + createdAt: Date; + readAt?: Date; +}) { + return { + conversationId: input.conversationId, + senderHandle: input.senderHandle, + senderDisplayName: input.senderDisplayName, + senderAvatarUrl: input.senderAvatarUrl, + senderNodeDomain: null, + senderDid: input.senderDid, + content: null, + protocolVersion: E2EE_PROTOCOL_VERSION, + clientMessageId: input.envelope.messageId, + encryptedEnvelope: JSON.stringify(input.envelope), + e2eeSignature: input.signedAction.sig, + e2eeActionNonce: input.signedAction.nonce, + e2eeActionTs: input.signedAction.ts, + deliveredAt: new Date(), + readAt: input.readAt, + createdAt: input.createdAt, + }; +} + +export async function POST(request: NextRequest) { + try { + const signedAction = signedUserActionSchema.parse(await request.json()) as SignedAction; + const user = await requireSignedAction(signedAction); + const envelope = e2eeMessageEnvelopeSchema.parse(signedAction.data); + validateMessageBindings(envelope, signedAction); + validateCiphertextLengths(envelope); + + if (envelope.senderDid !== user.did || envelope.senderHandle !== user.handle) { + return NextResponse.json({ error: 'Sender identity mismatch' }, { status: 403 }); + } + + const [senderKey, senderVault] = await Promise.all([ + db.query.e2eeKeyBundles.findFirst({ where: { userId: user.id } }), + db.query.e2eeKeyVaults.findFirst({ where: { userId: user.id } }), + ]); + if (!senderKey || !senderVault + || senderKey.keyId !== senderVault.keyId + || senderKey.keyVersion !== senderVault.keyVersion + || senderKey.publicKey !== senderVault.publicKey + || senderVault.ownerDid !== user.did + || senderKey.keyId !== envelope.senderKeyId + || senderKey.keyVersion !== envelope.senderKeyVersion) { + return NextResponse.json({ + error: senderKey && !senderVault + ? 'Finish encrypted message setup on this node before sending' + : 'Your encrypted message key changed. Reload Chat and try again.', + code: 'E2EE_SENDER_KEY_STALE', + }, { status: 409 }); + } + + const recipientUser = await db.query.users.findFirst({ where: { did: envelope.recipientDid } }); + const isRemoteRecipient = !recipientUser + || recipientUser.handle.includes('@') + || recipientUser.id.startsWith('swarm:'); + const createdAt = new Date(envelope.createdAt); + + if (recipientUser && !isRemoteRecipient) { + if (recipientUser.id === user.id) { + return NextResponse.json({ error: 'Cannot send an encrypted DM to yourself' }, { status: 400 }); + } + if (envelope.recipientHandle.toLowerCase() !== recipientUser.handle.toLowerCase()) { + return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 403 }); + } + if (recipientUser.isBot) { + return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 }); + } + if (recipientUser.dmPrivacy === 'none') { + return NextResponse.json({ error: 'This user does not accept direct messages' }, { status: 403 }); + } + if (recipientUser.dmPrivacy === 'following') { + const followsSender = await db.query.follows.findFirst({ + where: { AND: [{ followerId: recipientUser.id }, { followingId: user.id }] }, + }); + if (!followsSender) { + return NextResponse.json({ error: 'This user only accepts messages from accounts they follow' }, { status: 403 }); + } + } + const [recipientBlockedSender, senderBlockedRecipient] = await Promise.all([ + db.query.blocks.findFirst({ + where: { AND: [{ userId: recipientUser.id }, { blockedUserId: user.id }] }, + }), + db.query.blocks.findFirst({ + where: { AND: [{ userId: user.id }, { blockedUserId: recipientUser.id }] }, + }), + ]); + if (recipientBlockedSender || senderBlockedRecipient) { + return NextResponse.json({ error: 'Message not permitted' }, { status: 403 }); + } + + const [recipientKey, recipientVault] = await Promise.all([ + db.query.e2eeKeyBundles.findFirst({ where: { userId: recipientUser.id } }), + db.query.e2eeKeyVaults.findFirst({ where: { userId: recipientUser.id } }), + ]); + if (!recipientKey || !recipientVault) { + return NextResponse.json({ + error: recipientKey + ? 'Recipient needs to finish encrypted message setup on this node' + : 'Recipient has not set up encrypted messages', + code: 'E2EE_NOT_CONFIGURED', + }, { status: 409 }); + } + if (recipientKey.keyId !== recipientVault.keyId + || recipientKey.keyVersion !== recipientVault.keyVersion + || recipientKey.publicKey !== recipientVault.publicKey + || recipientVault.ownerDid !== recipientUser.did + || recipientKey.keyId !== envelope.recipientKeyId + || recipientKey.keyVersion !== envelope.recipientKeyVersion) { + return NextResponse.json({ + error: 'Recipient encryption key changed. Reload Chat and try again.', + code: 'E2EE_RECIPIENT_KEY_STALE', + }, { status: 409 }); + } + + await db.transaction(async (tx) => { + const [recipientReceipt] = await tx.insert(e2eeMessageReceipts).values({ + ownerUserId: recipientUser.id, + senderDid: user.did, + messageId: envelope.messageId, + }).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id }); + if (!recipientReceipt) return; + + const [senderReceipt] = await tx.insert(e2eeMessageReceipts).values({ + ownerUserId: user.id, + senderDid: user.did, + messageId: envelope.messageId, + }).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id }); + if (!senderReceipt) throw new Error('Encrypted message receipt state is inconsistent'); + + let [recipientConversation] = await tx.select().from(chatConversations).where(and( + eq(chatConversations.participant1Id, recipientUser.id), + eq(chatConversations.participant2Handle, user.handle), + )).limit(1); + if (!recipientConversation) { + [recipientConversation] = await tx.insert(chatConversations).values({ + participant1Id: recipientUser.id, + participant2Handle: user.handle, + lastMessageAt: createdAt, + lastMessagePreview: 'Encrypted message', + encryptionMode: 'e2ee', + e2eeActivatedAt: createdAt, + }).returning(); + } else { + await tx.update(chatConversations).set({ + lastMessageAt: recipientConversation.lastMessageAt && recipientConversation.lastMessageAt > createdAt + ? recipientConversation.lastMessageAt + : createdAt, + lastMessagePreview: 'Encrypted message', + encryptionMode: 'e2ee', + e2eeActivatedAt: recipientConversation.e2eeActivatedAt ?? createdAt, + updatedAt: new Date(), + }).where(eq(chatConversations.id, recipientConversation.id)); + } + + let [senderConversation] = await tx.select().from(chatConversations).where(and( + eq(chatConversations.participant1Id, user.id), + eq(chatConversations.participant2Handle, recipientUser.handle), + )).limit(1); + if (!senderConversation) { + [senderConversation] = await tx.insert(chatConversations).values({ + participant1Id: user.id, + participant2Handle: recipientUser.handle, + lastMessageAt: createdAt, + lastMessagePreview: 'Encrypted message', + encryptionMode: 'e2ee', + e2eeActivatedAt: createdAt, + }).returning(); + } else { + await tx.update(chatConversations).set({ + lastMessageAt: senderConversation.lastMessageAt && senderConversation.lastMessageAt > createdAt + ? senderConversation.lastMessageAt + : createdAt, + lastMessagePreview: 'Encrypted message', + encryptionMode: 'e2ee', + e2eeActivatedAt: senderConversation.e2eeActivatedAt ?? createdAt, + updatedAt: new Date(), + }).where(eq(chatConversations.id, senderConversation.id)); + } + + if (!recipientConversation || !senderConversation) { + throw new Error('Failed to create encrypted conversations'); + } + await tx.insert(chatMessages).values([ + messageValues({ + conversationId: recipientConversation.id, + senderHandle: user.handle, + senderDisplayName: user.displayName, + senderAvatarUrl: user.avatarUrl, + senderDid: user.did, + envelope, + signedAction, + createdAt, + }), + messageValues({ + conversationId: senderConversation.id, + senderHandle: user.handle, + senderDisplayName: user.displayName, + senderAvatarUrl: user.avatarUrl, + senderDid: user.did, + envelope, + signedAction, + createdAt, + readAt: new Date(), + }), + ]); + }); + + return NextResponse.json({ success: true, messageId: envelope.messageId }); + } + + const cachedRecipientKey = await db.query.e2eeRemoteKeyBundles.findFirst({ + where: { did: envelope.recipientDid }, + }); + if (!cachedRecipientKey || cachedRecipientKey.keyId !== envelope.recipientKeyId + || cachedRecipientKey.keyVersion !== envelope.recipientKeyVersion) { + return NextResponse.json({ + error: 'Recipient encryption key must be verified again', + code: 'E2EE_RECIPIENT_KEY_STALE', + }, { status: 409 }); + } + if (cachedRecipientKey.handle.toLowerCase().replace(/^@/, '') + !== envelope.recipientHandle.toLowerCase().replace(/^@/, '')) { + return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 403 }); + } + if (recipientUser) { + const senderBlockedRecipient = await db.query.blocks.findFirst({ + where: { AND: [{ userId: user.id }, { blockedUserId: recipientUser.id }] }, + }); + if (senderBlockedRecipient) { + return NextResponse.json({ error: 'Message not permitted' }, { status: 403 }); + } + } + + const fullRecipientHandle = envelope.recipientHandle.toLowerCase().replace(/^@/, ''); + const recipientHandleParts = fullRecipientHandle.split('@'); + if (recipientHandleParts.length !== 2 || !recipientHandleParts[0] || !recipientHandleParts[1]) { + return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 }); + } + let targetDomain: string | null = normalizeNodeDomain(recipientHandleParts[1]); + const registryEntry = await db.query.handleRegistry.findFirst({ + where: { handle: fullRecipientHandle }, + }); + if (registryEntry && normalizeNodeDomain(registryEntry.nodeDomain) !== targetDomain) { + return NextResponse.json({ + error: 'Recipient node identity does not match the verified handle', + code: 'E2EE_IDENTITY_KEY_CHANGED', + }, { status: 409 }); + } + const normalizedTargetDomain = normalizeNodeDomain(targetDomain); + const isDevelopmentLoopback = process.env.NODE_ENV === 'development' + && /^localhost(?::\d{1,5})?$/i.test(normalizedTargetDomain); + targetDomain = isDevelopmentLoopback + ? normalizedTargetDomain + : getPublicSwarmDomain(normalizedTargetDomain); + if (!targetDomain) { + return NextResponse.json({ error: 'Recipient node domain is invalid' }, { status: 400 }); + } + if (await isNodeBlocked(targetDomain)) { + return NextResponse.json({ error: 'Recipient node is blocked' }, { status: 403 }); + } + + const protocol = isDevelopmentLoopback ? 'http' : 'https'; + const sourceDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || ''); + if (!sourceDomain) { + return NextResponse.json({ error: 'This node is not configured for federated messages' }, { status: 503 }); + } + const deliveredAt = Date.now(); + const federatedPayload = { + userAction: signedAction, + fullSenderHandle: `${user.handle}@${sourceDomain}`, + sourceDomain, + destinationDomain: targetDomain, + route: '/api/chat/receive' as const, + deliveryId: `${envelope.messageId}:${targetDomain}`, + ts: deliveredAt, + expiresAt: deliveredAt + 5 * 60 * 1000, + }; + const { payload, signature } = await createSignedPayload(federatedPayload); + const remoteResponse = await safeFederationRequest(`${protocol}://${targetDomain}/api/chat/receive`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Swarm-Source-Domain': sourceDomain, + 'X-Swarm-Signature': signature, + }, + body: JSON.stringify(payload), + maxResponseBytes: 64 * 1024, + }); + if (remoteResponse.status < 200 || remoteResponse.status >= 300) { + let remoteBody: { error?: string; code?: string } | null = null; + try { + remoteBody = remoteResponse.json() as { error?: string; code?: string }; + } catch { + // Preserve a generic delivery error for non-JSON remote failures. + } + return NextResponse.json({ + error: remoteBody?.error || 'Recipient node rejected the encrypted message', + code: remoteBody?.code || 'E2EE_REMOTE_DELIVERY_FAILED', + }, { status: remoteResponse.status === 426 ? 409 : 502 }); + } + + await db.transaction(async (tx) => { + const [receipt] = await tx.insert(e2eeMessageReceipts).values({ + ownerUserId: user.id, + senderDid: user.did, + messageId: envelope.messageId, + }).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id }); + if (!receipt) return; + + let [senderConversation] = await tx.select().from(chatConversations).where(and( + eq(chatConversations.participant1Id, user.id), + eq(chatConversations.participant2Handle, envelope.recipientHandle), + )).limit(1); + if (!senderConversation) { + [senderConversation] = await tx.insert(chatConversations).values({ + participant1Id: user.id, + participant2Handle: envelope.recipientHandle, + lastMessageAt: createdAt, + lastMessagePreview: 'Encrypted message', + encryptionMode: 'e2ee', + e2eeActivatedAt: createdAt, + }).returning(); + } else { + await tx.update(chatConversations).set({ + lastMessageAt: senderConversation.lastMessageAt && senderConversation.lastMessageAt > createdAt + ? senderConversation.lastMessageAt + : createdAt, + lastMessagePreview: 'Encrypted message', + encryptionMode: 'e2ee', + e2eeActivatedAt: senderConversation.e2eeActivatedAt ?? createdAt, + updatedAt: new Date(), + }).where(eq(chatConversations.id, senderConversation.id)); + } + if (!senderConversation) throw new Error('Failed to create encrypted conversation'); + + await tx.insert(chatMessages).values(messageValues({ + conversationId: senderConversation.id, + senderHandle: user.handle, + senderDisplayName: user.displayName, + senderAvatarUrl: user.avatarUrl, + senderDid: user.did, + envelope, + signedAction, + createdAt, + readAt: new Date(), + })); + }); + + return NextResponse.json({ success: true, messageId: envelope.messageId }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ + error: 'This client must send an encrypted message envelope', + code: 'E2EE_REQUIRED', + details: error.issues, + }, { status: 426 }); + } + if (error instanceof SignedActionError) { + const rateLimited = error.message === 'RATE_LIMITED'; + return NextResponse.json({ + error: rateLimited ? 'Too many encrypted messages; try again shortly' : 'Message signature was rejected', + code: rateLimited ? 'E2EE_RATE_LIMITED' : 'E2EE_SIGNATURE_REJECTED', + }, { status: rateLimited ? 429 : 403 }); + } + console.error('[E2EE Chat] Send failed:', error); + return NextResponse.json({ error: error instanceof Error ? error.message : 'Send failed' }, { status: 500 }); + } } diff --git a/src/app/api/e2ee/keys/[did]/route.ts b/src/app/api/e2ee/keys/[did]/route.ts new file mode 100644 index 0000000..387a2ad --- /dev/null +++ b/src/app/api/e2ee/keys/[did]/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/db'; +import { e2eeKeyBundleSchema, signedUserActionSchema } from '@/lib/e2ee/protocol'; + +type RouteContext = { params: Promise<{ did: string }> }; + +export async function GET(_request: Request, context: RouteContext) { + const { did } = await context.params; + const user = await db.query.users.findFirst({ where: { did } }); + if (!user || user.handle.includes('@') || user.id.startsWith('swarm:')) { + return NextResponse.json({ error: 'Encryption key not found' }, { status: 404 }); + } + + const [row, vault] = await Promise.all([ + db.query.e2eeKeyBundles.findFirst({ where: { userId: user.id } }), + db.query.e2eeKeyVaults.findFirst({ where: { userId: user.id } }), + ]); + if (!row && !vault) { + return NextResponse.json({ + error: 'Encrypted messages are not configured for this account', + code: 'E2EE_NOT_CONFIGURED', + }, { status: 404 }); + } + if (!row || !vault || row.keyId !== vault.keyId || row.keyVersion !== vault.keyVersion + || row.publicKey !== vault.publicKey || vault.ownerDid !== user.did) { + return NextResponse.json({ + error: vault ? 'Encrypted message key state is inconsistent' : 'Encrypted messages need setup on this node', + code: vault ? 'E2EE_KEY_STATE_INVALID' : 'E2EE_NOT_CONFIGURED', + }, { status: vault ? 500 : 404 }); + } + + try { + const proof = signedUserActionSchema.parse(JSON.parse(row.proofAction)); + const bundle = e2eeKeyBundleSchema.parse(proof.data); + return NextResponse.json({ + bundle, + proof, + signingPublicKey: user.publicKey, + }, { headers: { 'Cache-Control': 'public, max-age=60, must-revalidate' } }); + } catch (error) { + console.error('[E2EE Keys] Stored key proof is invalid:', error); + return NextResponse.json({ error: 'Encryption key proof is invalid' }, { status: 500 }); + } +} diff --git a/src/app/api/e2ee/keys/resolve/route.ts b/src/app/api/e2ee/keys/resolve/route.ts new file mode 100644 index 0000000..e923c6c --- /dev/null +++ b/src/app/api/e2ee/keys/resolve/route.ts @@ -0,0 +1,186 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq } from 'drizzle-orm'; +import { z } from 'zod'; + +import { db, e2eeRemoteKeyBundles } from '@/db'; +import { getSession } from '@/lib/auth'; +import { normalizeSigningPublicKey } from '@/lib/crypto/did-key'; +import { signingPublicKeyFromDid, verifyE2EEPublicBundle } from '@/lib/e2ee/bundle-proof'; +import { e2eePublicBundleResponseSchema } from '@/lib/e2ee/protocol'; +import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist'; +import { getPublicSwarmDomain } from '@/lib/swarm/node-domain'; +import { safeFederationRequest } from '@/lib/swarm/safe-federation-http'; + +const querySchema = z.object({ + did: z.string().min(8).max(2_048).regex(/^did:/), + handle: z.string().min(1).max(320), +}); + +export async function GET(request: NextRequest) { + try { + const session = await getSession(); + if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const query = querySchema.parse({ + did: request.nextUrl.searchParams.get('did'), + handle: request.nextUrl.searchParams.get('handle'), + }); + + const localUser = await db.query.users.findFirst({ where: { did: query.did } }); + const isLocal = localUser && !localUser.handle.includes('@') && !localUser.id.startsWith('swarm:'); + if (isLocal) { + const requestedHandle = query.handle.toLowerCase().replace(/^@/, '').split('@')[0]; + if (requestedHandle !== localUser.handle.toLowerCase()) { + return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 409 }); + } + const [bundle, vault] = await Promise.all([ + db.query.e2eeKeyBundles.findFirst({ where: { userId: localUser.id } }), + db.query.e2eeKeyVaults.findFirst({ where: { userId: localUser.id } }), + ]); + if (!bundle && !vault) { + return NextResponse.json({ code: 'E2EE_NOT_CONFIGURED', error: 'Recipient has not set up encrypted messages' }, { status: 404 }); + } + if (!bundle || !vault || bundle.keyId !== vault.keyId || bundle.keyVersion !== vault.keyVersion + || bundle.publicKey !== vault.publicKey || vault.ownerDid !== localUser.did) { + return NextResponse.json({ + code: vault ? 'E2EE_KEY_STATE_INVALID' : 'E2EE_NOT_CONFIGURED', + error: vault + ? 'Recipient encryption key state is inconsistent' + : 'Recipient needs to finish encrypted message setup on this node', + }, { status: vault ? 500 : 404 }); + } + const response = e2eePublicBundleResponseSchema.parse({ + bundle: JSON.parse(bundle.proofAction).data, + proof: JSON.parse(bundle.proofAction), + signingPublicKey: localUser.publicKey, + }); + return NextResponse.json(response, { headers: { 'Cache-Control': 'no-store' } }); + } + + const handleParts = query.handle.toLowerCase().replace(/^@/, '').split('@'); + if (handleParts.length < 2) { + return NextResponse.json({ error: 'Remote recipient domain is missing' }, { status: 400 }); + } + const remoteHandle = handleParts[0]; + const normalizedDomain = normalizeNodeDomain(handleParts.at(-1) || ''); + const isDevelopmentLoopback = process.env.NODE_ENV === 'development' + && /^localhost(?::\d{1,5})?$/i.test(normalizedDomain); + const domain = isDevelopmentLoopback + ? normalizedDomain + : getPublicSwarmDomain(normalizedDomain); + if (!domain) { + return NextResponse.json({ error: 'Recipient node domain is invalid' }, { status: 400 }); + } + if (await isNodeBlocked(domain)) { + return NextResponse.json({ error: 'Recipient node is blocked' }, { status: 403 }); + } + + const protocol = isDevelopmentLoopback ? 'http' : 'https'; + const remote = await safeFederationRequest(`${protocol}://${domain}/api/e2ee/keys/${encodeURIComponent(query.did)}`, { + headers: { Accept: 'application/json' }, + maxResponseBytes: 64 * 1024, + }); + if (remote.status < 200 || remote.status >= 300) { + let remoteBody: { code?: string } | null = null; + try { + remoteBody = remote.json() as { code?: string }; + } catch { + // A non-JSON error response still maps to a bounded lookup failure. + } + const code = remoteBody?.code === 'E2EE_NOT_CONFIGURED' ? 'E2EE_NOT_CONFIGURED' : 'E2EE_KEY_LOOKUP_FAILED'; + return NextResponse.json({ + error: code === 'E2EE_NOT_CONFIGURED' + ? 'Recipient has not set up encrypted messages' + : 'Recipient encryption key could not be verified', + code, + }, { status: code === 'E2EE_NOT_CONFIGURED' ? 404 : 502 }); + } + + const response = e2eePublicBundleResponseSchema.parse(remote.json()); + if (response.proof.handle.toLowerCase() !== remoteHandle || !await verifyE2EEPublicBundle(response, query.did)) { + return NextResponse.json({ error: 'Recipient encryption key proof is invalid' }, { status: 502 }); + } + const resolvedSigningPublicKey = normalizeSigningPublicKey(response.signingPublicKey); + if (!resolvedSigningPublicKey) { + return NextResponse.json({ error: 'Recipient signing key is invalid' }, { status: 502 }); + } + const canonicalSigningPublicKey = signingPublicKeyFromDid(query.did) + || resolvedSigningPublicKey; + + const now = new Date(); + const persistedValues = { + did: query.did, + handle: query.handle, + keyId: response.bundle.keyId, + keyVersion: response.bundle.version, + publicKey: response.bundle.publicKey, + proofAction: JSON.stringify(response.proof), + signingPublicKey: canonicalSigningPublicKey, + updatedAt: now, + }; + let persisted = false; + for (let attempt = 0; attempt < 16; attempt += 1) { + const cached = await db.query.e2eeRemoteKeyBundles.findFirst({ where: { did: query.did } }); + const cachedSigningPublicKey = cached + ? normalizeSigningPublicKey(cached.signingPublicKey) + : null; + if (cached && cachedSigningPublicKey !== canonicalSigningPublicKey) { + return NextResponse.json({ + error: 'Recipient signing identity changed unexpectedly', + code: 'E2EE_IDENTITY_KEY_CHANGED', + }, { status: 409 }); + } + if (cached && ( + response.bundle.version < cached.keyVersion + || (response.bundle.version === cached.keyVersion && response.bundle.keyId !== cached.keyId) + || (response.bundle.version === cached.keyVersion && response.bundle.publicKey !== cached.publicKey) + || (response.bundle.version > cached.keyVersion && !response.bundle.replacesKeyId) + )) { + return NextResponse.json({ + error: 'Recipient encryption key continuity check failed', + code: 'E2EE_KEY_ROLLBACK', + }, { status: 409 }); + } + + if (!cached) { + const [inserted] = await db.insert(e2eeRemoteKeyBundles) + .values(persistedValues) + .onConflictDoNothing() + .returning({ did: e2eeRemoteKeyBundles.did }); + if (inserted) { + persisted = true; + break; + } + continue; + } + + const [updated] = await db.update(e2eeRemoteKeyBundles).set(persistedValues).where(and( + eq(e2eeRemoteKeyBundles.did, cached.did), + eq(e2eeRemoteKeyBundles.signingPublicKey, cached.signingPublicKey), + eq(e2eeRemoteKeyBundles.keyId, cached.keyId), + eq(e2eeRemoteKeyBundles.keyVersion, cached.keyVersion), + eq(e2eeRemoteKeyBundles.publicKey, cached.publicKey), + )).returning({ did: e2eeRemoteKeyBundles.did }); + if (updated) { + persisted = true; + break; + } + } + if (!persisted) { + return NextResponse.json({ + error: 'Recipient encryption key changed during verification; try again', + code: 'E2EE_KEY_CONFLICT', + }, { status: 409 }); + } + + return NextResponse.json({ ...response, signingPublicKey: canonicalSigningPublicKey }, { + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid key lookup request' }, { status: 400 }); + } + console.error('[E2EE Keys] Resolution failed:', error); + return NextResponse.json({ error: 'Recipient encryption key lookup failed', code: 'E2EE_KEY_LOOKUP_FAILED' }, { status: 502 }); + } +} diff --git a/src/app/api/e2ee/vault/route.ts b/src/app/api/e2ee/vault/route.ts new file mode 100644 index 0000000..26b593d --- /dev/null +++ b/src/app/api/e2ee/vault/route.ts @@ -0,0 +1,248 @@ +import crypto from 'node:crypto'; + +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq } from 'drizzle-orm'; +import { z } from 'zod'; + +import { db, e2eeKeyBundles, e2eeKeyVaults } from '@/db'; +import { requireSignedAction, SignedActionError, type SignedAction } from '@/lib/auth/verify-signature'; +import { + E2EE_KEY_BUNDLE_ACTION, + E2EE_MAX_UNLOCK_ATTEMPTS, + e2eeKeyBundleSchema, + e2eeVaultSetupSchema, + signedUserActionSchema, +} from '@/lib/e2ee/protocol'; +import { createPinVerifierMac, sealServerShare } from '@/lib/e2ee/server-secrets'; +import { getSession, verifyPassword } from '@/lib/auth'; +import { canonicalize } from '@/lib/crypto/user-signing'; + +const setupRequestSchema = z.strictObject({ + proof: signedUserActionSchema, + recovery: e2eeVaultSetupSchema, + currentPassword: z.string().min(8).max(256).optional(), +}); + +class E2EEKeyConflictError extends Error {} + +function byteLength(value: string): number { + return Buffer.from(value, 'base64url').length; +} + +function validateEncodedLengths(recovery: z.infer): void { + if (byteLength(recovery.vault.publicKey) !== 32) throw new Error('Invalid encryption public key'); + if (byteLength(recovery.serverShare) !== 32) throw new Error('Invalid recovery share'); + if (byteLength(recovery.pinVerifier) !== 32) throw new Error('Invalid PIN verifier'); + if (byteLength(recovery.vault.salt) !== 16) throw new Error('Invalid PIN salt'); + if (byteLength(recovery.vault.nonce) !== 24) throw new Error('Invalid vault nonce'); + const ciphertextLength = byteLength(recovery.vault.ciphertext); + if (ciphertextLength < 64 || ciphertextLength > 3_072) throw new Error('Invalid encrypted vault'); +} + +export async function GET() { + const session = await getSession(); + if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const [bundle, vault] = await Promise.all([ + db.query.e2eeKeyBundles.findFirst({ where: { userId: session.user.id } }), + db.query.e2eeKeyVaults.findFirst({ where: { userId: session.user.id } }), + ]); + + if (!bundle && !vault) { + return NextResponse.json( + { ownerDid: session.user.did, configured: false }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } + if (bundle && !vault && bundle.did === session.user.did) { + return NextResponse.json({ + ownerDid: session.user.did, + configured: false, + previousKey: { + keyId: bundle.keyId, + keyVersion: bundle.keyVersion, + }, + }, { headers: { 'Cache-Control': 'no-store' } }); + } + if (!bundle || !vault + || bundle.did !== session.user.did + || vault.ownerDid !== session.user.did + || bundle.keyId !== vault.keyId + || bundle.keyVersion !== vault.keyVersion + || bundle.publicKey !== vault.publicKey) { + return NextResponse.json({ error: 'Encrypted message recovery is inconsistent' }, { status: 500 }); + } + + return NextResponse.json({ + ownerDid: session.user.did, + configured: true, + keyId: bundle.keyId, + keyVersion: bundle.keyVersion, + publicKey: bundle.publicKey, + salt: vault.salt, + kdfAlgorithm: vault.kdfAlgorithm, + kdfOpsLimit: vault.kdfOpsLimit, + kdfMemLimit: vault.kdfMemLimit, + failedAttempts: vault.failedAttempts, + attemptsRemaining: vault.lockedUntil && vault.lockedUntil > new Date() + ? 0 + : Math.max(0, E2EE_MAX_UNLOCK_ATTEMPTS - vault.failedAttempts), + lockedUntil: vault.lockedUntil?.toISOString() ?? null, + }, { headers: { 'Cache-Control': 'no-store' } }); +} + +export async function POST(request: NextRequest) { + try { + const session = await getSession(); + if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = setupRequestSchema.parse(await request.json()); + const proof = body.proof; + if (proof.action !== E2EE_KEY_BUNDLE_ACTION) { + return NextResponse.json({ error: 'Invalid encryption key proof' }, { status: 400 }); + } + if (proof.did !== session.user.did || proof.handle !== session.user.handle) { + return NextResponse.json({ error: 'Active account does not match encryption setup' }, { status: 403 }); + } + + const user = await requireSignedAction(proof as SignedAction); + if (user.id !== session.user.id) { + return NextResponse.json({ error: 'Active account does not match encryption setup' }, { status: 403 }); + } + const bundle = e2eeKeyBundleSchema.parse(proof.data); + if (byteLength(bundle.recoveryCommitment) !== 32) { + return NextResponse.json({ error: 'Invalid recovery commitment' }, { status: 400 }); + } + const recovery = body.recovery; + validateEncodedLengths(recovery); + + const recoveryCommitment = crypto.createHash('sha256') + .update(canonicalize(recovery)) + .digest('base64url'); + if (bundle.recoveryCommitment !== recoveryCommitment) { + return NextResponse.json({ error: 'Recovery vault does not match the signed key proof' }, { status: 400 }); + } + + if (recovery.vault.ownerDid !== proof.did + || recovery.vault.keyId !== bundle.keyId + || recovery.vault.keyVersion !== bundle.version + || recovery.vault.publicKey !== bundle.publicKey) { + return NextResponse.json({ error: 'Recovery vault does not match the signed key' }, { status: 400 }); + } + if (Math.abs(bundle.createdAt - proof.ts) > 5 * 60 * 1000) { + return NextResponse.json({ error: 'Encryption key timestamp is invalid' }, { status: 400 }); + } + + const [existing, existingVault] = await Promise.all([ + db.query.e2eeKeyBundles.findFirst({ where: { userId: user.id } }), + db.query.e2eeKeyVaults.findFirst({ where: { userId: user.id } }), + ]); + if (existing && existing.did !== user.did) { + return NextResponse.json({ error: 'Encryption key identity is inconsistent' }, { status: 409 }); + } + if (!existing && bundle.replacesKeyId) { + return NextResponse.json({ error: 'There is no encryption key to replace' }, { status: 409 }); + } + if (existing && bundle.replacesKeyId !== existing.keyId) { + return NextResponse.json({ + error: 'Encrypted messages are already configured', + code: 'E2EE_ALREADY_CONFIGURED', + }, { status: 409 }); + } + if ((!existing && bundle.version !== 1) + || (existing && bundle.version !== existing.keyVersion + 1)) { + return NextResponse.json({ error: 'Encryption key version is invalid' }, { status: 409 }); + } + if (existingVault) { + if (!body.currentPassword || !user.passwordHash + || !await verifyPassword(body.currentPassword, user.passwordHash)) { + return NextResponse.json({ error: 'Current password is required to reset encrypted messages' }, { status: 403 }); + } + } + + const now = new Date(); + const verifierMac = createPinVerifierMac(recovery.pinVerifier, user.id, bundle.keyId); + const sealedShare = sealServerShare(recovery.serverShare, user.id, bundle.keyId); + const vaultValues = { + keyId: bundle.keyId, + keyVersion: bundle.version, + ownerDid: proof.did, + publicKey: bundle.publicKey, + ciphertext: recovery.vault.ciphertext, + nonce: recovery.vault.nonce, + salt: recovery.vault.salt, + kdfAlgorithm: recovery.vault.kdfAlgorithm, + kdfOpsLimit: recovery.vault.kdfOpsLimit, + kdfMemLimit: recovery.vault.kdfMemLimit, + pinVerifierMac: verifierMac, + serverShareEncrypted: sealedShare, + failedAttempts: 0, + lockedUntil: null, + updatedAt: now, + }; + + await db.transaction(async (tx) => { + if (existing) { + const [updatedBundle] = await tx.update(e2eeKeyBundles).set({ + did: user.did, + keyId: bundle.keyId, + keyVersion: bundle.version, + publicKey: bundle.publicKey, + proofAction: JSON.stringify(proof), + updatedAt: now, + }).where(and( + eq(e2eeKeyBundles.userId, user.id), + eq(e2eeKeyBundles.keyId, existing.keyId), + eq(e2eeKeyBundles.keyVersion, existing.keyVersion), + )).returning({ userId: e2eeKeyBundles.userId }); + if (!updatedBundle) throw new E2EEKeyConflictError('Encryption key changed during reset'); + + if (existingVault) { + const [updatedVault] = await tx.update(e2eeKeyVaults).set(vaultValues).where(and( + eq(e2eeKeyVaults.userId, user.id), + eq(e2eeKeyVaults.keyId, existing.keyId), + eq(e2eeKeyVaults.keyVersion, existing.keyVersion), + )).returning({ userId: e2eeKeyVaults.userId }); + if (!updatedVault) throw new E2EEKeyConflictError('Recovery vault changed during reset'); + } else { + await tx.insert(e2eeKeyVaults).values({ userId: user.id, ...vaultValues }); + } + return; + } + + await tx.insert(e2eeKeyBundles).values({ + userId: user.id, + did: user.did, + keyId: bundle.keyId, + keyVersion: bundle.version, + publicKey: bundle.publicKey, + proofAction: JSON.stringify(proof), + updatedAt: now, + }); + await tx.insert(e2eeKeyVaults).values({ userId: user.id, ...vaultValues }); + }); + + return NextResponse.json({ success: true, keyId: bundle.keyId }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid encrypted message setup', details: error.issues }, { status: 400 }); + } + if (error instanceof SignedActionError) { + return NextResponse.json({ + error: error.message === 'RATE_LIMITED' + ? 'Too many encryption setup attempts; try again shortly' + : 'Encryption key proof was rejected', + code: error.message === 'RATE_LIMITED' ? 'E2EE_RATE_LIMITED' : 'E2EE_SIGNATURE_REJECTED', + }, { status: error.message === 'RATE_LIMITED' ? 429 : 403 }); + } + if (error instanceof E2EEKeyConflictError + || (error instanceof Error && /unique|constraint/i.test(error.message))) { + return NextResponse.json({ + error: 'Encrypted messages changed in another tab. Reload Chat and try again.', + code: 'E2EE_KEY_CONFLICT', + }, { status: 409 }); + } + console.error('[E2EE Vault] Setup failed:', error); + return NextResponse.json({ error: error instanceof Error ? error.message : 'Setup failed' }, { status: 500 }); + } +} diff --git a/src/app/api/e2ee/vault/unlock/route.ts b/src/app/api/e2ee/vault/unlock/route.ts new file mode 100644 index 0000000..83d8d72 --- /dev/null +++ b/src/app/api/e2ee/vault/unlock/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, isNull } from 'drizzle-orm'; +import { z } from 'zod'; + +import { db, e2eeKeyVaults } from '@/db'; +import { getSession } from '@/lib/auth'; +import { + E2EE_LOCKOUT_MS, + E2EE_MAX_UNLOCK_ATTEMPTS, + E2EE_PROTOCOL, +} from '@/lib/e2ee/protocol'; +import { openServerShare, pinVerifierMatches } from '@/lib/e2ee/server-secrets'; + +const unlockSchema = z.strictObject({ + ownerDid: z.string().min(8).max(2_048).regex(/^did:/), + keyId: z.string().min(12).max(96).regex(/^k1_[A-Za-z0-9_-]+$/), + keyVersion: z.number().int().positive().max(1_000_000), + pinVerifier: z.string().min(40).max(64).regex(/^[A-Za-z0-9_-]+$/), +}); + +const MAX_CAS_RETRIES = 16; + +export async function POST(request: NextRequest) { + try { + const session = await getSession(); + if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const { ownerDid, keyId, keyVersion, pinVerifier } = unlockSchema.parse(await request.json()); + + let observedKey: { keyId: string; keyVersion: number } | null = null; + + for (let retry = 0; retry < MAX_CAS_RETRIES; retry += 1) { + const vault = await db.query.e2eeKeyVaults.findFirst({ where: { userId: session.user.id } }); + if (!vault) return NextResponse.json({ error: 'Encrypted messages are not configured' }, { status: 404 }); + + if (ownerDid !== session.user.did || vault.ownerDid !== ownerDid + || vault.keyId !== keyId || vault.keyVersion !== keyVersion) { + return NextResponse.json({ + error: 'The active account or encryption key changed during unlock', + code: 'E2EE_ACCOUNT_CHANGED', + }, { status: 409 }); + } + + if (observedKey && (vault.keyId !== observedKey.keyId || vault.keyVersion !== observedKey.keyVersion)) { + return NextResponse.json({ + error: 'Encryption key changed during unlock', + code: 'E2EE_KEY_CONFLICT', + }, { status: 409 }); + } + observedKey ??= { keyId: vault.keyId, keyVersion: vault.keyVersion }; + + const now = new Date(); + if (vault.lockedUntil && vault.lockedUntil > now) { + return NextResponse.json({ + error: 'Too many PIN attempts', + code: 'E2EE_PIN_LOCKED', + lockedUntil: vault.lockedUntil.toISOString(), + }, { status: 429 }); + } + + const unchangedVault = and( + eq(e2eeKeyVaults.userId, session.user.id), + eq(e2eeKeyVaults.keyId, vault.keyId), + eq(e2eeKeyVaults.keyVersion, vault.keyVersion), + eq(e2eeKeyVaults.pinVerifierMac, vault.pinVerifierMac), + eq(e2eeKeyVaults.failedAttempts, vault.failedAttempts), + vault.lockedUntil + ? eq(e2eeKeyVaults.lockedUntil, vault.lockedUntil) + : isNull(e2eeKeyVaults.lockedUntil), + ); + + if (!pinVerifierMatches(pinVerifier, vault.pinVerifierMac, session.user.id, vault.keyId)) { + const priorAttempts = vault.lockedUntil && vault.lockedUntil <= now ? 0 : vault.failedAttempts; + const failedAttempts = priorAttempts + 1; + const shouldLock = failedAttempts >= E2EE_MAX_UNLOCK_ATTEMPTS; + const lockedUntil = shouldLock ? new Date(Date.now() + E2EE_LOCKOUT_MS) : null; + const [updated] = await db.update(e2eeKeyVaults).set({ + failedAttempts: shouldLock ? 0 : failedAttempts, + lockedUntil, + updatedAt: now, + }).where(unchangedVault).returning({ userId: e2eeKeyVaults.userId }); + + if (!updated) continue; + + return NextResponse.json({ + error: shouldLock ? 'Too many PIN attempts' : 'Incorrect PIN', + code: shouldLock ? 'E2EE_PIN_LOCKED' : 'E2EE_PIN_INCORRECT', + attemptsRemaining: shouldLock ? 0 : E2EE_MAX_UNLOCK_ATTEMPTS - failedAttempts, + lockedUntil: lockedUntil?.toISOString() ?? null, + }, { status: shouldLock ? 429 : 403 }); + } + + const [updated] = await db.update(e2eeKeyVaults).set({ + failedAttempts: 0, + lockedUntil: null, + updatedAt: now, + }).where(unchangedVault).returning({ userId: e2eeKeyVaults.userId }); + + if (!updated) continue; + + return NextResponse.json({ + serverShare: openServerShare(vault.serverShareEncrypted, session.user.id, vault.keyId), + vault: { + protocol: E2EE_PROTOCOL, + ownerDid: session.user.did, + keyId: vault.keyId, + keyVersion: vault.keyVersion, + publicKey: vault.publicKey, + ciphertext: vault.ciphertext, + nonce: vault.nonce, + salt: vault.salt, + kdfAlgorithm: vault.kdfAlgorithm, + kdfOpsLimit: vault.kdfOpsLimit, + kdfMemLimit: vault.kdfMemLimit, + }, + }, { headers: { 'Cache-Control': 'no-store' } }); + } + + return NextResponse.json({ + error: 'Encrypted message unlock was busy; retry', + code: 'E2EE_UNLOCK_CONFLICT', + }, { status: 409 }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid unlock request' }, { status: 400 }); + } + console.error('[E2EE Vault] Unlock failed:', error); + return NextResponse.json({ error: 'Failed to unlock encrypted messages' }, { status: 500 }); + } +} diff --git a/src/app/api/swarm/announce/route.ts b/src/app/api/swarm/announce/route.ts index f79629c..b3c164d 100644 --- a/src/app/api/swarm/announce/route.ts +++ b/src/app/api/swarm/announce/route.ts @@ -33,7 +33,7 @@ const announcementSchema = z.object({ userCount: z.number().optional(), postCount: z.number().optional(), isNsfw: z.boolean().optional(), - capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(), + capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(), timestamp: z.string().optional(), }); diff --git a/src/app/api/swarm/chat/conversations/[id]/route.ts b/src/app/api/swarm/chat/conversations/[id]/route.ts index 47fd09a..9d6cf5a 100644 --- a/src/app/api/swarm/chat/conversations/[id]/route.ts +++ b/src/app/api/swarm/chat/conversations/[id]/route.ts @@ -5,10 +5,9 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, chatConversations, chatMessages, users } from '@/db'; -import { eq, and } from 'drizzle-orm'; +import { db, chatConversations } from '@/db'; +import { eq } from 'drizzle-orm'; import { getSession } from '@/lib/auth'; -import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist'; import { z } from 'zod'; // Schema for conversation ID parameter @@ -62,67 +61,31 @@ export async function DELETE( } if (deleteFor === 'both') { + const participant2Handle = conversation.participant2Handle; + if (participant2Handle.includes('@')) { + return NextResponse.json({ + error: 'Delete for everyone is not supported across nodes. You can still delete this conversation for yourself.', + code: 'REMOTE_DELETE_FOR_EVERYONE_UNSUPPORTED', + }, { status: 409 }); + } + // Delete the entire conversation and all messages (cascade will handle messages) await db.delete(chatConversations).where(eq(chatConversations.id, id)); - // Send deletion request to the other party - const participant2Handle = conversation.participant2Handle; - const isRemote = participant2Handle.includes('@'); + // Local user - find and delete their conversation too. + const recipientUser = await db.query.users.findFirst({ + where: { handle: participant2Handle }, + }); - if (isRemote) { - // Extract domain from handle (format: handle@domain) - const domain = normalizeNodeDomain(participant2Handle.split('@')[1]); - const handle = participant2Handle.split('@')[0]; - - try { - if (await isNodeBlocked(domain)) { - return NextResponse.json({ success: true }); - } - - const protocol = domain.includes('localhost') ? 'http' : 'https'; - const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; - - // SECURITY: Sign the deletion request - const { signPayload, getNodePrivateKey } = await import('@/lib/swarm/signature'); - const privateKey = await getNodePrivateKey(); - - const payload = { - senderHandle: session.user.handle, - senderNodeDomain: nodeDomain, - recipientHandle: handle, - conversationId: id, - timestamp: new Date().toISOString(), - }; - - const signature = signPayload(payload, privateKey); - - await fetch(`${protocol}://${domain}/api/swarm/chat/delete`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...payload, signature }), - }); - - console.log(`[Chat Delete] Sent deletion request to ${domain}`); - } catch (error) { - console.error('[Chat Delete] Failed to notify remote node:', error); - // Continue anyway - local deletion succeeded - } - } else { - // Local user - find and delete their conversation too - const recipientUser = await db.query.users.findFirst({ - where: { handle: participant2Handle }, + if (recipientUser) { + // Find their conversation with us + const recipientConversation = await db.query.chatConversations.findFirst({ + where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: session.user.handle }] }, }); - if (recipientUser) { - // Find their conversation with us - const recipientConversation = await db.query.chatConversations.findFirst({ - where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: session.user.handle }] }, - }); - - if (recipientConversation) { - await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id)); - console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`); - } + if (recipientConversation) { + await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id)); + console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`); } } diff --git a/src/app/api/swarm/chat/conversations/route.ts b/src/app/api/swarm/chat/conversations/route.ts index bc3a2a6..47464e3 100644 --- a/src/app/api/swarm/chat/conversations/route.ts +++ b/src/app/api/swarm/chat/conversations/route.ts @@ -4,12 +4,13 @@ * 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 { NextResponse } from 'next/server'; +import { db, chatMessages } from '@/db'; +import { eq, and, isNull, sql } from 'drizzle-orm'; import { getSession } from '@/lib/auth'; +import { E2EE_CHAT_ACTION, E2EE_PROTOCOL_VERSION, e2eeMessageEnvelopeSchema } from '@/lib/e2ee/protocol'; -export async function GET(request: NextRequest) { +export async function GET() { try { if (!db) { return NextResponse.json({ conversations: [] }); @@ -87,12 +88,13 @@ export async function GET(request: NextRequest) { avatarUrl: profileData.profile.avatarUrl || null, did: profileData.profile.did || '', isBot: profileData.profile.isBot || false, + publicKey: profileData.profile.publicKey, }); // Re-query to get the new cached user cachedUser = await db.query.users.findFirst({ where: { handle: participant2Handle }, - }) as any; + }); } } catch (e) { console.error(`[Lazy Load] Failed for ${participant2Handle}:`, e); @@ -102,18 +104,66 @@ export async function GET(request: NextRequest) { if (cachedUser) { participant2Info = { handle: cachedUser.handle, - displayName: (cachedUser as any).displayName || cachedUser.handle, - avatarUrl: (cachedUser as any).avatarUrl || null, - did: (cachedUser as any).did || '', + displayName: cachedUser.displayName || cachedUser.handle, + avatarUrl: cachedUser.avatarUrl || null, + did: cachedUser.did || '', }; } + const latest = conv.messages[0] || null; + let lastMessage: { + protocolVersion: number; + content: string | null; + encryptedEnvelope: unknown; + signedAction: unknown; + senderPublicKey: string | null; + } | null = latest ? { + protocolVersion: 0, + content: latest.content, + encryptedEnvelope: null, + signedAction: null, + senderPublicKey: null as string | null, + } : null; + + if (latest?.protocolVersion === E2EE_PROTOCOL_VERSION && latest.encryptedEnvelope + && latest.senderDid && latest.e2eeSignature && latest.e2eeActionNonce && latest.e2eeActionTs) { + try { + const encryptedEnvelope = e2eeMessageEnvelopeSchema.parse(JSON.parse(latest.encryptedEnvelope)); + const senderPublicKey = latest.senderDid === session.user.did + ? session.user.publicKey + : cachedUser?.did === latest.senderDid + ? cachedUser.publicKey + : null; + lastMessage = { + protocolVersion: E2EE_PROTOCOL_VERSION, + content: null, + encryptedEnvelope, + signedAction: { + action: E2EE_CHAT_ACTION, + data: encryptedEnvelope, + did: latest.senderDid, + handle: encryptedEnvelope.senderHandle, + ts: latest.e2eeActionTs, + nonce: latest.e2eeActionNonce, + sig: latest.e2eeSignature, + }, + senderPublicKey, + }; + } catch (error) { + console.error(`[E2EE Chat] Invalid conversation preview ${latest.id}:`, error); + lastMessage = null; + } + } + + const { messages: _rawMessages, ...conversation } = conv; + void _rawMessages; return { - ...conv, + ...conversation, participant2: { ...participant2Info, - isBot: (cachedUser as any)?.isBot || false, + isBot: cachedUser?.isBot || false, }, + lastMessage, unreadCount: Number(unreadCount[0]?.count || 0), }; }) @@ -121,7 +171,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ conversations: conversationsWithUnread.filter(c => !c.participant2.isBot), - }); + }, { headers: { 'Cache-Control': 'no-store' } }); } 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/messages/route.ts b/src/app/api/swarm/chat/messages/route.ts index 5753ab0..1b24ae5 100644 --- a/src/app/api/swarm/chat/messages/route.ts +++ b/src/app/api/swarm/chat/messages/route.ts @@ -6,10 +6,11 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, chatConversations, chatMessages, users } from '@/db'; -import { eq, desc, and, lt, isNull, sql, inArray } from 'drizzle-orm'; +import { db, chatMessages, users } from '@/db'; +import { eq, and, isNull } from 'drizzle-orm'; import { getSession } from '@/lib/auth'; import { z } from 'zod'; +import { E2EE_CHAT_ACTION, E2EE_PROTOCOL_VERSION, e2eeMessageEnvelopeSchema } from '@/lib/e2ee/protocol'; // Schema for query parameters const messagesQuerySchema = z.object({ @@ -23,6 +24,8 @@ const markReadSchema = z.object({ conversationId: z.string().uuid(), }); +type ChatUser = typeof users.$inferSelect; + export async function GET(request: NextRequest) { try { @@ -83,8 +86,8 @@ export async function GET(request: NextRequest) { }); // Fetch users - const usersByDid: Record = {}; - const usersByHandle: Record = {}; + const usersByDid: Record = {}; + const usersByHandle: Record = {}; if (senderDids.size > 0) { const found = await db.query.users.findMany({ @@ -102,7 +105,7 @@ export async function GET(request: NextRequest) { } const messagesMapped = messages.map((msg) => { - const isSentByMe = msg.senderHandle === session.user.handle; + const isSentByMe = msg.senderDid === session.user.did || msg.senderHandle === session.user.handle; // Resolve fresh user data const user = msg.senderDid ? usersByDid[msg.senderDid] : usersByHandle[msg.senderHandle]; @@ -110,13 +113,39 @@ export async function GET(request: NextRequest) { const displayName = user?.displayName || msg.senderDisplayName || msg.senderHandle; const avatarUrl = user?.avatarUrl || msg.senderAvatarUrl; + let encryptedEnvelope = null; + let signedAction = null; + if (msg.protocolVersion === E2EE_PROTOCOL_VERSION && msg.encryptedEnvelope) { + try { + encryptedEnvelope = e2eeMessageEnvelopeSchema.parse(JSON.parse(msg.encryptedEnvelope)); + if (!msg.senderDid || !msg.e2eeSignature || !msg.e2eeActionNonce || !msg.e2eeActionTs) { + throw new Error('Encrypted message signature metadata is incomplete'); + } + signedAction = { + action: E2EE_CHAT_ACTION, + data: encryptedEnvelope, + did: msg.senderDid, + handle: encryptedEnvelope.senderHandle, + ts: msg.e2eeActionTs, + nonce: msg.e2eeActionNonce, + sig: msg.e2eeSignature, + }; + } catch (error) { + console.error(`[E2EE Chat] Invalid stored envelope ${msg.id}:`, error); + } + } + return { id: msg.id, senderHandle: msg.senderHandle, senderDisplayName: displayName, senderAvatarUrl: avatarUrl, senderDid: msg.senderDid, - content: msg.content, + content: msg.protocolVersion === 0 ? msg.content : null, + protocolVersion: msg.protocolVersion, + encryptedEnvelope, + signedAction, + senderPublicKey: user?.publicKey || null, deliveredAt: msg.deliveredAt, readAt: msg.readAt, createdAt: msg.createdAt, @@ -127,7 +156,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ messages: messagesMapped.reverse(), // Oldest first for display nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null, - }); + }, { headers: { 'Cache-Control': 'no-store' } }); } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 }); diff --git a/src/app/api/swarm/gossip/route.ts b/src/app/api/swarm/gossip/route.ts index 78c2322..9519d15 100644 --- a/src/app/api/swarm/gossip/route.ts +++ b/src/app/api/swarm/gossip/route.ts @@ -31,7 +31,7 @@ const nodeInfoSchema = z.object({ userCount: z.number().optional(), postCount: z.number().optional(), isNsfw: z.boolean().optional(), - capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(), + capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(), lastSeenAt: z.string().optional(), }); diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 446451d..3cd325d 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,13 +1,24 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { Fragment, useCallback, useState, useEffect, useRef } from 'react'; import { useAuth } from '@/lib/contexts/AuthContext'; import { signedAPI } from '@/lib/api/signed-fetch'; -import { ArrowLeft, Send, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react'; +import { ArrowLeft, Send, Loader2, LockKeyhole, MessageCircle, Search, Trash2 } from 'lucide-react'; import Link from 'next/link'; import { getProfilePath, useFormattedHandle } from '@/lib/utils/handle'; import { useRouter, useSearchParams } from 'next/navigation'; import { AvatarImage } from '@/components/AvatarImage'; +import { E2EEChatGate } from '@/components/E2EEChatGate'; +import { IdentityUnlockPrompt } from '@/components/IdentityUnlockPrompt'; +import { + decryptStoredChatMessage, + E2EEClientError, + resolveE2EEPublicBundle, + type StoredChatMessage, +} from '@/lib/e2ee/client'; +import { encryptE2EEMessage } from '@/lib/e2ee/client-crypto'; +import type { E2EEKeyBundle, E2EEMessageEnvelope } from '@/lib/e2ee/protocol'; +import { useE2EEIdentity } from '@/lib/e2ee/use-e2ee-identity'; interface Conversation { id: string; @@ -19,38 +30,83 @@ interface Conversation { }; lastMessageAt: string; lastMessagePreview: string; + lastMessage?: StoredChatMessage | null; unreadCount: number; + encryptionMode?: 'legacy' | 'e2ee'; + e2eeActivatedAt?: string | null; } -interface Message { +interface ChatMessagePayload extends StoredChatMessage { id: string; senderHandle: string; senderDisplayName?: string; senderAvatarUrl?: string; senderDid?: string; - content: string; isSentByMe: boolean; deliveredAt?: string; readAt?: string; createdAt: string; } +type Message = Omit & { + content: string; + legacy: boolean; + decryptionError: boolean; +}; + +type ConversationEncryptionState = + | { status: 'idle' } + | { status: 'resolving'; conversationKey: string } + | { + status: 'ready'; + conversationKey: string; + recipientDid: string; + senderBundle: E2EEKeyBundle; + recipientBundle: E2EEKeyBundle; + } + | { status: 'error'; conversationKey: string; code: string; message: string }; + +interface PreparedSend { + accountDid: string; + conversationKey: string; + draft: string; + senderKeyId: string; + recipientKeyId: string; + envelope: E2EEMessageEnvelope; +} + +function encryptionConversationKey(conversation: Conversation): string { + return `${conversation.id}:${conversation.participant2.did || conversation.participant2.handle}`; +} + +function accountConversationKey(accountDid: string | null, conversationKey: string): string { + return JSON.stringify([accountDid, conversationKey]); +} + export default function ChatPage() { - const { user } = useAuth(); + const { user, loading: authLoading, isIdentityUnlocked, isRestoring: isIdentityRestoring } = useAuth(); const router = useRouter(); const searchParams = useSearchParams(); const composeHandle = searchParams.get('compose'); const sharedPostUrl = searchParams.get('share'); + const e2eeIdentity = useE2EEIdentity(user?.did, user?.handle); + const activeE2EEKeyId = e2eeIdentity.state.status === 'ready' + ? e2eeIdentity.state.material.keyId + : null; // Chat Data State const [conversations, setConversations] = useState([]); const [selectedConversation, setSelectedConversation] = useState(null); - const selectedHandle = selectedConversation ? useFormattedHandle(selectedConversation.participant2.handle) : ''; + const selectedHandle = useFormattedHandle(selectedConversation?.participant2.handle || ''); const [messages, setMessages] = useState([]); - const [newMessage, setNewMessage] = useState(''); + const [drafts, setDrafts] = useState>({}); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(null); + const [conversationsError, setConversationsError] = useState(null); + const [messagesError, setMessagesError] = useState(null); + const [conversationEncryption, setConversationEncryption] = useState({ status: 'idle' }); const [searchQuery, setSearchQuery] = useState(''); const [loadingMessages, setLoadingMessages] = useState(false); @@ -58,11 +114,63 @@ export default function ChatPage() { const [showDeleteModal, setShowDeleteModal] = useState(false); const [conversationToDelete, setConversationToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); const appliedSharedPostRef = useRef(null); + const messagesRequestRef = useRef(0); + const conversationsRequestRef = useRef(0); + const peerResolutionRef = useRef(0); + const composeRequestRef = useRef(0); + const sendRequestRef = useRef(0); + const accountDidRef = useRef(null); + const renderedAccountDidRef = useRef(user?.did ?? null); + const selectedConversationRef = useRef(selectedConversation); + const selectedConversationKeyRef = useRef(null); + const preparedSendsRef = useRef(new Map()); + const activeSendKeysRef = useRef(new Set()); + + renderedAccountDidRef.current = user?.did ?? null; + selectedConversationRef.current = selectedConversation; + selectedConversationKeyRef.current = selectedConversation + ? encryptionConversationKey(selectedConversation) + : null; + + const selectedConversationKey = selectedConversationKeyRef.current; + const newMessage = selectedConversationKey ? drafts[selectedConversationKey] || '' : ''; + + const updateSelectedDraft = useCallback((value: string) => { + const key = selectedConversationKeyRef.current; + if (!key) return; + const cacheKey = accountConversationKey(renderedAccountDidRef.current, key); + const prepared = preparedSendsRef.current.get(cacheKey); + if (prepared && prepared.draft !== value) preparedSendsRef.current.delete(cacheKey); + setDrafts((current) => current[key] === value ? current : { ...current, [key]: value }); + setSendError(null); + }, []); + + const selectConversation = useCallback((conversation: Conversation | null) => { + messagesRequestRef.current += 1; + peerResolutionRef.current += 1; + sendRequestRef.current += 1; + selectedConversationRef.current = conversation; + selectedConversationKeyRef.current = conversation + ? encryptionConversationKey(conversation) + : null; + setMessages([]); + setMessagesError(null); + setSendError(null); + setSending(conversation + ? activeSendKeysRef.current.has(accountConversationKey( + renderedAccountDidRef.current, + encryptionConversationKey(conversation), + )) + : false); + setIsAtBottom(true); + setSelectedConversation(conversation); + }, []); // ============================================ // HELPER FUNCTIONS (Defined before useEffects) @@ -81,145 +189,400 @@ export default function ChatPage() { setIsAtBottom(checkIfAtBottom()); }; - // Scroll to bottom manually - const scrollToBottom = () => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }; - - const loadConversations = async (isInitialLoad = true) => { + const loadConversations = useCallback(async (isInitialLoad = true) => { + const requestId = ++conversationsRequestRef.current; + const requestAccountDid = user?.did ?? null; try { - if (isInitialLoad) setLoading(true); + if (isInitialLoad) { + setLoading(true); + setConversationsError(null); + } const res = await fetch('/api/swarm/chat/conversations'); - if (res.ok) { - const data = await res.json(); - setConversations(data.conversations || []); + if (!res.ok) throw new Error('Conversations could not be loaded'); + const data = await res.json(); + let nextConversations = (Array.isArray(data.conversations) ? data.conversations : []) as Conversation[]; + + // Render the server's generic encrypted previews as soon as the list + // arrives; local preview decryption can finish without holding the + // whole screen on a spinner. + if (isInitialLoad + && requestId === conversationsRequestRef.current + && renderedAccountDidRef.current === requestAccountDid) { + setConversations(nextConversations); + setConversationsError(null); + setLoading(false); + } + + if (user?.did && e2eeIdentity.state.status === 'ready') { + const material = e2eeIdentity.state.material; + nextConversations = await Promise.all(nextConversations.map(async (conversation) => { + if (!conversation.lastMessage) return conversation; + try { + const { content } = await decryptStoredChatMessage( + conversation.lastMessage, + user.did!, + material, + ); + const normalized = content.replace(/\s+/g, ' ').trim(); + const characters = Array.from(normalized); + return { + ...conversation, + lastMessagePreview: characters.length > 96 + ? `${characters.slice(0, 96).join('')}…` + : normalized, + }; + } catch { + return { ...conversation, lastMessagePreview: 'Encrypted message' }; + } + })); + } + + if (requestId === conversationsRequestRef.current + && renderedAccountDidRef.current === requestAccountDid) { + setConversations(nextConversations); + setConversationsError(null); } } catch (e) { console.error("Failed to load conversations", e); + if (requestId === conversationsRequestRef.current + && renderedAccountDidRef.current === requestAccountDid) { + setConversationsError(e instanceof Error ? e.message : 'Conversations could not be loaded'); + } } finally { - if (isInitialLoad) setLoading(false); + if (requestId === conversationsRequestRef.current + && renderedAccountDidRef.current === requestAccountDid) { + setLoading(false); + } } - }; + }, [user?.did, e2eeIdentity.state]); - const loadMessages = async (conversationId: string) => { - try { - const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`); - const data = await res.json(); - - const plainMessages = (data.messages || []).map((msg: any) => ({ - ...msg, - content: msg.content || '[Empty Message]' - })); - - // Only update if different - setMessages(prev => { - const prevIds = prev.map(m => m.id).join(','); - const newIds = plainMessages.map((m: any) => m.id).join(','); - if (prevIds === newIds && prev.length === plainMessages.length) return prev; - return plainMessages; - }); - - // Mark as read - markAsRead(conversationId); - } catch (e) { - console.error("Failed to load messages", e); - } - }; - - const markAsRead = async (conversationId: string) => { + const markAsRead = useCallback(async (conversationId: string) => { + const requestAccountDid = renderedAccountDidRef.current; try { const res = await fetch('/api/swarm/chat/messages', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationId }) }); - if (!res.ok) { - return; - } + if (!res.ok || renderedAccountDidRef.current !== requestAccountDid) return; setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, unreadCount: 0 } : c)); window.dispatchEvent(new Event('synapsis:chat-updated')); } catch { } - }; + }, []); + + const loadMessages = useCallback(async (conversationId: string) => { + const requestId = ++messagesRequestRef.current; + const requestAccountDid = user?.did ?? null; + if (!user?.did || e2eeIdentity.state.status !== 'ready') { + if (requestId === messagesRequestRef.current) setLoadingMessages(false); + return; + } + + const material = e2eeIdentity.state.material; + try { + setMessagesError(null); + const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`); + if (!res.ok) throw new Error('Messages could not be loaded'); + const data = await res.json(); + + const storedMessages = (Array.isArray(data.messages) ? data.messages : []) as ChatMessagePayload[]; + const decryptedMessages = await Promise.all(storedMessages.map(async (msg): Promise => { + if (msg.protocolVersion !== 0 && msg.protocolVersion !== 1) { + return { + ...msg, + content: 'Encrypted message unavailable', + legacy: false, + decryptionError: true, + }; + } + + try { + const decrypted = await decryptStoredChatMessage(msg, user.did!, material); + return { + ...msg, + content: decrypted.content, + legacy: decrypted.legacy, + decryptionError: false, + }; + } catch (error) { + console.error(`[E2EE Chat] Message ${msg.id} could not be decrypted:`, error); + return { + ...msg, + content: 'Encrypted message unavailable', + legacy: false, + decryptionError: true, + }; + } + })); + + if (requestId !== messagesRequestRef.current + || renderedAccountDidRef.current !== requestAccountDid + || selectedConversationRef.current?.id !== conversationId) return; + + // Only update if different + setMessages(prev => { + const unchanged = prev.length === decryptedMessages.length + && prev.every((message, index) => { + const next = decryptedMessages[index]; + return message.id === next.id + && message.content === next.content + && message.decryptionError === next.decryptionError + && message.readAt === next.readAt + && message.deliveredAt === next.deliveredAt; + }); + return unchanged ? prev : decryptedMessages; + }); + + // Mark as read + void markAsRead(conversationId); + } catch (e) { + console.error("Failed to load messages", e); + if (requestId === messagesRequestRef.current + && renderedAccountDidRef.current === requestAccountDid + && selectedConversationRef.current?.id === conversationId) { + setMessagesError(e instanceof Error ? e.message : 'Messages could not be loaded'); + } + } finally { + if (requestId === messagesRequestRef.current + && renderedAccountDidRef.current === requestAccountDid + && selectedConversationRef.current?.id === conversationId) { + setLoadingMessages(false); + } + } + }, [user?.did, e2eeIdentity.state, markAsRead]); + + const resolveConversationEncryption = useCallback(async (conversation: Conversation) => { + const requestId = ++peerResolutionRef.current; + const conversationKey = encryptionConversationKey(conversation); + const requestAccountDid = user?.did ?? null; + setConversationEncryption({ status: 'resolving', conversationKey }); + + try { + if (!user?.did || !user.handle || e2eeIdentity.state.status !== 'ready') { + throw new E2EEClientError('Your encrypted message key is not ready', 'E2EE_IDENTITY_NOT_READY'); + } + + let recipientDid = conversation.participant2.did; + if (!recipientDid) { + const response = await fetch(`/api/users/${encodeURIComponent(conversation.participant2.handle)}`, { + cache: 'no-store', + }); + const body = await response.json().catch(() => null); + recipientDid = body?.user?.did; + if (!response.ok || !recipientDid) { + throw new E2EEClientError('Recipient identity could not be loaded', 'E2EE_RECIPIENT_NOT_FOUND'); + } + } + + const [sender, recipient] = await Promise.all([ + resolveE2EEPublicBundle(user.did, user.handle), + resolveE2EEPublicBundle(recipientDid, conversation.participant2.handle), + ]); + + if (sender.bundle.keyId !== e2eeIdentity.state.material.keyId + || sender.bundle.publicKey !== e2eeIdentity.state.material.publicKey) { + throw new E2EEClientError( + 'Your local encryption key does not match your signed public key', + 'E2EE_IDENTITY_KEY_MISMATCH', + ); + } + + if (requestId !== peerResolutionRef.current + || renderedAccountDidRef.current !== requestAccountDid + || selectedConversationKeyRef.current !== conversationKey) return; + setConversationEncryption({ + status: 'ready', + conversationKey, + recipientDid, + senderBundle: sender.bundle, + recipientBundle: recipient.bundle, + }); + } catch (error) { + if (requestId !== peerResolutionRef.current + || renderedAccountDidRef.current !== requestAccountDid + || selectedConversationKeyRef.current !== conversationKey) return; + const clientError = error instanceof E2EEClientError ? error : null; + setConversationEncryption({ + status: 'error', + conversationKey, + code: clientError?.code || 'E2EE_KEY_LOOKUP_FAILED', + message: error instanceof Error ? error.message : 'Encryption keys could not be verified', + }); + } + }, [user?.did, user?.handle, e2eeIdentity.state]); const handleSendMessage = async (e: React.FormEvent) => { e.preventDefault(); - if (!newMessage.trim() || !selectedConversation) return; + const conversation = selectedConversationRef.current; + const conversationKey = selectedConversationKeyRef.current; + const draftToSend = conversationKey ? drafts[conversationKey] || '' : ''; + if (!draftToSend.trim() || !conversation || !conversationKey) return; + const cacheKey = accountConversationKey(renderedAccountDidRef.current, conversationKey); + if (activeSendKeysRef.current.has(cacheKey)) { + setSendError('This encrypted message is still being confirmed.'); + return; + } + if (conversationEncryption.status !== 'ready' + || conversationEncryption.conversationKey !== conversationKey) { + setSendError('Encryption keys are still being verified. Your draft has not been sent.'); + return; + } + if (!user?.did || e2eeIdentity.state.status !== 'ready') { + setSendError('Encrypted messages are locked. Your draft has not been sent.'); + return; + } + + const accountDid = user.did; + const requestId = ++sendRequestRef.current; + if (new TextEncoder().encode(draftToSend).length > 8_000) { + setSendError('Encrypted messages can be up to 8,000 bytes. Your draft has not been sent.'); + return; + } + activeSendKeysRef.current.add(cacheKey); setSending(true); + setSendError(null); try { - // Get recipient DID - let did = selectedConversation.participant2.did; - - if (!did) { - const res = await fetch(`/api/users/${encodeURIComponent(selectedConversation.participant2.handle)}`); - const data = await res.json(); - did = data.user?.did; - if (!did) throw new Error('User not found'); - } - - if (!user || !user.did) throw new Error('User identity not loaded or DID missing'); - - - - // Send using Signed API - await signedAPI.sendChat( - did, - selectedConversation.participant2.handle, - newMessage, - user.did, - user.handle - ); - - setNewMessage(''); - - // If this was a new conversation, we need to refresh the conversation list and select the real one - if (selectedConversation.id === 'new') { - // Refresh conversations to get the new ID - const res = await fetch('/api/swarm/chat/conversations'); - const data = await res.json(); - const updatedConversations = data.conversations || []; - setConversations(updatedConversations); - - // Find the real conversation - const realConv = updatedConversations.find((c: Conversation) => - c.participant2.handle === selectedConversation.participant2.handle - ); - - if (realConv) { - setSelectedConversation(realConv); - loadMessages(realConv.id); - } + const priorPrepared = preparedSendsRef.current.get(cacheKey); + let envelope: E2EEMessageEnvelope; + if (priorPrepared + && priorPrepared.accountDid === accountDid + && priorPrepared.draft === draftToSend + && priorPrepared.senderKeyId === conversationEncryption.senderBundle.keyId + && priorPrepared.recipientKeyId === conversationEncryption.recipientBundle.keyId) { + envelope = priorPrepared.envelope; } else { - await loadMessages(selectedConversation.id); - loadConversations(false); + envelope = await encryptE2EEMessage({ + plaintext: draftToSend, + senderDid: accountDid, + senderHandle: user.handle, + senderBundle: conversationEncryption.senderBundle, + recipientDid: conversationEncryption.recipientDid, + recipientHandle: conversation.participant2.handle, + recipientBundle: conversationEncryption.recipientBundle, + }); + preparedSendsRef.current.set(cacheKey, { + accountDid, + conversationKey, + draft: draftToSend, + senderKeyId: conversationEncryption.senderBundle.keyId, + recipientKeyId: conversationEncryption.recipientBundle.keyId, + envelope, + }); } - } catch (err: any) { + let response: Response; + try { + response = await signedAPI.sendChat( + envelope, + accountDid, + user.handle + ); + } catch (error) { + throw new E2EEClientError( + error instanceof Error ? error.message : 'Delivery could not be confirmed', + 'E2EE_SEND_AMBIGUOUS', + ); + } + if (!response.ok) { + const body = await response.json().catch(() => null); + if (response.status < 500) preparedSendsRef.current.delete(cacheKey); + throw new E2EEClientError( + body?.error || 'Encrypted message could not be sent', + body?.code || (response.status >= 500 ? 'E2EE_SEND_AMBIGUOUS' : 'E2EE_SEND_FAILED'), + body || undefined, + ); + } + + preparedSendsRef.current.delete(cacheKey); + setDrafts((current) => { + if (current[conversationKey] !== draftToSend) return current; + const next = { ...current }; + delete next[conversationKey]; + return next; + }); + + if (requestId !== sendRequestRef.current + || renderedAccountDidRef.current !== accountDid + || selectedConversationKeyRef.current !== conversationKey) { + void loadConversations(false); + return; + } + + // Refresh failures after a successful send must not be reported as send + // failures or restore a draft that the server already accepted. + try { + if (conversation.id === 'new') { + const res = await fetch('/api/swarm/chat/conversations'); + if (!res.ok) throw new Error('Conversation list could not be refreshed'); + const data = await res.json(); + const updatedConversations = (data.conversations || []) as Conversation[]; + if (requestId !== sendRequestRef.current + || renderedAccountDidRef.current !== accountDid + || selectedConversationKeyRef.current !== conversationKey) return; + setConversations(updatedConversations); + + const realConv = updatedConversations.find((c: Conversation) => + c.participant2.handle === conversation.participant2.handle + ); + + if (realConv) selectConversation(realConv); + } else { + await loadMessages(conversation.id); + void loadConversations(false); + } + } catch (refreshError) { + console.error('[Send] Message sent, but Chat could not refresh:', refreshError); + void loadConversations(false); + } + } catch (err) { console.error('[Send] Error:', err); - alert(`Failed: ${err.message}`); + if (requestId !== sendRequestRef.current + || renderedAccountDidRef.current !== accountDid + || selectedConversationKeyRef.current !== conversationKey) return; + if (err instanceof E2EEClientError) { + if (err.code === 'E2EE_SENDER_KEY_STALE') { + preparedSendsRef.current.delete(cacheKey); + void e2eeIdentity.retry(); + } else if (err.code === 'E2EE_RECIPIENT_KEY_STALE' || err.code === 'E2EE_NOT_CONFIGURED') { + preparedSendsRef.current.delete(cacheKey); + void resolveConversationEncryption(conversation); + } + } + setSendError(err instanceof E2EEClientError && err.code === 'E2EE_SEND_AMBIGUOUS' + ? 'Delivery could not be confirmed. Retry will safely reuse the same encrypted message.' + : err instanceof Error + ? `${err.message} Your draft has not been sent.` + : 'Encrypted message could not be sent. Your draft has not been sent.'); } finally { - setSending(false); + activeSendKeysRef.current.delete(cacheKey); + if (renderedAccountDidRef.current === accountDid + && selectedConversationKeyRef.current === conversationKey) { + setSending(activeSendKeysRef.current.has(cacheKey)); + } } }; const handleDeleteConversation = async (deleteFor: 'self' | 'both') => { if (!conversationToDelete) return; setIsDeleting(true); + setDeleteError(null); try { const res = await fetch(`/api/swarm/chat/conversations/${conversationToDelete.id}?deleteFor=${deleteFor}`, { method: 'DELETE', }); - if (res.ok) { - setConversations(prev => prev.filter(c => c.id !== conversationToDelete.id)); - if (selectedConversation?.id === conversationToDelete.id) { - setSelectedConversation(null); - } - setShowDeleteModal(false); - setConversationToDelete(null); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.error || 'Conversation could not be deleted'); } + + setConversations(prev => prev.filter(c => c.id !== conversationToDelete.id)); + if (selectedConversation?.id === conversationToDelete.id) { + selectConversation(null); + } + setShowDeleteModal(false); + setConversationToDelete(null); } catch (err) { - alert('Failed to delete'); + setDeleteError(err instanceof Error ? err.message : 'Conversation could not be deleted'); } finally { setIsDeleting(false); } @@ -230,19 +593,46 @@ export default function ChatPage() { // ============================================ // Load conversations + useEffect(() => { + const did = user?.did ?? null; + if (accountDidRef.current === did) return; + accountDidRef.current = did; + messagesRequestRef.current += 1; + conversationsRequestRef.current += 1; + peerResolutionRef.current += 1; + composeRequestRef.current += 1; + sendRequestRef.current += 1; + preparedSendsRef.current.clear(); + activeSendKeysRef.current.clear(); + selectedConversationRef.current = null; + selectedConversationKeyRef.current = null; + setConversations([]); + setSelectedConversation(null); + setMessages([]); + setDrafts({}); + setSendError(null); + setConversationsError(null); + setMessagesError(null); + setSending(false); + setIsAtBottom(true); + setConversationEncryption({ status: 'idle' }); + setLoading(Boolean(did)); + }, [user?.did]); + // Load conversations useEffect(() => { - if (user) { - loadConversations(true); // Initial load with spinner + if (!user) return; + void loadConversations(true); - // Poll for new conversations every 5 seconds (no spinner) - const pollInterval = setInterval(() => { - loadConversations(false); - }, 5000); + const pollInterval = setInterval(() => { + void loadConversations(false); + }, 5000); - return () => clearInterval(pollInterval); - } - }, [user]); + return () => { + clearInterval(pollInterval); + conversationsRequestRef.current += 1; + }; + }, [user, activeE2EEKeyId, loadConversations]); // Handle Compose Intent useEffect(() => { @@ -253,15 +643,19 @@ export default function ChatPage() { ); if (existing) { - setSelectedConversation(existing); + selectConversation(existing); // Clear the query param so refresh doesn't keep resetting state router.replace('/chat', { scroll: false }); } else if (!loading) { // Fetch user details to create a draft conversation const fetchUserAndInitDraft = async () => { + const requestId = ++composeRequestRef.current; + const requestAccountDid = renderedAccountDidRef.current; try { const res = await fetch(`/api/users/${encodeURIComponent(composeHandle)}`); const data = await res.json(); + if (requestId !== composeRequestRef.current + || renderedAccountDidRef.current !== requestAccountDid) return; if (data.user) { if (data.user.isBot || data.user.canReceiveDms === false) { console.error('Cannot DM this account due to privacy settings'); @@ -280,7 +674,7 @@ export default function ChatPage() { lastMessagePreview: 'New Conversation', unreadCount: 0 }; - setSelectedConversation(draftConv); + selectConversation(draftConv); router.replace('/chat', { scroll: false }); } else { // User not found, clear compose param to show list @@ -288,6 +682,8 @@ export default function ChatPage() { router.replace('/chat'); } } catch (e) { + if (requestId !== composeRequestRef.current + || renderedAccountDidRef.current !== requestAccountDid) return; console.error("Failed to load user for compose", e); router.replace('/chat'); } @@ -295,59 +691,72 @@ export default function ChatPage() { fetchUserAndInitDraft(); } } - }, [composeHandle, selectedConversation, conversations, loading, router]); + return () => { + composeRequestRef.current += 1; + }; + }, [composeHandle, selectedConversation, conversations, loading, router, selectConversation]); // Redirect if not logged in useEffect(() => { - if (user === null) { + if (!authLoading && user === null) { router.push('/login'); } - }, [user, router]); + }, [authLoading, user, router]); + + // A thread is sendable only after both participants' public bundles have + // been resolved and verified. A failed lookup never falls back to plaintext. + useEffect(() => { + setSendError(null); + if (!selectedConversation || !activeE2EEKeyId) { + peerResolutionRef.current += 1; + setConversationEncryption({ status: 'idle' }); + return; + } + + void resolveConversationEncryption(selectedConversation); + return () => { + peerResolutionRef.current += 1; + }; + }, [selectedConversation, activeE2EEKeyId, resolveConversationEncryption]); // Load messages when conversation is selected useEffect(() => { - if (selectedConversation) { + messagesRequestRef.current += 1; + setMessages([]); - - // Clear messages immediately to prevent flash - setMessages([]); - - if (selectedConversation.id === 'new') { - setLoadingMessages(false); - return; // Don't load messages for new/draft conversation - } - - setLoadingMessages(true); - - loadMessages(selectedConversation.id); - markAsRead(selectedConversation.id); - - // Poll for new messages every 3 seconds - const pollInterval = setInterval(() => { - // Only load if still the same conversation - if (selectedConversation.id !== 'new') { - loadMessages(selectedConversation.id); - } - }, 3000); - - return () => clearInterval(pollInterval); - } else if (!selectedConversation) { - // Clear messages when no conversation selected - - setMessages([]); + if (!selectedConversation || !activeE2EEKeyId) { setLoadingMessages(false); + return; } - }, [selectedConversation]); + + if (selectedConversation.id === 'new') { + setLoadingMessages(false); + return; + } + + setLoadingMessages(true); + void loadMessages(selectedConversation.id); + + // Polling only retrieves ciphertext; decryption remains local. + const pollInterval = setInterval(() => { + void loadMessages(selectedConversation.id); + }, 3000); + + return () => { + clearInterval(pollInterval); + messagesRequestRef.current += 1; + }; + }, [selectedConversation, activeE2EEKeyId, loadMessages]); // A post shared from the timeline waits for the user to choose a conversation, // then appears in the composer so they remain in control of sending it. useEffect(() => { if (!selectedConversation || !sharedPostUrl || appliedSharedPostRef.current === sharedPostUrl) return; - setNewMessage(sharedPostUrl); + updateSelectedDraft(sharedPostUrl); appliedSharedPostRef.current = sharedPostUrl; router.replace('/chat', { scroll: false }); - }, [selectedConversation, sharedPostUrl, router]); + }, [selectedConversation, sharedPostUrl, router, updateSelectedDraft]); // Auto-scroll to bottom of messages only if user was already at bottom useEffect(() => { @@ -356,6 +765,26 @@ export default function ChatPage() { } }, [messages, isAtBottom]); + useEffect(() => { + setIsAtBottom(true); + const frame = window.requestAnimationFrame(() => { + const container = messagesContainerRef.current; + if (container) container.scrollTop = container.scrollHeight; + }); + return () => window.cancelAnimationFrame(frame); + }, [selectedConversationKey]); + + useEffect(() => { + if (!showDeleteModal) return; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || isDeleting) return; + setDeleteError(null); + setShowDeleteModal(false); + }; + window.addEventListener('keydown', closeOnEscape); + return () => window.removeEventListener('keydown', closeOnEscape); + }, [showDeleteModal, isDeleting]); + // ============================================ // RENDER LOGIC // ============================================ @@ -364,14 +793,48 @@ export default function ChatPage() { conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) || conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase()) ); + const selectedEncryptionReady = conversationEncryption.status === 'ready' + && conversationEncryption.conversationKey === selectedConversationKey; + const selectedEncryptionError = conversationEncryption.status === 'error' + && conversationEncryption.conversationKey === selectedConversationKey + ? conversationEncryption + : null; + + if (authLoading || isIdentityRestoring) { + return ( +
+ +
+ ); + } if (user === null) return null; + if (!isIdentityUnlocked) { + return router.push('/')} />; + } + + if (e2eeIdentity.state.status !== 'ready') { + return ( + router.push('/')} + /> + ); + } + // Prevent flash of list view while processing compose intent if (composeHandle && !selectedConversation) { return ( -
- +
+
); } @@ -393,7 +856,8 @@ export default function ChatPage() { }}>
+
+ ) : messages.length === 0 ? ( +
+ {selectedEncryptionReady + ? 'New messages in this conversation will be end-to-end encrypted.' + : 'No messages yet.'} +
+ ) : messages.map((msg, i) => { + const startsEncryptedSection = msg.protocolVersion === 1 + && i > 0 + && messages[i - 1].legacy; + + return ( + + {startsEncryptedSection && ( +
+ + + + +
+ )} +
+
+ +
+ +
+
+ {msg.decryptionError ? ( +
+
+
+
+ This message could not be decrypted on this device. +
+
+ ) : msg.content} +
+ {msg.legacy && ( +
+ Sent before end-to-end encryption +
+ )} +
+ {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
+
+
+
+ ); + })}
{/* Input */}
-
- setNewMessage(e.target.value)} - /> - -
+ {selectedEncryptionReady ? ( + <> +
+ updateSelectedDraft(e.target.value)} + aria-label={`Encrypted message to ${selectedHandle}`} + aria-describedby={sendError ? 'encrypted-send-error' : undefined} + /> + +
+ {sendError && ( + + )} + + ) : selectedEncryptionError ? ( +
+
+
+

+ {selectedEncryptionError.code === 'E2EE_NOT_CONFIGURED' + ? 'They need to set up encrypted messages before you can send them a DM. ' + : `${selectedEncryptionError.message}. `} + Synapsis will not send this conversation unencrypted. +

+ +
+ ) : ( +
+
+ )}
{/* Delete Modal */} {showDeleteModal && ( -
-

Delete Conversation

+

Delete Conversation

This action cannot be undone.

+ {deleteError && ( +

+ {deleteError} +

+ )}
+ {!selectedConversation.participant2.handle.includes('@') && ( + + )} + {selectedConversation.participant2.handle.includes('@') && ( +

+ Across nodes, deleting removes only your copy. +

+ )} -
) : filteredConversations.length === 0 ? (
@@ -604,14 +1209,12 @@ export default function ChatPage() {
) : ( filteredConversations.map(conv => ( -
{ - setMessages([]); - setSelectedConversation(conv); - }} - style={{ cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: '12px' }} + onClick={() => selectConversation(conv)} + style={{ cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: '12px', width: '100%', textAlign: 'left', color: 'inherit', border: 0 }} >
-
+ )) )}
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 3864ad4..1355529 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useRef } from 'react'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; import Image from 'next/image'; import { TriangleAlert, X } from 'lucide-react'; import { decryptPrivateKey } from '@/lib/crypto/private-key-client'; @@ -31,7 +30,6 @@ interface AuthScreenProps { } export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProps) { - const router = useRouter(); const [mode, setMode] = useState<'login' | 'register' | 'import'>('login'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -53,10 +51,12 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp const [importFile, setImportFile] = useState(null); const [importPassword, setImportPassword] = useState(''); + const [importEmail, setImportEmail] = useState(''); const [importHandle, setImportHandle] = useState(''); const [acceptedCompliance, setAcceptedCompliance] = useState(false); const [importAgeVerified, setImportAgeVerified] = useState(false); const [importSuccess, setImportSuccess] = useState(null); + const [importWarnings, setImportWarnings] = useState([]); // Fetch node info useEffect(() => { @@ -105,7 +105,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp if (turnstileWidgetId.current && window.turnstile) { try { window.turnstile.remove(turnstileWidgetId.current); - } catch (e) { + } catch { // Ignore errors } } @@ -130,7 +130,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp if (turnstileWidgetId.current && window.turnstile) { try { window.turnstile.remove(turnstileWidgetId.current); - } catch (e) { + } catch { // Ignore errors } } @@ -165,7 +165,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp const handleImport = async (e: React.FormEvent) => { e.preventDefault(); - if (!importFile || !importPassword || !importHandle || !acceptedCompliance) { + if (!importFile || !importPassword || !importEmail || !importHandle || !acceptedCompliance) { setError('Please fill in all fields and accept the compliance agreement'); return; } @@ -178,6 +178,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp setLoading(true); setError(''); setImportSuccess(null); + setImportWarnings([]); try { const fileContent = await importFile.text(); @@ -189,6 +190,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp body: JSON.stringify({ exportData, password: importPassword, + destinationEmail: importEmail, newHandle: importHandle, acceptedCompliance, }), @@ -200,16 +202,21 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp throw new Error(data.error || 'Import failed'); } - setImportSuccess(data.message); - // Soft navigation to preserve AuthContext/KeyStore state - setTimeout(() => { - router.refresh(); - if (onSuccess) { - onSuccess(); - } else { - router.push('/'); - } - }, 2000); + const warnings = Array.isArray(data.warnings) + ? data.warnings.filter((warning: unknown): warning is string => typeof warning === 'string') + : []; + setImportSuccess(data.message || 'Account imported successfully.'); + setImportWarnings(warnings); + if (warnings.length === 0) { + setTimeout(() => { + if (onSuccess) { + onSuccess(); + window.location.reload(); + } else { + window.location.assign('/'); + } + }, 2000); + } } catch (err) { setError(err instanceof Error ? err.message : 'Import failed'); @@ -280,7 +287,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp // Import and set in memory store // Remove PEM headers if present and clean whitespace - let cleanKey = privateKeyDecrypted + const cleanKey = privateKeyDecrypted .replace(/-----BEGIN [A-Z ]+-----/, '') .replace(/-----END [A-Z ]+-----/, '') .replace(/\s/g, ''); @@ -306,7 +313,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp if (data.user?.privateKeyEncrypted) { try { // Update AuthContext first so it has the user and key - login(data.user); + await login(data.user); // Now unlock (passing user explicitly to avoid async state delay) await unlockIdentity(password, data.user); @@ -315,12 +322,14 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp } } - // Soft navigation to preserve AuthContext/KeyStore state - router.refresh(); + // Start Chat in a fresh JavaScript realm before it creates or loads + // the E2EE account key. Turnstile-enabled credential handling remains + // part of the login trust boundary and is documented accordingly. if (onSuccess) { onSuccess(); + window.location.reload(); } else { - router.push('/'); + window.location.assign('/'); } } catch (err) { setError(err instanceof Error ? err.message : 'An error occurred'); @@ -688,7 +697,29 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp color: '#000', fontSize: '14px', }}> - {importSuccess} Redirecting... +
{importSuccess}
+ {importWarnings.length > 0 ? ( + <> +
    + {importWarnings.map((warning) =>
  • {warning}
  • )} +
+ + + ) : ' Redirecting…'} )} @@ -738,6 +769,25 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp /> +
+ + setImportEmail(e.target.value)} + placeholder="you@example.com" + autoComplete="email" + required + maxLength={320} + /> + + You'll use this email to sign in after the import. + +
+
@@ -834,7 +884,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp type="submit" className="btn btn-primary btn-lg" style={{ width: '100%' }} - disabled={loading || !importFile || !importPassword || !importHandle || !acceptedCompliance || (nodeInfo.isNsfw && !importAgeVerified)} + disabled={loading || !importFile || !importPassword || !importEmail || !importHandle || !acceptedCompliance || (nodeInfo.isNsfw && !importAgeVerified)} > {loading ? 'Importing...' : 'Import Account'} diff --git a/src/app/settings/migration/page.tsx b/src/app/settings/migration/page.tsx index 2b7966c..12af6f9 100644 --- a/src/app/settings/migration/page.tsx +++ b/src/app/settings/migration/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import Link from 'next/link'; import { ArrowLeftIcon } from '@/components/Icons'; import { useAuth } from '@/lib/contexts/AuthContext'; @@ -114,8 +114,8 @@ export default function MigrationPage() {

- Download a complete backup of your account including your identity, posts, and media. - You can use this file to migrate to another Synapsis node by selecting "Import" on the login page of that node. + Download a signed backup of your account identity, posts, and media. + You can use this file to migrate to another Synapsis node by selecting "Import" on the login page of that node.

Your profile information
  • All your posts
  • Your following list
  • -
  • All DMs and conversation history
  • +
  • DM conversation records and encrypted message envelopes
  • Your automated bots and their configuration
  • +
    + +

    + Encrypted DM keys are not portable yet. This export preserves encrypted message records, + but E2EE history will not open after importing it on another node. Keep access to this + browser and node if you need to read that history. +

    +
    +