From 2b9c4dd22aef1e3d41d7571b6093e90f2ce2a843 Mon Sep 17 00:00:00 2001 From: codebestia Date: Fri, 24 Jul 2026 14:26:25 +0100 Subject: [PATCH] fix(major): major error fixes --- .gitignore | 3 +- apps/backend/docs/e2ee-onboarding.md | 99 +- apps/backend/drizzle/0000_init.sql | 19 - apps/backend/drizzle/0000_stale_mandarin.sql | 193 +++ apps/backend/drizzle/0001_messaging.sql | 27 - apps/backend/drizzle/0002_greedy_hellion.sql | 2 - .../0003_messages_content_search_idx.sql | 1 - .../drizzle/0004_premium_mattie_franklin.sql | 15 - .../0005_conversation_member_settings.sql | 2 - .../drizzle/0005_soft_deleted_messages.sql | 1 - .../0006_add_conversation_avatar_url.sql | 1 - apps/backend/drizzle/0007_user_devices.sql | 17 - apps/backend/drizzle/0008_extend_messages.sql | 6 - apps/backend/drizzle/0008_files.sql | 14 - apps/backend/drizzle/0008_files_uploads.sql | 30 - apps/backend/drizzle/0008_keen_sway.sql | 1 - .../0008_migrate_messages_ciphertext.sql | 11 - .../drizzle/0008_treasury_multisig_voting.sql | 32 - apps/backend/drizzle/0009_message_edits.sql | 2 - apps/backend/drizzle/0009_push_enabled.sql | 1 - .../drizzle/0009_push_file_hygiene.sql | 18 - .../drizzle/0009_push_subscriptions.sql | 13 - .../drizzle/0010_device_management.sql | 4 - apps/backend/drizzle/meta/0000_snapshot.json | 1382 ++++++++++++++++- apps/backend/drizzle/meta/0001_snapshot.json | 302 ---- apps/backend/drizzle/meta/0002_snapshot.json | 317 ---- apps/backend/drizzle/meta/0004_snapshot.json | 427 ----- apps/backend/drizzle/meta/0008_snapshot.json | 939 ----------- apps/backend/drizzle/meta/_journal.json | 78 +- apps/backend/package.json | 4 +- .../src/__tests__/auth.integration.test.ts | 6 +- .../src/__tests__/deliveryPipeline.test.ts | 3 +- .../src/__tests__/deliveryReceipts.test.ts | 2 +- .../src/__tests__/deviceDelivery.test.ts | 1 - .../src/__tests__/devices.bundle.test.ts | 210 --- .../src/__tests__/devices.prekeys.test.ts | 21 +- .../src/__tests__/devices.revoke.test.ts | 202 +++ apps/backend/src/__tests__/devices.test.ts | 63 +- .../backend/src/__tests__/fileCleanup.test.ts | 33 +- .../integration/gateway.integration.test.ts | 67 +- .../backend/src/__tests__/messageEdit.test.ts | 17 +- apps/backend/src/__tests__/push.test.ts | 111 ++ .../src/__tests__/resume.socket.test.ts | 4 +- apps/backend/src/__tests__/selfSync.test.ts | 45 +- .../backend/src/__tests__/sync.routes.test.ts | 75 +- .../src/__tests__/users.bundle.test.ts | 188 +++ .../src/__tests__/users.fingerprint.test.ts | 6 +- apps/backend/src/__tests__/users.test.ts | 10 +- apps/backend/src/db/schema.ts | 205 ++- apps/backend/src/index.ts | 59 +- apps/backend/src/lib/messages.ts | 1 - apps/backend/src/lib/objectStore.ts | 34 +- apps/backend/src/lib/storage.ts | 30 +- apps/backend/src/middleware/auth.ts | 2 +- apps/backend/src/middleware/socketAuth.ts | 2 +- apps/backend/src/routes/auth.ts | 44 +- apps/backend/src/routes/conversations.ts | 29 +- apps/backend/src/routes/devices.ts | 315 ++-- apps/backend/src/routes/messages.ts | 31 +- apps/backend/src/routes/sync.ts | 92 +- apps/backend/src/routes/userDevices.ts | 6 +- apps/backend/src/routes/users.ts | 82 +- apps/backend/src/schemas/auth.schemas.ts | 1 - .../src/services/deliveryAggregation.ts | 8 +- apps/backend/src/services/deliveryPipeline.ts | 10 +- apps/backend/src/services/deviceDelivery.ts | 1 - apps/backend/src/services/fanout.ts | 34 +- apps/backend/src/services/fileCleanup.ts | 13 +- apps/backend/src/services/heartbeat.ts | 26 +- apps/backend/src/services/presence.ts | 18 +- apps/backend/src/services/push.ts | 66 +- apps/backend/src/services/pushNotification.ts | 11 +- apps/backend/src/socket/messaging.ts | 112 +- pnpm-lock.yaml | 72 + 74 files changed, 3010 insertions(+), 3319 deletions(-) delete mode 100644 apps/backend/drizzle/0000_init.sql create mode 100644 apps/backend/drizzle/0000_stale_mandarin.sql delete mode 100644 apps/backend/drizzle/0001_messaging.sql delete mode 100644 apps/backend/drizzle/0002_greedy_hellion.sql delete mode 100644 apps/backend/drizzle/0003_messages_content_search_idx.sql delete mode 100644 apps/backend/drizzle/0004_premium_mattie_franklin.sql delete mode 100644 apps/backend/drizzle/0005_conversation_member_settings.sql delete mode 100644 apps/backend/drizzle/0005_soft_deleted_messages.sql delete mode 100644 apps/backend/drizzle/0006_add_conversation_avatar_url.sql delete mode 100644 apps/backend/drizzle/0007_user_devices.sql delete mode 100644 apps/backend/drizzle/0008_extend_messages.sql delete mode 100644 apps/backend/drizzle/0008_files.sql delete mode 100644 apps/backend/drizzle/0008_files_uploads.sql delete mode 100644 apps/backend/drizzle/0008_keen_sway.sql delete mode 100644 apps/backend/drizzle/0008_migrate_messages_ciphertext.sql delete mode 100644 apps/backend/drizzle/0008_treasury_multisig_voting.sql delete mode 100644 apps/backend/drizzle/0009_message_edits.sql delete mode 100644 apps/backend/drizzle/0009_push_enabled.sql delete mode 100644 apps/backend/drizzle/0009_push_file_hygiene.sql delete mode 100644 apps/backend/drizzle/0009_push_subscriptions.sql delete mode 100644 apps/backend/drizzle/0010_device_management.sql delete mode 100644 apps/backend/drizzle/meta/0001_snapshot.json delete mode 100644 apps/backend/drizzle/meta/0002_snapshot.json delete mode 100644 apps/backend/drizzle/meta/0004_snapshot.json delete mode 100644 apps/backend/drizzle/meta/0008_snapshot.json delete mode 100644 apps/backend/src/__tests__/devices.bundle.test.ts create mode 100644 apps/backend/src/__tests__/devices.revoke.test.ts create mode 100644 apps/backend/src/__tests__/push.test.ts create mode 100644 apps/backend/src/__tests__/users.bundle.test.ts diff --git a/.gitignore b/.gitignore index 8f78b68..b8d468c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ apps/backend/src/**/*.d.ts.map ISSUES.md -IMPLEMENTATION_DOCS.md \ No newline at end of file +IMPLEMENTATION_DOCS.md +REVIEW.md \ No newline at end of file diff --git a/apps/backend/docs/e2ee-onboarding.md b/apps/backend/docs/e2ee-onboarding.md index 8aab8d0..c27be68 100644 --- a/apps/backend/docs/e2ee-onboarding.md +++ b/apps/backend/docs/e2ee-onboarding.md @@ -18,11 +18,11 @@ The currently implemented backend endpoints involved in onboarding are: - `POST /auth/verify` - `GET /devices` - `POST /devices/:id/prekeys` +- `GET /users/:userId/devices/:deviceId/key-bundle` — fetches another user's device + bundle (identity key + signed prekey + one atomically-consumed one-time prekey) There is **currently no implemented backend endpoint in this repo** for: -- fetching another user's E2EE device bundle -- atomically consuming a recipient one-time prekey - server-side session creation - sending encrypted DM envelopes @@ -363,7 +363,8 @@ Client Backend Recipient stat | uploadedOneTimePreKeys, | | | capped } -------------------| | | | | - |-- fetch recipient bundle ------>| not implemented in repo | + |-- GET /users/:uid/devices/ ---->| resolves via #133 fanout | + | :did/key-bundle | for the full device set | |<- recipient bundle -------------| | | | | |-- establish session locally ----| | @@ -374,32 +375,34 @@ Client Backend Recipient stat ### Recipient bundle shape required for first DM -This bundle-fetch endpoint is **not implemented yet in this repo**, but the sender's first-DM flow requires at minimum the following data, because it is what the implemented schema stores today: +`GET /users/:userId/devices/:deviceId/key-bundle` returns one device's bundle per +call (callers loop over the recipient's active device list — see +`GET /conversations/:id/devices` — to build an envelope per device): ```json { - "userId": "uuid", - "devices": [ - { - "deviceId": "uuid", - "identityPublicKey": "base64-ed25519-spki-der", - "signedPreKey": { - "keyId": 1, - "publicKey": "base64", - "signature": "base64" - }, - "oneTimePreKey": { - "keyId": 10, - "publicKey": "base64" - } - } - ] + "deviceId": "uuid", + "identityPublicKey": "base64-ed25519-spki-der", + "registrationId": 1234, + "signedPreKey": { + "keyId": 1, + "publicKey": "base64", + "signature": "base64" + }, + "oneTimePreKey": { + "keyId": 10, + "publicKey": "base64" + } } ``` +`:userId` must match the target device's actual owner or the endpoint returns +`404` — this route intentionally cannot be used to enumerate another user's +devices without already knowing both ids. + Client expectations for the happy path: -1. fetch recipient bundle after sender has uploaded its own prekeys +1. fetch each recipient device's bundle after sender has uploaded its own prekeys 2. use recipient `identityPublicKey` 3. verify recipient `signedPreKey.signature` against recipient `identityPublicKey` 4. use a consumed recipient `oneTimePreKey` if present @@ -434,7 +437,7 @@ Required guarantees for this path: How this maps to the implemented code today: - the preconditions for offline delivery already exist: device identity keys, one signed prekey per device, and a stock of one-time prekeys per device -- the actual bundle-fetch and envelope-storage routes are not yet implemented in this repo +- the bundle-fetch route (`GET /users/:userId/devices/:deviceId/key-bundle`) is implemented; envelope storage/queueing for offline recipients is handled by the message-send + sync path (`GET /sync`), documented separately ## Sequence: prekey-exhausted path @@ -462,15 +465,17 @@ Client behavior: ### B) Recipient one-time prekeys exhausted -This recipient-side fetch path is **not implemented yet in this repo**, but the expected behavior for first DM should be: +This recipient-side fetch path is implemented — `GET /users/:userId/devices/:deviceId/key-bundle` +returns `oneTimePreKey: null` (rather than erroring) once a device's one-time +prekeys are exhausted, so the sender falls back to a 3-DH session: ```text Sender client Backend | | - |-- fetch recipient bundle ------>| + |-- GET .../key-bundle ---------->| |<- bundle with identity key -----| | + signed prekey only | - | + no oneTimePreKey | + | + oneTimePreKey: null | | | |-- establish fallback session ---| | using signed prekey only | @@ -478,23 +483,19 @@ Sender client Backend |-- send prekey envelope -------->| ``` -Required JSON shape for prekey-exhausted bundle response: +Actual JSON shape for the prekey-exhausted bundle response: ```json { - "userId": "uuid", - "devices": [ - { - "deviceId": "uuid", - "identityPublicKey": "base64-ed25519-spki-der", - "signedPreKey": { - "keyId": 1, - "publicKey": "base64", - "signature": "base64" - }, - "oneTimePreKey": null - } - ] + "deviceId": "uuid", + "identityPublicKey": "base64-ed25519-spki-der", + "registrationId": 1234, + "signedPreKey": { + "keyId": 1, + "publicKey": "base64", + "signature": "base64" + }, + "oneTimePreKey": null } ``` @@ -514,7 +515,7 @@ For compatibility with the current implementation, clients should rely on this o 4. call `POST /auth/verify` with `identityPublicKey` 5. receive JWT containing backend `deviceId` 6. call `POST /devices/:deviceId/prekeys` -7. only after successful prekey upload, attempt first-DM recipient bundle fetch +7. only after successful prekey upload, attempt first-DM recipient bundle fetch via `GET /users/:userId/devices/:deviceId/key-bundle` 8. establish session locally from recipient bundle 9. send encrypted envelope(s) @@ -530,17 +531,21 @@ For compatibility with the current implementation, clients should rely on this o - auth challenge/verify: `apps/backend/src/routes/auth.ts` - auth request schema: `apps/backend/src/schemas/auth.schemas.ts` -- device/prekey upload: `apps/backend/src/routes/devices.ts` -- E2EE-related schema: `apps/backend/src/db/schema.ts` +- device registration/listing/revocation/prekey upload: `apps/backend/src/routes/devices.ts` +- recipient key-bundle fetch: `apps/backend/src/routes/users.ts` (`GET /users/:userId/devices/:deviceId/key-bundle`) +- E2EE-related schema: `apps/backend/src/db/schema.ts` (`devices`, `devicePrekeys`) - prekey route tests: `apps/backend/src/__tests__/devices.prekeys.test.ts` +- key-bundle route tests: `apps/backend/src/__tests__/users.bundle.test.ts` ## Gaps to close for full first-DM support -To fully implement the flow described in the issue, backend work still needs routes for: +Recipient bundle fetch and atomic one-time prekey consumption are implemented +(see above). Backend work still needed for full first-DM support: -- recipient bundle fetch -- atomic one-time prekey reservation/consumption -- encrypted envelope submit/store/deliver +- encrypted envelope submit/store/deliver for the *first* contact between two + users specifically (the general send path exists — see `docs/` for the + message/envelope model — but hasn't been audited end-to-end against this + onboarding sequence) - explicit multi-device fanout semantics for first-contact DM -This document is written so those routes can be added without changing the already implemented onboarding JSON and ordering contract. +This document is written so that work can build on the already-implemented onboarding JSON and ordering contract without changing it. diff --git a/apps/backend/drizzle/0000_init.sql b/apps/backend/drizzle/0000_init.sql deleted file mode 100644 index 268ee09..0000000 --- a/apps/backend/drizzle/0000_init.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE "users" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "username" text, - "avatar_url" text, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "users_username_unique" UNIQUE("username") -); ---> statement-breakpoint -CREATE TABLE "wallets" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" uuid NOT NULL, - "address" text NOT NULL, - "is_primary" boolean DEFAULT false NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "wallets_address_unique" UNIQUE("address") -); ---> statement-breakpoint -ALTER TABLE "wallets" ADD CONSTRAINT "wallets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/backend/drizzle/0000_stale_mandarin.sql b/apps/backend/drizzle/0000_stale_mandarin.sql new file mode 100644 index 0000000..599c972 --- /dev/null +++ b/apps/backend/drizzle/0000_stale_mandarin.sql @@ -0,0 +1,193 @@ +CREATE TYPE "public"."content_type" AS ENUM('text', 'file', 'image', 'video', 'audio', 'system');--> statement-breakpoint +CREATE TYPE "public"."conversation_type" AS ENUM('dm', 'group');--> statement-breakpoint +CREATE TYPE "public"."device_platform" AS ENUM('web', 'ios', 'android');--> statement-breakpoint +CREATE TYPE "public"."file_status" AS ENUM('pending', 'ready', 'deleted');--> statement-breakpoint +CREATE TYPE "public"."prekey_type" AS ENUM('signed', 'one_time');--> statement-breakpoint +CREATE TYPE "public"."proposal_vote_type" AS ENUM('approve', 'reject');--> statement-breakpoint +CREATE TYPE "public"."treasury_proposal_status" AS ENUM('active', 'approved', 'rejected', 'executed', 'expired');--> statement-breakpoint +CREATE TABLE "conversation_members" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "conversation_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "last_read_message_id" uuid, + "is_muted" boolean DEFAULT false NOT NULL, + "is_archived" boolean DEFAULT false NOT NULL, + "joined_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "conversations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "type" "conversation_type" DEFAULT 'dm' NOT NULL, + "name" text, + "avatar_url" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "device_prekeys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "device_id" uuid NOT NULL, + "key_type" "prekey_type" NOT NULL, + "key_id" integer NOT NULL, + "public_key" text NOT NULL, + "signature" text, + "consumed" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "device_prekeys_signed_requires_signature" CHECK ("device_prekeys"."key_type" <> 'signed' OR "device_prekeys"."signature" IS NOT NULL) +); +--> statement-breakpoint +CREATE TABLE "devices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "identity_public_key" text NOT NULL, + "registration_id" integer, + "device_name" text, + "platform" "device_platform", + "last_seen_at" timestamp, + "push_enabled" boolean DEFAULT true NOT NULL, + "revoked_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "files" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "uploader_id" uuid NOT NULL, + "conversation_id" uuid NOT NULL, + "status" "file_status" DEFAULT 'pending' NOT NULL, + "size" integer NOT NULL, + "mime_type" text NOT NULL, + "sha256" text NOT NULL, + "storage_key" text NOT NULL, + "is_thumbnail" boolean DEFAULT false NOT NULL, + "deleted_at" timestamp, + "hard_deleted_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "files_storage_key_unique" UNIQUE("storage_key") +); +--> statement-breakpoint +CREATE TABLE "message_envelopes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "message_id" uuid NOT NULL, + "recipient_device_id" uuid NOT NULL, + "recipient_user_id" uuid NOT NULL, + "ciphertext" text NOT NULL, + "delivered_at" timestamp, + "read_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "conversation_id" uuid NOT NULL, + "sender_id" uuid NOT NULL, + "sender_device_id" uuid, + "content_type" text DEFAULT 'text' NOT NULL, + "ciphertext" text, + "file_id" uuid, + "edits_message_id" uuid, + "created_at" timestamp DEFAULT now() NOT NULL, + "deleted_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "proposal_votes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "treasury_proposal_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "vote" "proposal_vote_type" NOT NULL, + "signature" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "push_subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "device_id" uuid NOT NULL, + "endpoint" text NOT NULL, + "p256dh" text NOT NULL, + "auth" text NOT NULL, + "last_used_at" timestamp, + "disabled_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "push_subscriptions_endpoint_unique" UNIQUE("endpoint") +); +--> statement-breakpoint +CREATE TABLE "token_transfers" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "conversation_id" uuid NOT NULL, + "sender_id" uuid NOT NULL, + "recipient_address" text NOT NULL, + "amount" text NOT NULL, + "token_contract_id" text NOT NULL, + "tx_hash" text NOT NULL, + "memo" text, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "token_transfers_tx_hash_unique" UNIQUE("tx_hash") +); +--> statement-breakpoint +CREATE TABLE "treasury_proposals" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "contract_id" text NOT NULL, + "proposal_id" text NOT NULL, + "conversation_id" uuid, + "status" "treasury_proposal_status" DEFAULT 'active' NOT NULL, + "approvals_count" integer DEFAULT 0 NOT NULL, + "rejections_count" integer DEFAULT 0 NOT NULL, + "recipient" text, + "amount" text, + "token" text, + "threshold" integer DEFAULT 3 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "username" text, + "avatar_url" text, + "presence_visible" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "send_read_receipts" boolean DEFAULT true NOT NULL, + CONSTRAINT "users_username_unique" UNIQUE("username") +); +--> statement-breakpoint +CREATE TABLE "wallets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "address" text NOT NULL, + "is_primary" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "wallets_address_unique" UNIQUE("address") +); +--> statement-breakpoint +ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_last_read_message_id_messages_id_fk" FOREIGN KEY ("last_read_message_id") REFERENCES "public"."messages"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "device_prekeys" ADD CONSTRAINT "device_prekeys_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "devices" ADD CONSTRAINT "devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "files" ADD CONSTRAINT "files_uploader_id_users_id_fk" FOREIGN KEY ("uploader_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "files" ADD CONSTRAINT "files_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "message_envelopes" ADD CONSTRAINT "message_envelopes_message_id_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "message_envelopes" ADD CONSTRAINT "message_envelopes_recipient_device_id_devices_id_fk" FOREIGN KEY ("recipient_device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "message_envelopes" ADD CONSTRAINT "message_envelopes_recipient_user_id_users_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_device_id_devices_id_fk" FOREIGN KEY ("sender_device_id") REFERENCES "public"."devices"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "messages" ADD CONSTRAINT "messages_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "messages" ADD CONSTRAINT "messages_edits_message_id_messages_id_fk" FOREIGN KEY ("edits_message_id") REFERENCES "public"."messages"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "proposal_votes" ADD CONSTRAINT "proposal_votes_treasury_proposal_id_treasury_proposals_id_fk" FOREIGN KEY ("treasury_proposal_id") REFERENCES "public"."treasury_proposals"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "proposal_votes" ADD CONSTRAINT "proposal_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "token_transfers" ADD CONSTRAINT "token_transfers_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "token_transfers" ADD CONSTRAINT "token_transfers_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "treasury_proposals" ADD CONSTRAINT "treasury_proposals_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wallets" ADD CONSTRAINT "wallets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "device_prekeys_device_type_keyid_idx" ON "device_prekeys" USING btree ("device_id","key_type","key_id");--> statement-breakpoint +CREATE UNIQUE INDEX "device_prekeys_signed_device_idx" ON "device_prekeys" USING btree ("device_id") WHERE "device_prekeys"."key_type" = 'signed';--> statement-breakpoint +CREATE INDEX "device_prekeys_one_time_available_idx" ON "device_prekeys" USING btree ("device_id") WHERE "device_prekeys"."key_type" = 'one_time' AND "device_prekeys"."consumed" = false;--> statement-breakpoint +CREATE UNIQUE INDEX "devices_user_identity_idx" ON "devices" USING btree ("user_id","identity_public_key");--> statement-breakpoint +CREATE INDEX "devices_user_id_active_idx" ON "devices" USING btree ("user_id") WHERE "devices"."revoked_at" IS NULL;--> statement-breakpoint +CREATE INDEX "me_recipient_device_created_idx" ON "message_envelopes" USING btree ("recipient_device_id","created_at");--> statement-breakpoint +CREATE INDEX "me_message_idx" ON "message_envelopes" USING btree ("message_id");--> statement-breakpoint +CREATE INDEX "messages_conversation_created_idx" ON "messages" USING btree ("conversation_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "proposal_votes_proposal_user_unique" ON "proposal_votes" USING btree ("treasury_proposal_id","user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "treasury_proposals_contract_proposal_idx" ON "treasury_proposals" USING btree ("contract_id","proposal_id"); \ No newline at end of file diff --git a/apps/backend/drizzle/0001_messaging.sql b/apps/backend/drizzle/0001_messaging.sql deleted file mode 100644 index f5ae586..0000000 --- a/apps/backend/drizzle/0001_messaging.sql +++ /dev/null @@ -1,27 +0,0 @@ -CREATE TYPE "public"."conversation_type" AS ENUM('dm', 'group');--> statement-breakpoint -CREATE TABLE "conversation_members" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "conversation_id" uuid NOT NULL, - "user_id" uuid NOT NULL, - "joined_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "conversations" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "type" "conversation_type" DEFAULT 'dm' NOT NULL, - "name" text, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "messages" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "conversation_id" uuid NOT NULL, - "sender_id" uuid NOT NULL, - "content" text NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/backend/drizzle/0002_greedy_hellion.sql b/apps/backend/drizzle/0002_greedy_hellion.sql deleted file mode 100644 index 1852472..0000000 --- a/apps/backend/drizzle/0002_greedy_hellion.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "conversation_members" ADD COLUMN "last_read_message_id" uuid;--> statement-breakpoint -ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_last_read_message_id_messages_id_fk" FOREIGN KEY ("last_read_message_id") REFERENCES "public"."messages"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/apps/backend/drizzle/0003_messages_content_search_idx.sql b/apps/backend/drizzle/0003_messages_content_search_idx.sql deleted file mode 100644 index 494b906..0000000 --- a/apps/backend/drizzle/0003_messages_content_search_idx.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE INDEX "messages_content_search_idx" ON "messages" USING gin (to_tsvector('english', "content")); diff --git a/apps/backend/drizzle/0004_premium_mattie_franklin.sql b/apps/backend/drizzle/0004_premium_mattie_franklin.sql deleted file mode 100644 index 0f58877..0000000 --- a/apps/backend/drizzle/0004_premium_mattie_franklin.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE "token_transfers" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "conversation_id" uuid NOT NULL, - "sender_id" uuid NOT NULL, - "recipient_address" text NOT NULL, - "amount" text NOT NULL, - "token_contract_id" text NOT NULL, - "tx_hash" text NOT NULL, - "memo" text, - "created_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "token_transfers_tx_hash_unique" UNIQUE("tx_hash") -); ---> statement-breakpoint -ALTER TABLE "token_transfers" ADD CONSTRAINT "token_transfers_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "token_transfers" ADD CONSTRAINT "token_transfers_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/backend/drizzle/0005_conversation_member_settings.sql b/apps/backend/drizzle/0005_conversation_member_settings.sql deleted file mode 100644 index d9141f7..0000000 --- a/apps/backend/drizzle/0005_conversation_member_settings.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "conversation_members" ADD COLUMN "is_muted" boolean DEFAULT false NOT NULL;--> statement-breakpoint -ALTER TABLE "conversation_members" ADD COLUMN "is_archived" boolean DEFAULT false NOT NULL; diff --git a/apps/backend/drizzle/0005_soft_deleted_messages.sql b/apps/backend/drizzle/0005_soft_deleted_messages.sql deleted file mode 100644 index fa01971..0000000 --- a/apps/backend/drizzle/0005_soft_deleted_messages.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "messages" ADD COLUMN "deleted_at" timestamp; \ No newline at end of file diff --git a/apps/backend/drizzle/0006_add_conversation_avatar_url.sql b/apps/backend/drizzle/0006_add_conversation_avatar_url.sql deleted file mode 100644 index e05d59a..0000000 --- a/apps/backend/drizzle/0006_add_conversation_avatar_url.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "conversations" ADD COLUMN "avatar_url" text; diff --git a/apps/backend/drizzle/0007_user_devices.sql b/apps/backend/drizzle/0007_user_devices.sql deleted file mode 100644 index a98bc1a..0000000 --- a/apps/backend/drizzle/0007_user_devices.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE TYPE "public"."device_platform" AS ENUM('web', 'ios', 'android');--> statement-breakpoint -CREATE TABLE "user_devices" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" uuid NOT NULL, - "device_id" text NOT NULL, - "device_name" text NOT NULL, - "platform" "device_platform" NOT NULL, - "identity_public_key" text NOT NULL, - "registration_id" integer, - "last_seen_at" timestamp, - "revoked_at" timestamp, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "user_devices" ADD CONSTRAINT "user_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE UNIQUE INDEX "user_devices_user_id_device_id_unique" ON "user_devices" USING btree ("user_id","device_id");--> statement-breakpoint -CREATE INDEX "user_devices_user_id_active_idx" ON "user_devices" USING btree ("user_id") WHERE "revoked_at" IS NULL; diff --git a/apps/backend/drizzle/0008_extend_messages.sql b/apps/backend/drizzle/0008_extend_messages.sql deleted file mode 100644 index 8156484..0000000 --- a/apps/backend/drizzle/0008_extend_messages.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TYPE "public"."content_type" AS ENUM('text', 'file', 'image', 'video', 'audio', 'system');--> statement-breakpoint -ALTER TABLE "messages" ADD COLUMN "content_type" "content_type" NOT NULL DEFAULT 'text';--> statement-breakpoint -ALTER TABLE "messages" ADD COLUMN "sender_device_id" uuid NOT NULL;--> statement-breakpoint -ALTER TABLE "messages" ADD COLUMN "sequence_number" bigint NOT NULL;--> statement-breakpoint -ALTER TABLE "messages" ADD COLUMN "expires_at" timestamp;--> statement-breakpoint -ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_device_id_user_devices_id_fk" FOREIGN KEY ("sender_device_id") REFERENCES "public"."user_devices"("id") ON DELETE cascade ON UPDATE no action; diff --git a/apps/backend/drizzle/0008_files.sql b/apps/backend/drizzle/0008_files.sql deleted file mode 100644 index a1d8594..0000000 --- a/apps/backend/drizzle/0008_files.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TYPE "public"."file_content_type" AS ENUM('file', 'image', 'video', 'audio');--> statement-breakpoint -CREATE TABLE "files" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "owner_id" uuid NOT NULL, - "storage_path" text NOT NULL, - "size" bigint NOT NULL, - "mime_type" text NOT NULL, - "sha256" text NOT NULL, - "content_type" "file_content_type" DEFAULT 'file' NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "deleted_at" timestamp -); ---> statement-breakpoint -ALTER TABLE "files" ADD CONSTRAINT "files_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; diff --git a/apps/backend/drizzle/0008_files_uploads.sql b/apps/backend/drizzle/0008_files_uploads.sql deleted file mode 100644 index 6e21b08..0000000 --- a/apps/backend/drizzle/0008_files_uploads.sql +++ /dev/null @@ -1,30 +0,0 @@ --- Issue #228: file status enum + files table (base, from PR #256) -DO $$ BEGIN - CREATE TYPE "file_status" AS ENUM('pending', 'ready', 'deleted'); -EXCEPTION WHEN duplicate_object THEN null; -END $$; - --- Issue #228: message content type enum (from PR #256) -DO $$ BEGIN - CREATE TYPE "message_content_type" AS ENUM('text', 'file', 'image', 'video', 'audio'); -EXCEPTION WHEN duplicate_object THEN null; -END $$; - --- Issue #226/#230: create files table with size/mimeType/sha256/storageKey/isThumbnail -CREATE TABLE IF NOT EXISTS "files" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "uploader_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, - "conversation_id" uuid NOT NULL REFERENCES "conversations"("id") ON DELETE CASCADE, - "status" "file_status" NOT NULL DEFAULT 'pending', - "size" integer NOT NULL, - "mime_type" text NOT NULL, - "sha256" text NOT NULL, - "storage_key" text NOT NULL, - "is_thumbnail" boolean NOT NULL DEFAULT false, - "created_at" timestamp NOT NULL DEFAULT now() -); - --- Issue #228: add contentType + fileId columns to messages -ALTER TABLE "messages" - ADD COLUMN IF NOT EXISTS "content_type" "message_content_type" NOT NULL DEFAULT 'text', - ADD COLUMN IF NOT EXISTS "file_id" uuid REFERENCES "files"("id") ON DELETE SET NULL; diff --git a/apps/backend/drizzle/0008_keen_sway.sql b/apps/backend/drizzle/0008_keen_sway.sql deleted file mode 100644 index c60acbf..0000000 --- a/apps/backend/drizzle/0008_keen_sway.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "users" ADD COLUMN "presence_visible" boolean DEFAULT true NOT NULL; \ No newline at end of file diff --git a/apps/backend/drizzle/0008_migrate_messages_ciphertext.sql b/apps/backend/drizzle/0008_migrate_messages_ciphertext.sql deleted file mode 100644 index 6f0034e..0000000 --- a/apps/backend/drizzle/0008_migrate_messages_ciphertext.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Drop the full-text search index that relied on the plaintext content column. -DROP INDEX IF EXISTS "messages_content_search_idx";--> statement-breakpoint - --- Add the new nullable ciphertext column for single-blob E2EE messages. -ALTER TABLE "messages" ADD COLUMN "ciphertext" text;--> statement-breakpoint - --- Migrate existing plaintext content into ciphertext so no data is lost. -UPDATE "messages" SET "ciphertext" = "content" WHERE "deleted_at" IS NULL;--> statement-breakpoint - --- Drop the old plaintext content column. -ALTER TABLE "messages" DROP COLUMN "content"; diff --git a/apps/backend/drizzle/0008_treasury_multisig_voting.sql b/apps/backend/drizzle/0008_treasury_multisig_voting.sql deleted file mode 100644 index 27869a1..0000000 --- a/apps/backend/drizzle/0008_treasury_multisig_voting.sql +++ /dev/null @@ -1,32 +0,0 @@ -CREATE TYPE "public"."treasury_proposal_status" AS ENUM('active', 'approved', 'rejected', 'executed', 'expired');--> statement-breakpoint -CREATE TYPE "public"."proposal_vote_type" AS ENUM('approve', 'reject');--> statement-breakpoint -CREATE TABLE "treasury_proposals" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "contract_id" text NOT NULL, - "proposal_id" text NOT NULL, - "conversation_id" uuid, - "status" "treasury_proposal_status" DEFAULT 'active' NOT NULL, - "approvals_count" integer DEFAULT 0 NOT NULL, - "rejections_count" integer DEFAULT 0 NOT NULL, - "recipient" text, - "amount" text, - "token" text, - "threshold" integer DEFAULT 3 NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "proposal_votes" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "treasury_proposal_id" uuid NOT NULL, - "user_id" uuid NOT NULL, - "vote" "proposal_vote_type" NOT NULL, - "signature" text, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "treasury_proposals" ADD CONSTRAINT "treasury_proposals_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "proposal_votes" ADD CONSTRAINT "proposal_votes_treasury_proposal_id_treasury_proposals_id_fk" FOREIGN KEY ("treasury_proposal_id") REFERENCES "public"."treasury_proposals"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "proposal_votes" ADD CONSTRAINT "proposal_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE UNIQUE INDEX "treasury_proposals_contract_proposal_idx" ON "treasury_proposals" USING btree ("contract_id","proposal_id");--> statement-breakpoint -CREATE UNIQUE INDEX "proposal_votes_proposal_user_unique" ON "proposal_votes" USING btree ("treasury_proposal_id","user_id"); diff --git a/apps/backend/drizzle/0009_message_edits.sql b/apps/backend/drizzle/0009_message_edits.sql deleted file mode 100644 index 3f93b9a..0000000 --- a/apps/backend/drizzle/0009_message_edits.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "messages" ADD COLUMN "edits_message_id" uuid;--> statement-breakpoint -ALTER TABLE "messages" ADD CONSTRAINT "messages_edits_message_id_messages_id_fk" FOREIGN KEY ("edits_message_id") REFERENCES "public"."messages"("id") ON DELETE set null ON UPDATE no action; diff --git a/apps/backend/drizzle/0009_push_enabled.sql b/apps/backend/drizzle/0009_push_enabled.sql deleted file mode 100644 index d0e8b08..0000000 --- a/apps/backend/drizzle/0009_push_enabled.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "user_devices" ADD COLUMN "push_enabled" boolean DEFAULT true NOT NULL; diff --git a/apps/backend/drizzle/0009_push_file_hygiene.sql b/apps/backend/drizzle/0009_push_file_hygiene.sql deleted file mode 100644 index fd46900..0000000 --- a/apps/backend/drizzle/0009_push_file_hygiene.sql +++ /dev/null @@ -1,18 +0,0 @@ --- #231: files table for tracking S3 storage objects with soft/hard delete -CREATE TABLE IF NOT EXISTS "files" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "storage_key" text NOT NULL, - "deleted_at" timestamp, - "hard_deleted_at" timestamp, - "created_at" timestamp NOT NULL DEFAULT now() -);--> statement-breakpoint -ALTER TABLE "files" ADD CONSTRAINT "files_storage_key_unique" UNIQUE("storage_key");--> statement-breakpoint - --- #231: link messages to their S3 file object -ALTER TABLE "messages" ADD COLUMN "file_id" uuid;--> statement-breakpoint -ALTER TABLE "messages" ADD CONSTRAINT "messages_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint - --- #237: push subscription hygiene columns -ALTER TABLE "push_subscriptions" ADD COLUMN "last_used_at" timestamp;--> statement-breakpoint -ALTER TABLE "push_subscriptions" ADD COLUMN "disabled_at" timestamp;--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "push_subscriptions_device_active_idx" ON "push_subscriptions" ("device_id") WHERE "disabled_at" IS NULL; diff --git a/apps/backend/drizzle/0009_push_subscriptions.sql b/apps/backend/drizzle/0009_push_subscriptions.sql deleted file mode 100644 index 0b1f9b7..0000000 --- a/apps/backend/drizzle/0009_push_subscriptions.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE "push_subscriptions" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "device_id" uuid NOT NULL, - "endpoint" text NOT NULL, - "p256dh" text NOT NULL, - "auth" text NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "last_used_at" timestamp, - "disabled_at" timestamp, - CONSTRAINT "push_subscriptions_endpoint_unique" UNIQUE("endpoint") -); ---> statement-breakpoint -ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_device_id_user_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."user_devices"("id") ON DELETE cascade ON UPDATE no action; diff --git a/apps/backend/drizzle/0010_device_management.sql b/apps/backend/drizzle/0010_device_management.sql deleted file mode 100644 index faff7bd..0000000 --- a/apps/backend/drizzle/0010_device_management.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE "devices" ADD COLUMN "registration_id" integer;--> statement-breakpoint -ALTER TABLE "devices" ADD COLUMN "device_name" text;--> statement-breakpoint -ALTER TABLE "devices" ADD COLUMN "platform" "device_platform";--> statement-breakpoint -ALTER TABLE "devices" ADD COLUMN "last_seen_at" timestamp; diff --git a/apps/backend/drizzle/meta/0000_snapshot.json b/apps/backend/drizzle/meta/0000_snapshot.json index de73d2b..898fed6 100644 --- a/apps/backend/drizzle/meta/0000_snapshot.json +++ b/apps/backend/drizzle/meta/0000_snapshot.json @@ -1,9 +1,1289 @@ { - "id": "ccda4e45-b486-4477-892a-b4ff308b3326", + "id": "d5682005-cccf-4e2e-992d-d66a5d6d3f4c", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", "tables": { + "public.conversation_members": { + "name": "conversation_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_read_message_id": { + "name": "last_read_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_muted": { + "name": "is_muted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "conversation_members_conversation_id_conversations_id_fk": { + "name": "conversation_members_conversation_id_conversations_id_fk", + "tableFrom": "conversation_members", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_members_user_id_users_id_fk": { + "name": "conversation_members_user_id_users_id_fk", + "tableFrom": "conversation_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_members_last_read_message_id_messages_id_fk": { + "name": "conversation_members_last_read_message_id_messages_id_fk", + "tableFrom": "conversation_members", + "tableTo": "messages", + "columnsFrom": [ + "last_read_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "conversation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dm'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_prekeys": { + "name": "device_prekeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_type": { + "name": "key_type", + "type": "prekey_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_prekeys_device_type_keyid_idx": { + "name": "device_prekeys_device_type_keyid_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_prekeys_signed_device_idx": { + "name": "device_prekeys_signed_device_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"device_prekeys\".\"key_type\" = 'signed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_prekeys_one_time_available_idx": { + "name": "device_prekeys_one_time_available_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"device_prekeys\".\"key_type\" = 'one_time' AND \"device_prekeys\".\"consumed\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_prekeys_device_id_devices_id_fk": { + "name": "device_prekeys_device_id_devices_id_fk", + "tableFrom": "device_prekeys", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "device_prekeys_signed_requires_signature": { + "name": "device_prekeys_signed_requires_signature", + "value": "\"device_prekeys\".\"key_type\" <> 'signed' OR \"device_prekeys\".\"signature\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "identity_public_key": { + "name": "identity_public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "registration_id": { + "name": "registration_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "device_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_identity_idx": { + "name": "devices_user_identity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity_public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "devices_user_id_active_idx": { + "name": "devices_user_id_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"devices\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "uploader_id": { + "name": "uploader_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "file_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_thumbnail": { + "name": "is_thumbnail", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "hard_deleted_at": { + "name": "hard_deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "files_uploader_id_users_id_fk": { + "name": "files_uploader_id_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "uploader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_conversation_id_conversations_id_fk": { + "name": "files_conversation_id_conversations_id_fk", + "tableFrom": "files", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "files_storage_key_unique": { + "name": "files_storage_key_unique", + "nullsNotDistinct": false, + "columns": [ + "storage_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_envelopes": { + "name": "message_envelopes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_device_id": { + "name": "recipient_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "me_recipient_device_created_idx": { + "name": "me_recipient_device_created_idx", + "columns": [ + { + "expression": "recipient_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "me_message_idx": { + "name": "me_message_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_envelopes_message_id_messages_id_fk": { + "name": "message_envelopes_message_id_messages_id_fk", + "tableFrom": "message_envelopes", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_envelopes_recipient_device_id_devices_id_fk": { + "name": "message_envelopes_recipient_device_id_devices_id_fk", + "tableFrom": "message_envelopes", + "tableTo": "devices", + "columnsFrom": [ + "recipient_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_envelopes_recipient_user_id_users_id_fk": { + "name": "message_envelopes_recipient_user_id_users_id_fk", + "tableFrom": "message_envelopes", + "tableTo": "users", + "columnsFrom": [ + "recipient_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_device_id": { + "name": "sender_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "edits_message_id": { + "name": "edits_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "messages_conversation_created_idx": { + "name": "messages_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_id_users_id_fk": { + "name": "messages_sender_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_device_id_devices_id_fk": { + "name": "messages_sender_device_id_devices_id_fk", + "tableFrom": "messages", + "tableTo": "devices", + "columnsFrom": [ + "sender_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_file_id_files_id_fk": { + "name": "messages_file_id_files_id_fk", + "tableFrom": "messages", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_edits_message_id_messages_id_fk": { + "name": "messages_edits_message_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "edits_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proposal_votes": { + "name": "proposal_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "treasury_proposal_id": { + "name": "treasury_proposal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "proposal_vote_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proposal_votes_proposal_user_unique": { + "name": "proposal_votes_proposal_user_unique", + "columns": [ + { + "expression": "treasury_proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proposal_votes_treasury_proposal_id_treasury_proposals_id_fk": { + "name": "proposal_votes_treasury_proposal_id_treasury_proposals_id_fk", + "tableFrom": "proposal_votes", + "tableTo": "treasury_proposals", + "columnsFrom": [ + "treasury_proposal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposal_votes_user_id_users_id_fk": { + "name": "proposal_votes_user_id_users_id_fk", + "tableFrom": "proposal_votes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_device_id_devices_id_fk": { + "name": "push_subscriptions_device_id_devices_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_transfers": { + "name": "token_transfers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_address": { + "name": "recipient_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_contract_id": { + "name": "token_contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "token_transfers_conversation_id_conversations_id_fk": { + "name": "token_transfers_conversation_id_conversations_id_fk", + "tableFrom": "token_transfers", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "token_transfers_sender_id_users_id_fk": { + "name": "token_transfers_sender_id_users_id_fk", + "tableFrom": "token_transfers", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "token_transfers_tx_hash_unique": { + "name": "token_transfers_tx_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "tx_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.treasury_proposals": { + "name": "treasury_proposals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "treasury_proposal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "approvals_count": { + "name": "approvals_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rejections_count": { + "name": "rejections_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "threshold": { + "name": "threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "treasury_proposals_contract_proposal_idx": { + "name": "treasury_proposals_contract_proposal_idx", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "treasury_proposals_conversation_id_conversations_id_fk": { + "name": "treasury_proposals_conversation_id_conversations_id_fk", + "tableFrom": "treasury_proposals", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.users": { "name": "users", "schema": "", @@ -27,6 +1307,13 @@ "primaryKey": false, "notNull": false }, + "presence_visible": { + "name": "presence_visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -40,6 +1327,13 @@ "primaryKey": false, "notNull": true, "default": "now()" + }, + "send_read_receipts": { + "name": "send_read_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true } }, "indexes": {}, @@ -49,7 +1343,9 @@ "users_username_unique": { "name": "users_username_unique", "nullsNotDistinct": false, - "columns": ["username"] + "columns": [ + "username" + ] } }, "policies": {}, @@ -100,8 +1396,12 @@ "name": "wallets_user_id_users_id_fk", "tableFrom": "wallets", "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -111,7 +1411,9 @@ "wallets_address_unique": { "name": "wallets_address_unique", "nullsNotDistinct": false, - "columns": ["address"] + "columns": [ + "address" + ] } }, "policies": {}, @@ -119,7 +1421,73 @@ "isRLSEnabled": false } }, - "enums": {}, + "enums": { + "public.content_type": { + "name": "content_type", + "schema": "public", + "values": [ + "text", + "file", + "image", + "video", + "audio", + "system" + ] + }, + "public.conversation_type": { + "name": "conversation_type", + "schema": "public", + "values": [ + "dm", + "group" + ] + }, + "public.device_platform": { + "name": "device_platform", + "schema": "public", + "values": [ + "web", + "ios", + "android" + ] + }, + "public.file_status": { + "name": "file_status", + "schema": "public", + "values": [ + "pending", + "ready", + "deleted" + ] + }, + "public.prekey_type": { + "name": "prekey_type", + "schema": "public", + "values": [ + "signed", + "one_time" + ] + }, + "public.proposal_vote_type": { + "name": "proposal_vote_type", + "schema": "public", + "values": [ + "approve", + "reject" + ] + }, + "public.treasury_proposal_status": { + "name": "treasury_proposal_status", + "schema": "public", + "values": [ + "active", + "approved", + "rejected", + "executed", + "expired" + ] + } + }, "schemas": {}, "sequences": {}, "roles": {}, @@ -130,4 +1498,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0001_snapshot.json b/apps/backend/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 9c9a0d9..0000000 --- a/apps/backend/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,302 +0,0 @@ -{ - "id": "f0633220-541a-4c99-9b99-8242c7f3420f", - "prevId": "ccda4e45-b486-4477-892a-b4ff308b3326", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.conversation_members": { - "name": "conversation_members", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "joined_at": { - "name": "joined_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "conversation_members_conversation_id_conversations_id_fk": { - "name": "conversation_members_conversation_id_conversations_id_fk", - "tableFrom": "conversation_members", - "tableTo": "conversations", - "columnsFrom": ["conversation_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "conversation_members_user_id_users_id_fk": { - "name": "conversation_members_user_id_users_id_fk", - "tableFrom": "conversation_members", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.conversations": { - "name": "conversations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "type": { - "name": "type", - "type": "conversation_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'dm'" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.messages": { - "name": "messages", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "sender_id": { - "name": "sender_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "messages_conversation_id_conversations_id_fk": { - "name": "messages_conversation_id_conversations_id_fk", - "tableFrom": "messages", - "tableTo": "conversations", - "columnsFrom": ["conversation_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "messages_sender_id_users_id_fk": { - "name": "messages_sender_id_users_id_fk", - "tableFrom": "messages", - "tableTo": "users", - "columnsFrom": ["sender_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_username_unique": { - "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": ["username"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.wallets": { - "name": "wallets", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_primary": { - "name": "is_primary", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "wallets_user_id_users_id_fk": { - "name": "wallets_user_id_users_id_fk", - "tableFrom": "wallets", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "wallets_address_unique": { - "name": "wallets_address_unique", - "nullsNotDistinct": false, - "columns": ["address"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.conversation_type": { - "name": "conversation_type", - "schema": "public", - "values": ["dm", "group"] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/backend/drizzle/meta/0002_snapshot.json b/apps/backend/drizzle/meta/0002_snapshot.json deleted file mode 100644 index ac83d45..0000000 --- a/apps/backend/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,317 +0,0 @@ -{ - "id": "dce54807-ba05-4ac2-bb4c-c58334c2a707", - "prevId": "f0633220-541a-4c99-9b99-8242c7f3420f", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.conversation_members": { - "name": "conversation_members", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "last_read_message_id": { - "name": "last_read_message_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "joined_at": { - "name": "joined_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "conversation_members_conversation_id_conversations_id_fk": { - "name": "conversation_members_conversation_id_conversations_id_fk", - "tableFrom": "conversation_members", - "tableTo": "conversations", - "columnsFrom": ["conversation_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "conversation_members_user_id_users_id_fk": { - "name": "conversation_members_user_id_users_id_fk", - "tableFrom": "conversation_members", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "conversation_members_last_read_message_id_messages_id_fk": { - "name": "conversation_members_last_read_message_id_messages_id_fk", - "tableFrom": "conversation_members", - "tableTo": "messages", - "columnsFrom": ["last_read_message_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.conversations": { - "name": "conversations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "type": { - "name": "type", - "type": "conversation_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'dm'" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.messages": { - "name": "messages", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "sender_id": { - "name": "sender_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "messages_conversation_id_conversations_id_fk": { - "name": "messages_conversation_id_conversations_id_fk", - "tableFrom": "messages", - "tableTo": "conversations", - "columnsFrom": ["conversation_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "messages_sender_id_users_id_fk": { - "name": "messages_sender_id_users_id_fk", - "tableFrom": "messages", - "tableTo": "users", - "columnsFrom": ["sender_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_username_unique": { - "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": ["username"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.wallets": { - "name": "wallets", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_primary": { - "name": "is_primary", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "wallets_user_id_users_id_fk": { - "name": "wallets_user_id_users_id_fk", - "tableFrom": "wallets", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "wallets_address_unique": { - "name": "wallets_address_unique", - "nullsNotDistinct": false, - "columns": ["address"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.conversation_type": { - "name": "conversation_type", - "schema": "public", - "values": ["dm", "group"] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/backend/drizzle/meta/0004_snapshot.json b/apps/backend/drizzle/meta/0004_snapshot.json deleted file mode 100644 index 47b44dd..0000000 --- a/apps/backend/drizzle/meta/0004_snapshot.json +++ /dev/null @@ -1,427 +0,0 @@ -{ - "id": "dfbe295d-f8b1-462b-9ad9-e23c4e3fefd2", - "prevId": "dce54807-ba05-4ac2-bb4c-c58334c2a707", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.conversation_members": { - "name": "conversation_members", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "last_read_message_id": { - "name": "last_read_message_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "joined_at": { - "name": "joined_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "conversation_members_conversation_id_conversations_id_fk": { - "name": "conversation_members_conversation_id_conversations_id_fk", - "tableFrom": "conversation_members", - "tableTo": "conversations", - "columnsFrom": ["conversation_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "conversation_members_user_id_users_id_fk": { - "name": "conversation_members_user_id_users_id_fk", - "tableFrom": "conversation_members", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "conversation_members_last_read_message_id_messages_id_fk": { - "name": "conversation_members_last_read_message_id_messages_id_fk", - "tableFrom": "conversation_members", - "tableTo": "messages", - "columnsFrom": ["last_read_message_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.conversations": { - "name": "conversations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "type": { - "name": "type", - "type": "conversation_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'dm'" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.messages": { - "name": "messages", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "sender_id": { - "name": "sender_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "messages_content_search_idx": { - "name": "messages_content_search_idx", - "columns": [ - { - "expression": "to_tsvector('english', \"content\")", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "gin", - "with": {} - } - }, - "foreignKeys": { - "messages_conversation_id_conversations_id_fk": { - "name": "messages_conversation_id_conversations_id_fk", - "tableFrom": "messages", - "tableTo": "conversations", - "columnsFrom": ["conversation_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "messages_sender_id_users_id_fk": { - "name": "messages_sender_id_users_id_fk", - "tableFrom": "messages", - "tableTo": "users", - "columnsFrom": ["sender_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.token_transfers": { - "name": "token_transfers", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "sender_id": { - "name": "sender_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "recipient_address": { - "name": "recipient_address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "amount": { - "name": "amount", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_contract_id": { - "name": "token_contract_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "tx_hash": { - "name": "tx_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "memo": { - "name": "memo", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "token_transfers_conversation_id_conversations_id_fk": { - "name": "token_transfers_conversation_id_conversations_id_fk", - "tableFrom": "token_transfers", - "tableTo": "conversations", - "columnsFrom": ["conversation_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "token_transfers_sender_id_users_id_fk": { - "name": "token_transfers_sender_id_users_id_fk", - "tableFrom": "token_transfers", - "tableTo": "users", - "columnsFrom": ["sender_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "token_transfers_tx_hash_unique": { - "name": "token_transfers_tx_hash_unique", - "nullsNotDistinct": false, - "columns": ["tx_hash"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_username_unique": { - "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": ["username"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.wallets": { - "name": "wallets", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_primary": { - "name": "is_primary", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "wallets_user_id_users_id_fk": { - "name": "wallets_user_id_users_id_fk", - "tableFrom": "wallets", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "wallets_address_unique": { - "name": "wallets_address_unique", - "nullsNotDistinct": false, - "columns": ["address"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.conversation_type": { - "name": "conversation_type", - "schema": "public", - "values": ["dm", "group"] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/backend/drizzle/meta/0008_snapshot.json b/apps/backend/drizzle/meta/0008_snapshot.json deleted file mode 100644 index 6ca93fd..0000000 --- a/apps/backend/drizzle/meta/0008_snapshot.json +++ /dev/null @@ -1,939 +0,0 @@ -{ - "id": "e7c3f7fd-d30f-485f-8487-ac940c3f85e8", - "prevId": "dfbe295d-f8b1-462b-9ad9-e23c4e3fefd2", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.conversation_members": { - "name": "conversation_members", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "last_read_message_id": { - "name": "last_read_message_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "is_muted": { - "name": "is_muted", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_archived": { - "name": "is_archived", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "joined_at": { - "name": "joined_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "conversation_members_conversation_id_conversations_id_fk": { - "name": "conversation_members_conversation_id_conversations_id_fk", - "tableFrom": "conversation_members", - "tableTo": "conversations", - "columnsFrom": ["conversation_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "conversation_members_user_id_users_id_fk": { - "name": "conversation_members_user_id_users_id_fk", - "tableFrom": "conversation_members", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "conversation_members_last_read_message_id_messages_id_fk": { - "name": "conversation_members_last_read_message_id_messages_id_fk", - "tableFrom": "conversation_members", - "tableTo": "messages", - "columnsFrom": ["last_read_message_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.conversations": { - "name": "conversations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "type": { - "name": "type", - "type": "conversation_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'dm'" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.devices": { - "name": "devices", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "identity_public_key": { - "name": "identity_public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_revoked": { - "name": "is_revoked", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "devices_user_identity_idx": { - "name": "devices_user_identity_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "identity_public_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "devices_user_id_users_id_fk": { - "name": "devices_user_id_users_id_fk", - "tableFrom": "devices", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.messages": { - "name": "messages", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "sender_id": { - "name": "sender_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "messages_content_search_idx": { - "name": "messages_content_search_idx", - "columns": [ - { - "expression": "to_tsvector('english', \"content\")", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "gin", - "with": {} - } - }, - "foreignKeys": { - "messages_conversation_id_conversations_id_fk": { - "name": "messages_conversation_id_conversations_id_fk", - "tableFrom": "messages", - "tableTo": "conversations", - "columnsFrom": ["conversation_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "messages_sender_id_users_id_fk": { - "name": "messages_sender_id_users_id_fk", - "tableFrom": "messages", - "tableTo": "users", - "columnsFrom": ["sender_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.one_time_pre_keys": { - "name": "one_time_pre_keys", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "device_id": { - "name": "device_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "key_id": { - "name": "key_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "otp_device_keyid_idx": { - "name": "otp_device_keyid_idx", - "columns": [ - { - "expression": "device_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "one_time_pre_keys_device_id_devices_id_fk": { - "name": "one_time_pre_keys_device_id_devices_id_fk", - "tableFrom": "one_time_pre_keys", - "tableTo": "devices", - "columnsFrom": ["device_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.signed_pre_keys": { - "name": "signed_pre_keys", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "device_id": { - "name": "device_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "key_id": { - "name": "key_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "signature": { - "name": "signature", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "spk_device_idx": { - "name": "spk_device_idx", - "columns": [ - { - "expression": "device_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "signed_pre_keys_device_id_devices_id_fk": { - "name": "signed_pre_keys_device_id_devices_id_fk", - "tableFrom": "signed_pre_keys", - "tableTo": "devices", - "columnsFrom": ["device_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.token_transfers": { - "name": "token_transfers", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "sender_id": { - "name": "sender_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "recipient_address": { - "name": "recipient_address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "amount": { - "name": "amount", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_contract_id": { - "name": "token_contract_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "tx_hash": { - "name": "tx_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "memo": { - "name": "memo", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "token_transfers_conversation_id_conversations_id_fk": { - "name": "token_transfers_conversation_id_conversations_id_fk", - "tableFrom": "token_transfers", - "tableTo": "conversations", - "columnsFrom": ["conversation_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "token_transfers_sender_id_users_id_fk": { - "name": "token_transfers_sender_id_users_id_fk", - "tableFrom": "token_transfers", - "tableTo": "users", - "columnsFrom": ["sender_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "token_transfers_tx_hash_unique": { - "name": "token_transfers_tx_hash_unique", - "nullsNotDistinct": false, - "columns": ["tx_hash"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.treasury_proposals": { - "name": "treasury_proposals", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "contract_id": { - "name": "contract_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "proposal_id": { - "name": "proposal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "conversation_id": { - "name": "conversation_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "treasury_proposal_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'active'" - }, - "approvals_count": { - "name": "approvals_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "rejections_count": { - "name": "rejections_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "treasury_proposals_contract_proposal_idx": { - "name": "treasury_proposals_contract_proposal_idx", - "columns": [ - { - "expression": "contract_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "proposal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "treasury_proposals_conversation_id_conversations_id_fk": { - "name": "treasury_proposals_conversation_id_conversations_id_fk", - "tableFrom": "treasury_proposals", - "tableTo": "conversations", - "columnsFrom": ["conversation_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_devices": { - "name": "user_devices", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "device_id": { - "name": "device_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "device_name": { - "name": "device_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "platform": { - "name": "platform", - "type": "device_platform", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "identity_public_key": { - "name": "identity_public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "registration_id": { - "name": "registration_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "last_seen_at": { - "name": "last_seen_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "revoked_at": { - "name": "revoked_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "user_devices_user_id_device_id_unique": { - "name": "user_devices_user_id_device_id_unique", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "device_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "user_devices_user_id_active_idx": { - "name": "user_devices_user_id_active_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"user_devices\".\"revoked_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "user_devices_user_id_users_id_fk": { - "name": "user_devices_user_id_users_id_fk", - "tableFrom": "user_devices", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "presence_visible": { - "name": "presence_visible", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_username_unique": { - "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": ["username"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.wallets": { - "name": "wallets", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_primary": { - "name": "is_primary", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "wallets_user_id_users_id_fk": { - "name": "wallets_user_id_users_id_fk", - "tableFrom": "wallets", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "wallets_address_unique": { - "name": "wallets_address_unique", - "nullsNotDistinct": false, - "columns": ["address"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.conversation_type": { - "name": "conversation_type", - "schema": "public", - "values": ["dm", "group"] - }, - "public.device_platform": { - "name": "device_platform", - "schema": "public", - "values": ["web", "ios", "android"] - }, - "public.treasury_proposal_status": { - "name": "treasury_proposal_status", - "schema": "public", - "values": ["active", "approved", "rejected", "executed", "expired"] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 6e4c211..52ed62d 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -5,81 +5,9 @@ { "idx": 0, "version": "7", - "when": 1778589700461, - "tag": "0000_init", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1778597011664, - "tag": "0001_messaging", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1780063933769, - "tag": "0002_greedy_hellion", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1780129152703, - "tag": "0003_messages_content_search_idx", - "breakpoints": true - }, - { - "idx": 4, - "version": "7", - "when": 1780241403332, - "tag": "0004_premium_mattie_franklin", - "breakpoints": true - }, - { - "idx": 5, - "version": "7", - "when": 1780269667000, - "tag": "0005_soft_deleted_messages", - "breakpoints": true - }, - { - "idx": 6, - "version": "7", - "when": 1780560000000, - "tag": "0006_add_conversation_avatar_url", - "breakpoints": true - }, - { - "idx": 7, - "version": "7", - "when": 1782345600000, - "tag": "0007_user_devices", - "breakpoints": true - }, - { - "idx": 8, - "version": "7", - "when": 1783000000000, - "tag": "0008_extend_messages", - "breakpoints": true - }, - { - "idx": 9, - "version": "7", - "when": 1785000000000, - "tag": "0009_push_enabled", - "when": 1783500000000, - "tag": "0009_message_edits", - "breakpoints": true - }, - { - "idx": 10, - "version": "7", - "when": 1783700000000, - "tag": "0010_device_management", + "when": 1784899426825, + "tag": "0000_stale_mandarin", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index de1640d..9a90937 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -48,6 +48,7 @@ "@eslint/js": "^10.0.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", + "@types/ioredis-mock": "8.2.7", "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.10", "@types/node": "^20.19.37", @@ -56,13 +57,14 @@ "@vitest/coverage-v8": "^4.1.6", "drizzle-kit": "^0.31.10", "eslint": "^9.39.4", + "ioredis-mock": "8.13.1", "prettier": "^3.8.3", + "socket.io-client": "^4.8.3", "supertest": "^7.2.2", "ts-node": "^10.9.2", "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.59.3", - "socket.io-client": "^4.8.3", "vitest": "^4.1.6" } } diff --git a/apps/backend/src/__tests__/auth.integration.test.ts b/apps/backend/src/__tests__/auth.integration.test.ts index a1d263e..a0bf900 100644 --- a/apps/backend/src/__tests__/auth.integration.test.ts +++ b/apps/backend/src/__tests__/auth.integration.test.ts @@ -158,7 +158,7 @@ describe('POST /auth/verify', () => { mockConsumeNonce.mockReturnValue(true); mockVerify.mockReturnValue(true); mockWalletFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET }); - mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', isRevoked: false }); + mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', revokedAt: null }); const res = await request(app).post('/auth/verify').send({ walletAddress: WALLET, @@ -222,7 +222,7 @@ describe('POST /auth/verify', () => { mockConsumeNonce.mockReturnValue(true); mockVerify.mockReturnValue(true); mockWalletFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET }); - mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', isRevoked: true }); + mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', revokedAt: new Date() }); const res = await request(app).post('/auth/verify').send({ walletAddress: WALLET, @@ -283,7 +283,7 @@ describe('Auth rate limiting', () => { mockConsumeNonce.mockReturnValue(true); mockVerify.mockReturnValue(true); mockWalletFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET }); - mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', isRevoked: false }); + mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', revokedAt: null }); }); it('allows up to 10 /auth/challenge requests per minute, blocks the 11th with 429', async () => { diff --git a/apps/backend/src/__tests__/deliveryPipeline.test.ts b/apps/backend/src/__tests__/deliveryPipeline.test.ts index bc3658c..6d8e8cb 100644 --- a/apps/backend/src/__tests__/deliveryPipeline.test.ts +++ b/apps/backend/src/__tests__/deliveryPipeline.test.ts @@ -14,7 +14,7 @@ vi.mock('../db/index.js', () => ({ vi.mock('../db/schema.js', () => ({ conversationMembers: { conversationId: 'conv_id', userId: 'user_id' }, - userDevices: { userId: 'ud_user_id', revokedAt: 'ud_revoked_at', id: 'ud_id' }, + devices: { userId: 'ud_user_id', revokedAt: 'ud_revoked_at', id: 'ud_id' }, messageEnvelopes: { messageId: 'me_msg_id', recipientDeviceId: 'me_rcpt_device_id', @@ -61,7 +61,6 @@ function baseMessage() { senderId: 'user-a', senderDeviceId: 'dev-a', contentType: 'text/plain', - sequenceNumber: 1, createdAt: new Date('2024-01-01'), deletedAt: null, ciphertext: 'base-ciphertext', diff --git a/apps/backend/src/__tests__/deliveryReceipts.test.ts b/apps/backend/src/__tests__/deliveryReceipts.test.ts index 18cd5c0..f1fed8b 100644 --- a/apps/backend/src/__tests__/deliveryReceipts.test.ts +++ b/apps/backend/src/__tests__/deliveryReceipts.test.ts @@ -24,7 +24,7 @@ vi.mock('../db/schema.js', () => ({ conversationMembers: {}, messages: {}, messageEnvelopes: {}, - userDevices: {}, + devices: {}, })); vi.mock('../lib/redis.js', () => ({ redis: null })); diff --git a/apps/backend/src/__tests__/deviceDelivery.test.ts b/apps/backend/src/__tests__/deviceDelivery.test.ts index 9dd5b25..5efcb8d 100644 --- a/apps/backend/src/__tests__/deviceDelivery.test.ts +++ b/apps/backend/src/__tests__/deviceDelivery.test.ts @@ -44,7 +44,6 @@ const SAMPLE_PAYLOAD: DeviceDeliveryPayload = { messageId: 'msg-1', conversationId: 'conv-1', ciphertext: 'encrypted', - sequenceNumber: 42, }; // ── Tests ───────────────────────────────────────────────────────────────────── diff --git a/apps/backend/src/__tests__/devices.bundle.test.ts b/apps/backend/src/__tests__/devices.bundle.test.ts deleted file mode 100644 index bfe9110..0000000 --- a/apps/backend/src/__tests__/devices.bundle.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Tests for GET /devices/:id/bundle, DELETE /devices/:id, - * and POST /devices/logout-everywhere (issues #305, #302). - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import request from 'supertest'; -import express from 'express'; - -// ── Mocks ───────────────────────────────────────────────────────────────────── - -const mockDeviceFindFirst = vi.fn(); -const mockSignedPreKeyFindFirst = vi.fn(); -const mockUpdate = vi.fn(); -const mockTransaction = vi.fn(); - -vi.mock('../db/index.js', () => ({ - db: { - query: { - devices: { findFirst: mockDeviceFindFirst }, - signedPreKeys: { findFirst: mockSignedPreKeyFindFirst }, - }, - update: mockUpdate, - transaction: mockTransaction, - }, -})); - -vi.mock('../db/schema.js', () => ({ - devices: { id: 'id', userId: 'userId', isRevoked: 'isRevoked' }, - signedPreKeys: { deviceId: 'deviceId', keyId: 'keyId' }, - oneTimePreKeys: { id: 'id', deviceId: 'deviceId', keyId: 'keyId', createdAt: 'createdAt' }, -})); - -vi.mock('drizzle-orm', () => ({ - eq: vi.fn((col: unknown, val: unknown) => ({ op: 'eq', col, val })), - and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), - ne: vi.fn((col: unknown, val: unknown) => ({ op: 'ne', col, val })), - count: vi.fn(() => 'count(*)'), - desc: vi.fn((col: unknown) => ({ op: 'desc', col })), -})); - -vi.mock('node:crypto', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, verify: vi.fn(() => true) }; -}); - -let currentAuth = { userId: 'owner-user-id', deviceId: 'device-1' }; - -vi.mock('../middleware/auth.js', () => ({ - requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { auth: typeof currentAuth }).auth = currentAuth; - next(); - }, -})); - -const { devicesRouter } = await import('../routes/devices.js'); - -function makeApp() { - const app = express(); - app.use(express.json()); - app.use('/devices', devicesRouter); - return app; -} - -const ACTIVE_DEVICE = { - id: 'device-2', - userId: 'other-user-id', - identityPublicKey: 'identity-key', - registrationId: 42, - isRevoked: false, -}; - -const SIGNED_PRE_KEY = { - keyId: 1, - publicKey: 'spk-pub', - signature: 'spk-sig', -}; - -function setupUpdateChain(returningRows: unknown[] = []) { - // `.where(...)` is awaitable directly (DELETE /:id) and also chains - // `.returning(...)` (POST /logout-everywhere) — mirror both shapes. - const returning = vi.fn().mockResolvedValue(returningRows); - const whereResult = Object.assign(Promise.resolve(undefined), { returning }); - const where = vi.fn().mockReturnValue(whereResult); - const set = vi.fn().mockReturnValue({ where }); - mockUpdate.mockReturnValue({ set }); - return { set, where, returning }; -} - -beforeEach(() => { - vi.clearAllMocks(); - currentAuth = { userId: 'owner-user-id', deviceId: 'device-1' }; -}); - -describe('GET /devices/:id/bundle', () => { - it('returns 404 when device does not exist', async () => { - mockDeviceFindFirst.mockResolvedValue(undefined); - - const res = await request(makeApp()).get('/devices/nonexistent/bundle'); - - expect(res.status).toBe(404); - }); - - it('returns 404 when device is revoked', async () => { - mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, isRevoked: true }); - - const res = await request(makeApp()).get('/devices/device-2/bundle'); - - expect(res.status).toBe(404); - }); - - it('returns 409 when device has no signed prekey', async () => { - mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); - mockSignedPreKeyFindFirst.mockResolvedValue(undefined); - - const res = await request(makeApp()).get('/devices/device-2/bundle'); - - expect(res.status).toBe(409); - }); - - it('returns a full bundle and consumes one OTP atomically', async () => { - mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); - mockSignedPreKeyFindFirst.mockResolvedValue(SIGNED_PRE_KEY); - - const claimed = { id: 'otp-1', keyId: 10, publicKey: 'otp-pub' }; - const deleteWhere = vi.fn().mockResolvedValue(undefined); - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - orderBy: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue({ - for: vi.fn().mockResolvedValue([claimed]), - }), - }), - }), - }), - }), - delete: vi.fn().mockReturnValue({ where: deleteWhere }), - }; - mockTransaction.mockImplementation(async (cb: (tx: typeof tx) => unknown) => cb(tx)); - - const res = await request(makeApp()).get('/devices/device-2/bundle'); - - expect(res.status).toBe(200); - expect(res.body.identityPublicKey).toBe('identity-key'); - expect(res.body.registrationId).toBe(42); - expect(res.body.signedPreKey).toEqual(SIGNED_PRE_KEY); - expect(res.body.oneTimePreKey).toEqual({ keyId: 10, publicKey: 'otp-pub' }); - expect(deleteWhere).toHaveBeenCalled(); - }); - - it('falls back to signed-prekey-only when OTPs are exhausted', async () => { - mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); - mockSignedPreKeyFindFirst.mockResolvedValue(SIGNED_PRE_KEY); - - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - orderBy: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue({ - for: vi.fn().mockResolvedValue([]), - }), - }), - }), - }), - }), - delete: vi.fn(), - }; - mockTransaction.mockImplementation(async (cb: (tx: typeof tx) => unknown) => cb(tx)); - - const res = await request(makeApp()).get('/devices/device-2/bundle'); - - expect(res.status).toBe(200); - expect(res.body.oneTimePreKey).toBeNull(); - expect(tx.delete).not.toHaveBeenCalled(); - }); -}); - -describe('DELETE /devices/:id', () => { - it('returns 404 when revoking a device owned by another user', async () => { - mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, userId: 'someone-else' }); - - const res = await request(makeApp()).delete('/devices/device-2'); - - expect(res.status).toBe(404); - }); - - it('revokes an owned device', async () => { - mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, userId: 'owner-user-id' }); - setupUpdateChain(); - - const res = await request(makeApp()).delete('/devices/device-2'); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ id: 'device-2', isRevoked: true }); - }); -}); - -describe('POST /devices/logout-everywhere', () => { - it('revokes all other devices and reports the count', async () => { - setupUpdateChain([{ id: 'device-2' }, { id: 'device-3' }]); - - const res = await request(makeApp()).post('/devices/logout-everywhere'); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ revokedCount: 2 }); - }); -}); diff --git a/apps/backend/src/__tests__/devices.prekeys.test.ts b/apps/backend/src/__tests__/devices.prekeys.test.ts index b67ebf4..2dce5e6 100644 --- a/apps/backend/src/__tests__/devices.prekeys.test.ts +++ b/apps/backend/src/__tests__/devices.prekeys.test.ts @@ -1,5 +1,6 @@ /** - * Tests for POST /devices/:id/prekeys (issue #159) + * Tests for POST /devices/:id/prekeys (issue #159, updated for the unified + * `device_prekeys` table — #104). */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -24,14 +25,24 @@ vi.mock('../db/index.js', () => ({ vi.mock('../db/schema.js', () => ({ devices: { id: 'id', userId: 'userId' }, - signedPreKeys: { deviceId: 'deviceId', keyId: 'keyId' }, - oneTimePreKeys: { deviceId: 'deviceId', keyId: 'keyId' }, + devicePrekeys: { + deviceId: 'deviceId', + keyType: 'keyType', + keyId: 'keyId', + consumed: 'consumed', + }, })); +vi.mock('../lib/redis.js', () => ({ redis: null })); + vi.mock('drizzle-orm', () => ({ eq: vi.fn((col: unknown, val: unknown) => ({ col, val })), and: vi.fn((...args: unknown[]) => args), count: vi.fn(() => 'count(*)'), + sql: Object.assign( + vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), + { raw: vi.fn() }, + ), })); // Stub crypto verify so we can control the outcome in tests. @@ -79,7 +90,7 @@ const ACTIVE_DEVICE = { id: 'device-1', userId: 'owner-user-id', identityPublicKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', - isRevoked: false, + revokedAt: null, }; function setupInsertChain() { @@ -120,7 +131,7 @@ describe('POST /devices/:id/prekeys', () => { }); it('returns 403 when the device is revoked', async () => { - mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, isRevoked: true }); + mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, revokedAt: new Date() }); const res = await request(makeApp()).post('/devices/device-1/prekeys').send(VALID_BODY); diff --git a/apps/backend/src/__tests__/devices.revoke.test.ts b/apps/backend/src/__tests__/devices.revoke.test.ts new file mode 100644 index 0000000..8fb66ab --- /dev/null +++ b/apps/backend/src/__tests__/devices.revoke.test.ts @@ -0,0 +1,202 @@ +/** + * Tests for DELETE /devices/:id and POST /devices/logout-everywhere (#107, #119). + * + * Revocation now has real side effects (#107's fix): prekeys are deleted, + * `device_revoked:*` is published so live sockets get force-disconnected + * (#146), a key-change system event is emitted, and revoking a caller's + * only active device is refused. The old GET /devices/:id/bundle endpoint + * that used to live in this file moved to + * GET /users/:userId/devices/:deviceId/key-bundle (#110) — see users.bundle.test.ts. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockDeviceFindFirst = vi.fn(); +const mockUpdate = vi.fn(); +const mockDelete = vi.fn(); +const mockFindMany = vi.fn(); +const mockSelect = vi.fn(); +const mockPublish = vi.fn().mockResolvedValue(undefined); +const mockMarkDeviceRevoked = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + devices: { findFirst: mockDeviceFindFirst, findMany: mockFindMany }, + conversationMembers: { findMany: vi.fn().mockResolvedValue([]) }, + }, + update: mockUpdate, + delete: mockDelete, + select: mockSelect, + }, +})); + +vi.mock('../db/schema.js', () => ({ + devices: { id: 'id', userId: 'userId', revokedAt: 'revokedAt' }, + devicePrekeys: { deviceId: 'deviceId' }, + conversationMembers: { userId: 'userId', conversationId: 'conversationId' }, + messages: {}, +})); + +vi.mock('../lib/redis.js', () => ({ redis: { publish: mockPublish } })); + +vi.mock('../services/deviceRevocation.js', () => ({ + markDeviceRevoked: mockMarkDeviceRevoked, +})); + +vi.mock('../lib/socket.js', () => ({ getSocketServer: vi.fn(() => null) })); + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((col: unknown, val: unknown) => ({ op: 'eq', col, val })), + and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), + ne: vi.fn((col: unknown, val: unknown) => ({ op: 'ne', col, val })), + count: vi.fn(() => 'count(*)'), + desc: vi.fn((col: unknown) => ({ op: 'desc', col })), + isNull: vi.fn((col: unknown) => ({ op: 'isNull', col })), + inArray: vi.fn((col: unknown, val: unknown) => ({ op: 'inArray', col, val })), + sql: Object.assign( + vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), + { raw: vi.fn() }, + ), +})); + +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, verify: vi.fn(() => true) }; +}); + +let currentAuth = { userId: 'owner-user-id', deviceId: 'device-1' }; + +vi.mock('../middleware/auth.js', () => ({ + requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + (req as express.Request & { auth: typeof currentAuth }).auth = currentAuth; + next(); + }, +})); + +const { devicesRouter } = await import('../routes/devices.js'); + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use('/devices', devicesRouter); + return app; +} + +const ACTIVE_DEVICE = { + id: 'device-2', + userId: 'owner-user-id', + identityPublicKey: 'identity-key', + registrationId: 42, + revokedAt: null, +}; + +function setupUpdateChain() { + const where = vi.fn().mockResolvedValue(undefined); + const set = vi.fn().mockReturnValue({ where }); + mockUpdate.mockReturnValue({ set }); + return { set, where }; +} + +function setupDeleteChain() { + const where = vi.fn().mockResolvedValue(undefined); + mockDelete.mockReturnValue({ where }); + return { where }; +} + +function setupActiveCount(total: number) { + const where = vi.fn().mockResolvedValue([{ total }]); + const from = vi.fn().mockReturnValue({ where }); + mockSelect.mockReturnValue({ from }); +} + +beforeEach(() => { + vi.clearAllMocks(); + currentAuth = { userId: 'owner-user-id', deviceId: 'device-1' }; + setupUpdateChain(); + setupDeleteChain(); +}); + +describe('DELETE /devices/:id', () => { + it('returns 404 when the device does not exist', async () => { + mockDeviceFindFirst.mockResolvedValue(undefined); + + const res = await request(makeApp()).delete('/devices/device-2'); + + expect(res.status).toBe(404); + }); + + it('returns 404 when revoking a device owned by another user', async () => { + mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, userId: 'someone-else' }); + + const res = await request(makeApp()).delete('/devices/device-2'); + + expect(res.status).toBe(404); + }); + + it('is idempotent for an already-revoked device', async () => { + const revokedAt = new Date('2026-01-01T00:00:00.000Z'); + mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, revokedAt }); + + const res = await request(makeApp()).delete('/devices/device-2'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: 'device-2', revokedAt: revokedAt.toISOString() }); + // No side effects re-run for an already-revoked device. + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + it("refuses to revoke the caller's only active device", async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + setupActiveCount(1); + + const res = await request(makeApp()).delete('/devices/device-2'); + + expect(res.status).toBe(409); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('revokes an owned device: clears prekeys, publishes device_revoked, marks it locally', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + setupActiveCount(2); + + const res = await request(makeApp()).delete('/devices/device-2'); + + expect(res.status).toBe(200); + expect(res.body.id).toBe('device-2'); + expect(res.body.revokedAt).toBeTruthy(); + expect(mockUpdate).toHaveBeenCalled(); + expect(mockDelete).toHaveBeenCalled(); + expect(mockMarkDeviceRevoked).toHaveBeenCalledWith('device-2'); + expect(mockPublish).toHaveBeenCalledWith('device_revoked:device-2', '1'); + }); +}); + +describe('POST /devices/logout-everywhere', () => { + it('revokes all other active devices and reports the count', async () => { + mockFindMany.mockResolvedValue([{ id: 'device-2' }, { id: 'device-3' }]); + + const res = await request(makeApp()).post('/devices/logout-everywhere'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ revokedCount: 2 }); + expect(mockMarkDeviceRevoked).toHaveBeenCalledWith('device-2'); + expect(mockMarkDeviceRevoked).toHaveBeenCalledWith('device-3'); + expect(mockPublish).toHaveBeenCalledTimes(2); + }); + + it('reports zero when there are no other active devices', async () => { + mockFindMany.mockResolvedValue([]); + + const res = await request(makeApp()).post('/devices/logout-everywhere'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ revokedCount: 0 }); + expect(mockPublish).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/__tests__/devices.test.ts b/apps/backend/src/__tests__/devices.test.ts index 0396e94..75707dd 100644 --- a/apps/backend/src/__tests__/devices.test.ts +++ b/apps/backend/src/__tests__/devices.test.ts @@ -11,9 +11,12 @@ vi.mock('../db/index.js', () => ({ findMany: vi.fn(), }, }, + select: vi.fn(), }, })); +vi.mock('../lib/redis.js', () => ({ redis: null })); + const { devicesRouter } = await import('../routes/devices.js'); const { db } = await import('../db/index.js'); @@ -34,7 +37,10 @@ const ROWS = [ id: CURRENT_DEVICE_ID, userId: USER_ID, identityPublicKey: 'key-active-1', - isRevoked: false, + deviceName: 'Phone', + platform: 'ios', + lastSeenAt: CREATED_AT, + revokedAt: null, createdAt: CREATED_AT, updatedAt: CREATED_AT, }, @@ -42,7 +48,10 @@ const ROWS = [ id: 'device-row-2', userId: USER_ID, identityPublicKey: 'key-active-2', - isRevoked: false, + deviceName: 'Laptop', + platform: 'web', + lastSeenAt: CREATED_AT, + revokedAt: null, createdAt: CREATED_AT, updatedAt: CREATED_AT, }, @@ -50,12 +59,22 @@ const ROWS = [ id: 'device-row-3', userId: USER_ID, identityPublicKey: 'key-revoked', - isRevoked: true, + deviceName: 'Old tablet', + platform: 'android', + lastSeenAt: CREATED_AT, + revokedAt: CREATED_AT, createdAt: CREATED_AT, updatedAt: CREATED_AT, }, ]; +function mockOtpCounts(rows: Array<{ deviceId: string; remaining: number }>) { + const groupBy = vi.fn().mockResolvedValue(rows); + const where = vi.fn().mockReturnValue({ groupBy }); + const from = vi.fn().mockReturnValue({ where }); + vi.mocked(db.select).mockReturnValue({ from } as never); +} + beforeEach(() => { vi.clearAllMocks(); // requireAuth calls db.query.devices.findFirst to verify the device exists and is active. @@ -63,10 +82,11 @@ beforeEach(() => { id: CURRENT_DEVICE_ID, userId: USER_ID, identityPublicKey: 'key-active-1', - isRevoked: false, + revokedAt: null, createdAt: CREATED_AT, updatedAt: CREATED_AT, } as never); + mockOtpCounts([]); }); describe('GET /devices', () => { @@ -104,8 +124,8 @@ describe('GET /devices', () => { 'device-row-3', ]); - expect(res.body[2].isRevoked).toBe(true); - expect(res.body[0].isRevoked).toBe(false); + expect(res.body[2].revokedAt).toBe(CREATED_AT.toISOString()); + expect(res.body[0].revokedAt).toBeNull(); }); it('flags only the device from the caller JWT as current', async () => { @@ -127,6 +147,25 @@ describe('GET /devices', () => { expect(res.status).toBe(401); }); + it('includes oneTimePreKeysRemaining per device', async () => { + vi.mocked(db.query.devices.findMany).mockResolvedValue([ROWS[0]] as never); + mockOtpCounts([{ deviceId: CURRENT_DEVICE_ID, remaining: 7 }]); + + const res = await request(app).get('/devices').set('Authorization', AUTH_HEADER); + + expect(res.status).toBe(200); + expect(res.body[0].oneTimePreKeysRemaining).toBe(7); + }); + + it('defaults oneTimePreKeysRemaining to 0 when no prekeys exist', async () => { + vi.mocked(db.query.devices.findMany).mockResolvedValue([ROWS[0]] as never); + + const res = await request(app).get('/devices').set('Authorization', AUTH_HEADER); + + expect(res.status).toBe(200); + expect(res.body[0].oneTimePreKeysRemaining).toBe(0); + }); + it('returns the exact response shape with no leaked internal fields', async () => { vi.mocked(db.query.devices.findMany).mockResolvedValue([ROWS[0]] as never); @@ -134,7 +173,17 @@ describe('GET /devices', () => { expect(res.status).toBe(200); expect(Object.keys(res.body[0]).sort()).toEqual( - ['createdAt', 'current', 'id', 'identityPublicKey', 'isRevoked'].sort(), + [ + 'createdAt', + 'current', + 'id', + 'identityPublicKey', + 'deviceName', + 'platform', + 'lastSeenAt', + 'revokedAt', + 'oneTimePreKeysRemaining', + ].sort(), ); expect(res.body[0]).not.toHaveProperty('userId'); expect(res.body[0]).not.toHaveProperty('updatedAt'); diff --git a/apps/backend/src/__tests__/fileCleanup.test.ts b/apps/backend/src/__tests__/fileCleanup.test.ts index e6866ef..928074c 100644 --- a/apps/backend/src/__tests__/fileCleanup.test.ts +++ b/apps/backend/src/__tests__/fileCleanup.test.ts @@ -1,18 +1,19 @@ /** - * Tests for file cleanup service (#231). + * Tests for file cleanup service (#231, #168). + * + * #168: this job used to construct its own independent S3Client from + * AWS_REGION/AWS_BUCKET, disconnected from the OBJECT_STORE_* config every + * other storage code path uses — meaning it could target the wrong bucket + * entirely in a real deployment. It now shares the single process-wide + * `getObjectStore()` singleton (lib/objectStore.ts) instead. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -// ── S3 mock (must use vi.hoisted so it's available in the factory) ──────────── -const mockS3Send = vi.hoisted(() => vi.fn()); +// ── object store mock (must use vi.hoisted so it's available in the factory) ── +const mockDeleteObject = vi.hoisted(() => vi.fn()); -vi.mock('@aws-sdk/client-s3', () => ({ - S3Client: class MockS3 { - send = mockS3Send; - }, - DeleteObjectCommand: class MockDeleteCmd { - constructor(public input: unknown) {} - }, +vi.mock('../lib/objectStore.js', () => ({ + getObjectStore: () => ({ deleteObject: mockDeleteObject }), })); // ── DB mock ─────────────────────────────────────────────────────────────────── @@ -55,7 +56,7 @@ const mockWhereFn = vi.fn().mockResolvedValue(undefined); beforeEach(() => { vi.clearAllMocks(); - mockS3Send.mockResolvedValue(undefined); + mockDeleteObject.mockResolvedValue(undefined); mockUpdate.mockReturnValue({ set: mockSetFn }); mockSetFn.mockReturnValue({ where: mockWhereFn }); mockDelete.mockReturnValue({ where: mockWhereFn }); @@ -80,7 +81,7 @@ describe('#231 – runHardDeletePass', () => { await runHardDeletePass(); - expect(mockS3Send).not.toHaveBeenCalled(); + expect(mockDeleteObject).not.toHaveBeenCalled(); expect(mockSetFn).not.toHaveBeenCalled(); }); @@ -92,7 +93,7 @@ describe('#231 – runHardDeletePass', () => { await runHardDeletePass(); - expect(mockS3Send).toHaveBeenCalledTimes(1); + expect(mockDeleteObject).toHaveBeenCalledTimes(1); expect(mockSetFn).toHaveBeenCalledWith({ hardDeletedAt: expect.any(Date) }); }); @@ -101,7 +102,7 @@ describe('#231 – runHardDeletePass', () => { .mockResolvedValueOnce([{ id: 'file-3', storageKey: 'key-3' }]) .mockResolvedValueOnce([]); mockExecute.mockResolvedValueOnce([]); - mockS3Send.mockRejectedValueOnce(new Error('NoSuchKey')); + mockDeleteObject.mockRejectedValueOnce(new Error('NoSuchKey')); await runHardDeletePass(); @@ -119,7 +120,7 @@ describe('#231 – runHardDeletePass', () => { await runHardDeletePass(); - expect(mockS3Send).toHaveBeenCalledTimes(2); + expect(mockDeleteObject).toHaveBeenCalledTimes(2); }); it('deletes pending files older than 24 hours', async () => { @@ -129,7 +130,7 @@ describe('#231 – runHardDeletePass', () => { await runHardDeletePass(); - expect(mockS3Send).toHaveBeenCalledTimes(1); + expect(mockDeleteObject).toHaveBeenCalledTimes(1); expect(mockDelete).toHaveBeenCalled(); }); }); diff --git a/apps/backend/src/__tests__/integration/gateway.integration.test.ts b/apps/backend/src/__tests__/integration/gateway.integration.test.ts index c4e17fd..3014ecb 100644 --- a/apps/backend/src/__tests__/integration/gateway.integration.test.ts +++ b/apps/backend/src/__tests__/integration/gateway.integration.test.ts @@ -1,7 +1,7 @@ /** * Gateway integration tests — issue #215 * - * Spins up two Socket.IO gateway instances sharing a real Redis instance to + * Spins up two Socket.IO gateway instances sharing a Redis instance to * assert the following acceptance criteria: * * 1. Cross-node delivery — message sent on node-1 arrives on node-2 @@ -10,8 +10,12 @@ * 4. Revocation disconnect — a device revoked via Redis pub/sub is force-disconnected * 5. Resume/sync after drop — missed ephemeral events are replayed on reconnect * - * Requires Redis at REDIS_URL (default redis://localhost:6379). - * Start one locally with: docker run -p 6379:6379 redis:7-alpine + * Uses `ioredis-mock` (#152) instead of a real Redis server: it's a faithful + * in-memory reimplementation of the ioredis client (hashes, sets, streams, + * SCAN, and — critically — real pub/sub/psubscribe semantics shared across + * `.duplicate()`'d instances, which is what `@socket.io/redis-adapter` and + * the device-revocation listener both depend on). This makes the suite + * hermetic: no Docker, no external service, runs the same in CI and locally. */ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; @@ -20,7 +24,8 @@ import { Server } from 'socket.io'; import { io as ioc } from 'socket.io-client'; import type { Socket as ClientSocket } from 'socket.io-client'; import { createAdapter } from '@socket.io/redis-adapter'; -import { Redis } from 'ioredis'; +import type { Redis } from 'ioredis'; +import RedisMock from 'ioredis-mock'; import jwt from 'jsonwebtoken'; // ── hoisted redis reference ─────────────────────────────────────────────────── @@ -35,11 +40,10 @@ const redisRef = vi.hoisted(() => ({ instance: null as Redis | null })); vi.mock('../../db/index.js', () => { const db: Record = { query: { - devices: { findFirst: vi.fn() }, + devices: { findFirst: vi.fn(), findMany: vi.fn() }, users: { findFirst: vi.fn() }, conversationMembers: { findFirst: vi.fn(), findMany: vi.fn() }, messages: { findFirst: vi.fn(), findMany: vi.fn() }, - userDevices: { findMany: vi.fn() }, }, insert: vi.fn(), update: vi.fn(), @@ -57,7 +61,6 @@ vi.mock('../../db/schema.js', () => ({ conversationMembers: {}, messages: {}, messageEnvelopes: {}, - userDevices: {}, users: {}, })); @@ -122,7 +125,6 @@ import { setSocketServer } from '../../lib/socket.js'; // ── fixtures ────────────────────────────────────────────────────────────────── const JWT_SECRET = 'test-secret-for-ci-only'; -const REDIS_URL = process.env['REDIS_URL'] ?? 'redis://localhost:6379'; const CONV_ID = 'conv-integration-215'; // Port range reserved for this suite — avoids clashes with other listeners. @@ -229,11 +231,11 @@ const adapterSync = () => new Promise((r) => setTimeout(r, 150)); // ── mock configurators ──────────────────────────────────────────────────────── -function mockDevice(user: typeof ALICE, isRevoked = false) { +function mockDevice(user: typeof ALICE, revoked = false) { vi.mocked(db.query.devices.findFirst).mockResolvedValue({ id: user.deviceId, userId: user.userId, - isRevoked, + revokedAt: revoked ? new Date() : null, } as never); } @@ -252,12 +254,10 @@ function mockInsertMessage(msg: { senderId: string; senderDeviceId: string; ciphertext: string; - sequenceNumber?: number; }) { const row = { ...msg, contentType: 'text/plain', - sequenceNumber: msg.sequenceNumber ?? 1, createdAt: new Date(), }; const returning = vi.fn().mockResolvedValue([row]); @@ -277,16 +277,12 @@ const suppressConnectionClosed = (err: unknown) => { throw err; }; -// Skipped: requires a real Redis instance at REDIS_URL and fails locally -// without one. CI provides Redis via a service container; run manually with -// `docker run -p 6379:6379 redis:7-alpine` and remove `.skip` to exercise it. -describe.skip('Gateway integration — issue #215', () => { +describe('Gateway integration — issue #215', () => { let redis: Redis; beforeAll(async () => { process.on('unhandledRejection', suppressConnectionClosed); - redis = new Redis(REDIS_URL, { lazyConnect: true }); - await redis.connect(); + redis = new RedisMock(); redisRef.instance = redis; }); @@ -310,14 +306,6 @@ describe.skip('Gateway integration — issue #215', () => { from: vi.fn().mockReturnValue({ where: mockWhere }), } as never); - // send_message bumps conversations.lastSequenceNumber inside db.transaction() - // before inserting the message row — default this to a working chain. - vi.mocked(db.update).mockReturnValue({ - set: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([{ newSeq: 1 }]), - } as never); - // Flush all keys written by this suite so tests are hermetically isolated. const patterns = [ `presence:${ALICE.userId}`, @@ -362,7 +350,7 @@ describe.skip('Gateway integration — issue #215', () => { conversationId: CONV_ID, } as never); vi.mocked(db.query.messages.findFirst).mockResolvedValue(undefined); - vi.mocked(db.query.userDevices.findMany).mockResolvedValue([] as never); + vi.mocked(db.query.devices.findMany).mockResolvedValue([] as never); mockInsertMessage({ id: MSG_ID, conversationId: CONV_ID, @@ -380,6 +368,11 @@ describe.skip('Gateway integration — issue #215', () => { conversationId: CONV_ID, messageId: MSG_ID, ciphertext: 'hello from node-1', + // validateMessagePayload requires >=1 envelope for text messages; + // it doesn't need to resolve to a real device for this test since + // room-broadcast delivery (not per-device envelope delivery, which + // "multi-device fanout" below covers) is what's under test here. + envelopes: [{ recipientDeviceId: BOB.deviceId, ciphertext: 'for-bob' }], }); const msg = await bobReceived; @@ -425,7 +418,7 @@ describe.skip('Gateway integration — issue #215', () => { conversationId: CONV_ID, } as never); vi.mocked(db.query.messages.findFirst).mockResolvedValue(undefined); - vi.mocked(db.query.userDevices.findMany).mockResolvedValue([ + vi.mocked(db.query.devices.findMany).mockResolvedValue([ { id: ALICE.deviceId, userId: ALICE.userId }, { id: ALICE2.deviceId, userId: ALICE.userId }, ] as never); @@ -439,7 +432,6 @@ describe.skip('Gateway integration — issue #215', () => { senderDeviceId: BOB.deviceId, ciphertext: 'broadcast', contentType: 'text/plain', - sequenceNumber: 1, createdAt: new Date(), }; vi.mocked(db.insert).mockReturnValue({ @@ -498,7 +490,7 @@ describe.skip('Gateway integration — issue #215', () => { conversationId: CONV_ID, } as never); vi.mocked(db.query.messages.findFirst).mockResolvedValue(undefined); - vi.mocked(db.query.userDevices.findMany).mockResolvedValue([] as never); + vi.mocked(db.query.devices.findMany).mockResolvedValue([] as never); // Introduce latency on the returning() step to prove ordering. const returning = vi.fn().mockImplementation(async () => { @@ -512,7 +504,6 @@ describe.skip('Gateway integration — issue #215', () => { senderDeviceId: ALICE.deviceId, ciphertext: 'persist-test', contentType: 'text/plain', - sequenceNumber: 99, createdAt: new Date(), }, ]; @@ -521,10 +512,7 @@ describe.skip('Gateway integration — issue #215', () => { values: vi.fn().mockReturnValue({ returning }), } as never); - const bobMessage = waitFor<{ id: string; sequenceNumber: number }>( - clientBob, - 'new_message', - ).then((m) => { + const bobMessage = waitFor<{ id: string }>(clientBob, 'new_message').then((m) => { order.push('new_message_received'); return m; }); @@ -533,13 +521,14 @@ describe.skip('Gateway integration — issue #215', () => { conversationId: CONV_ID, messageId: MSG_ID, ciphertext: 'persist-before-deliver', + envelopes: [{ recipientDeviceId: BOB.deviceId, ciphertext: 'for-bob' }], }); const received = await bobMessage; expect(returning).toHaveBeenCalledOnce(); expect(order).toEqual(['db_insert_done', 'new_message_received']); - expect(received.sequenceNumber).toBe(99); + expect(received.id).toBe(MSG_ID); clientAlice.disconnect(); clientBob.disconnect(); @@ -562,6 +551,12 @@ describe.skip('Gateway integration — issue #215', () => { // Dedicated subscriber Redis client (ioredis becomes subscriber-only // after psubscribe, so we must not reuse the main redis instance). const revSub = redis.duplicate(); + // ioredis-mock connections are already connected on construction but + // leave `.status` unset; startDeviceRevocationListener's guard + // (`status !== 'ready' && status !== 'connect'`) exists for real + // ioredis's lazyConnect flow, so tell it this connection is live — + // calling the mock's own .connect() again throws "already connected". + (revSub as unknown as { status: string }).status = 'ready'; await startDeviceRevocationListener(revSub, redis); try { diff --git a/apps/backend/src/__tests__/messageEdit.test.ts b/apps/backend/src/__tests__/messageEdit.test.ts index 39f21d1..f3e5cde 100644 --- a/apps/backend/src/__tests__/messageEdit.test.ts +++ b/apps/backend/src/__tests__/messageEdit.test.ts @@ -5,7 +5,7 @@ import { EventEmitter } from 'events'; const mockMessagesFindFirst = vi.fn(); const mockMembersFindMany = vi.fn(); -const mockUserDevicesFindMany = vi.fn(); +const mockDevicesFindMany = vi.fn(); const mockReturning = vi.fn(); // values() must work both as `.values(x).returning()` (message insert) and as @@ -28,7 +28,7 @@ vi.mock('../db/index.js', () => { query: { conversationMembers: { findFirst: vi.fn(), findMany: mockMembersFindMany }, messages: { findFirst: mockMessagesFindFirst }, - userDevices: { findMany: mockUserDevicesFindMany }, + devices: { findMany: mockDevicesFindMany }, }, insert: mockInsert, update: mockUpdate, @@ -43,7 +43,7 @@ vi.mock('../db/schema.js', () => ({ conversationMembers: {}, messages: {}, messageEnvelopes: {}, - userDevices: {}, + devices: {}, })); vi.mock('../lib/conversationCache.js', () => ({ @@ -123,9 +123,10 @@ const CONVERSATION_ID = 'conv-1'; beforeEach(() => { vi.clearAllMocks(); mockMembersFindMany.mockResolvedValue([]); - mockUserDevicesFindMany.mockResolvedValue([]); - mockReturning.mockResolvedValue([{ id: 'new-msg', sequenceNumber: 5 }]); - mockUpdateReturning.mockResolvedValue([{ newSeq: 5 }]); + mockDevicesFindMany.mockResolvedValue([]); + mockReturning.mockResolvedValue([ + { id: 'new-msg', createdAt: new Date('2024-01-01T00:00:05.000Z') }, + ]); }); describe('edit_message socket event', () => { @@ -250,7 +251,7 @@ describe('edit_message socket event', () => { editsMessageId: null, contentType: 'text/plain', }) - .mockResolvedValueOnce({ sequenceNumber: 9 }); // already exists + .mockResolvedValueOnce({ createdAt: new Date('2024-01-01T00:00:09.000Z') }); // already exists const socket = makeSocket(USER_ID, DEVICE_ID); const io = makeIo(); @@ -261,7 +262,7 @@ describe('edit_message socket event', () => { expect(mockInsert).not.toHaveBeenCalled(); expect(socket.emit).toHaveBeenCalledWith('message_ack', { messageId: 'dup', - sequenceNumber: 9, + createdAt: new Date('2024-01-01T00:00:09.000Z'), }); expect(io.roomEmitted.map((e) => e.event)).not.toContain('message_edited'); }); diff --git a/apps/backend/src/__tests__/push.test.ts b/apps/backend/src/__tests__/push.test.ts new file mode 100644 index 0000000..81e39f0 --- /dev/null +++ b/apps/backend/src/__tests__/push.test.ts @@ -0,0 +1,111 @@ +/** + * Tests for the file-message push path (services/push.ts, #176). + * + * This used to send one uncoalesced webpush.sendNotification directly, with + * no rate limiting and no dead-subscription pruning. It now delegates to + * pushNotification.ts's shared queueCoalescedPush so file-message pushes get + * the same coalescing/rate-limit/hygiene behavior as text-message pushes — + * this test verifies the delegation and the recipient-filtering logic that + * stays local to this call site (mute, pushEnabled, online-skip). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockMembersFindMany = vi.fn(); +const mockDevicesFindMany = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + conversationMembers: { findMany: mockMembersFindMany }, + devices: { findMany: mockDevicesFindMany }, + }, + }, +})); + +vi.mock('../db/schema.js', () => ({ + conversationMembers: { conversationId: 'conversation_id', userId: 'user_id' }, + devices: { userId: 'user_id', pushEnabled: 'push_enabled', revokedAt: 'revoked_at' }, +})); + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => args), + eq: vi.fn((col: unknown, val: unknown) => ({ col, val })), + isNull: vi.fn((col: unknown) => ({ col, isNull: true })), +})); + +const mockIsOnline = vi.fn(); +vi.mock('../services/presence.js', () => ({ isOnline: mockIsOnline })); + +const mockQueueCoalescedPush = vi.fn(); +vi.mock('../services/pushNotification.js', () => ({ + queueCoalescedPush: mockQueueCoalescedPush, +})); + +vi.mock('../lib/redis.js', () => ({ redis: { fake: true } })); + +const { sendPushForMessage } = await import('../services/push.js'); + +const CTX = { conversationId: 'conv-1', messageId: 'msg-1', senderId: 'sender-1' }; + +beforeEach(() => { + vi.clearAllMocks(); + mockIsOnline.mockResolvedValue(false); +}); + +describe('sendPushForMessage (#176)', () => { + it('queues a coalesced push for each active, push-enabled device of an offline, unmuted member', async () => { + mockMembersFindMany.mockResolvedValue([{ userId: 'recipient-1', isMuted: false }]); + mockDevicesFindMany.mockResolvedValue([{ id: 'device-a' }, { id: 'device-b' }]); + + await sendPushForMessage(CTX); + + expect(mockQueueCoalescedPush).toHaveBeenCalledTimes(2); + expect(mockQueueCoalescedPush).toHaveBeenCalledWith('device-a', 'conv-1', 'msg-1'); + expect(mockQueueCoalescedPush).toHaveBeenCalledWith('device-b', 'conv-1', 'msg-1'); + }); + + it('skips the sender', async () => { + mockMembersFindMany.mockResolvedValue([{ userId: CTX.senderId, isMuted: false }]); + + await sendPushForMessage(CTX); + + expect(mockDevicesFindMany).not.toHaveBeenCalled(); + expect(mockQueueCoalescedPush).not.toHaveBeenCalled(); + }); + + it('skips members who muted the conversation', async () => { + mockMembersFindMany.mockResolvedValue([{ userId: 'recipient-1', isMuted: true }]); + + await sendPushForMessage(CTX); + + expect(mockQueueCoalescedPush).not.toHaveBeenCalled(); + }); + + it('skips members who are currently online', async () => { + mockMembersFindMany.mockResolvedValue([{ userId: 'recipient-1', isMuted: false }]); + mockIsOnline.mockResolvedValue(true); + + await sendPushForMessage(CTX); + + expect(mockDevicesFindMany).not.toHaveBeenCalled(); + expect(mockQueueCoalescedPush).not.toHaveBeenCalled(); + }); + + it('only resolves active, push-enabled devices (query scoped correctly)', async () => { + mockMembersFindMany.mockResolvedValue([{ userId: 'recipient-1', isMuted: false }]); + mockDevicesFindMany.mockResolvedValue([]); + + await sendPushForMessage(CTX); + + expect(mockDevicesFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.anything() }), + ); + expect(mockQueueCoalescedPush).not.toHaveBeenCalled(); + }); + + it('never throws — push is best-effort', async () => { + mockMembersFindMany.mockRejectedValue(new Error('db down')); + + await expect(sendPushForMessage(CTX)).resolves.toBeUndefined(); + }); +}); diff --git a/apps/backend/src/__tests__/resume.socket.test.ts b/apps/backend/src/__tests__/resume.socket.test.ts index 37403ed..9fb0ef9 100644 --- a/apps/backend/src/__tests__/resume.socket.test.ts +++ b/apps/backend/src/__tests__/resume.socket.test.ts @@ -8,7 +8,7 @@ vi.mock('../db/index.js', () => ({ query: { conversationMembers: { findFirst: vi.fn(), findMany: vi.fn() }, messages: { findFirst: vi.fn() }, - userDevices: { findMany: vi.fn() }, + devices: { findMany: vi.fn() }, }, insert: vi.fn(), update: vi.fn(), @@ -21,7 +21,7 @@ vi.mock('../db/schema.js', () => ({ conversationMembers: {}, messages: {}, messageEnvelopes: {}, - userDevices: {}, + devices: {}, })); vi.mock('../lib/conversationCache.js', () => ({ diff --git a/apps/backend/src/__tests__/selfSync.test.ts b/apps/backend/src/__tests__/selfSync.test.ts index 5f2bf8a..544b448 100644 --- a/apps/backend/src/__tests__/selfSync.test.ts +++ b/apps/backend/src/__tests__/selfSync.test.ts @@ -21,7 +21,7 @@ import { EventEmitter } from 'events'; const mockMemberFindFirst = vi.fn(); const mockMessageFindFirst = vi.fn(); -const mockUserDevicesFindMany = vi.fn(); +const mockDevicesFindMany = vi.fn(); const mockMemberFindMany = vi.fn(); const mockReturning = vi.fn(); @@ -55,7 +55,7 @@ vi.mock('../db/index.js', () => { findMany: mockMemberFindMany, }, messages: { findFirst: mockMessageFindFirst }, - userDevices: { findMany: mockUserDevicesFindMany }, + devices: { findMany: mockDevicesFindMany }, }, insert: mockInsert, update: mockUpdate, @@ -71,7 +71,7 @@ vi.mock('../db/schema.js', () => ({ conversationMembers: {}, messages: {}, messageEnvelopes: {}, - userDevices: {}, + devices: {}, })); vi.mock('../lib/conversationCache.js', () => ({ @@ -156,7 +156,7 @@ const BASE_MESSAGE = { contentType: 'text/plain', editsMessageId: null, ciphertext: 'abc', - sequenceNumber: 1, + createdAt: new Date('2024-01-01T00:00:01.000Z'), }; beforeEach(() => { @@ -166,11 +166,12 @@ beforeEach(() => { mockMemberFindFirst.mockReset().mockResolvedValue(MEMBERSHIP); mockMessageFindFirst.mockReset().mockResolvedValue(undefined); mockMemberFindMany.mockReset().mockResolvedValue([]); - mockUserDevicesFindMany.mockReset().mockResolvedValue([]); + mockDevicesFindMany.mockReset().mockResolvedValue([]); mockReturning .mockReset() - .mockResolvedValue([{ ...BASE_MESSAGE, id: 'new-msg', sequenceNumber: 2 }]); - mockUpdateReturning.mockReset().mockResolvedValue([{ newSeq: 2 }]); + .mockResolvedValue([ + { ...BASE_MESSAGE, id: 'new-msg', createdAt: new Date('2024-01-01T00:00:02.000Z') }, + ]); // Only clear call history for structural vi.fn(impl) mocks — mockReset would // wipe their implementations and break the insert().values().returning() chain. @@ -192,7 +193,7 @@ beforeEach(() => { describe('send_message — sibling device enforcement (#188)', () => { it('accepts a message when the sender has no sibling devices', async () => { - mockUserDevicesFindMany.mockResolvedValue([]); + mockDevicesFindMany.mockResolvedValue([]); const socket = makeSocket(USER_ID, SENDER_DEVICE); const io = makeIo(); @@ -206,7 +207,7 @@ describe('send_message — sibling device enforcement (#188)', () => { }); it('accepts a message that includes envelopes for all active siblings', async () => { - mockUserDevicesFindMany + mockDevicesFindMany // fetchSiblingDeviceIds call → returns sibling B .mockResolvedValueOnce([{ id: SIBLING_B }]) // envelope fan-out validation call → returns device info for both recipients @@ -235,7 +236,7 @@ describe('send_message — sibling device enforcement (#188)', () => { }); it('rejects with device_set_mismatch when a sibling device is omitted', async () => { - mockUserDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }, { id: SIBLING_C }]); + mockDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }, { id: SIBLING_C }]); const socket = makeSocket(USER_ID, SENDER_DEVICE); const io = makeIo(); @@ -260,7 +261,7 @@ describe('send_message — sibling device enforcement (#188)', () => { }); it('rejects with device_set_mismatch when envelopes are absent but siblings exist', async () => { - mockUserDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }]); + mockDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }]); const socket = makeSocket(USER_ID, SENDER_DEVICE); const io = makeIo(); @@ -278,7 +279,7 @@ describe('send_message — sibling device enforcement (#188)', () => { it('does not require envelopes for revoked sibling devices', async () => { // fetchSiblingDeviceIds only returns non-revoked → empty because the // revokedAt filter excludes the revoked device at the DB level. - mockUserDevicesFindMany.mockResolvedValueOnce([]); // no active siblings + mockDevicesFindMany.mockResolvedValueOnce([]); // no active siblings const socket = makeSocket(USER_ID, SENDER_DEVICE); const io = makeIo(); @@ -293,10 +294,10 @@ describe('send_message — sibling device enforcement (#188)', () => { it('still passes through the idempotency ack without a device set check', async () => { // Idempotency fires BEFORE the sibling check — a duplicate messageId returns // ack immediately, no re-validation of envelopes needed. - mockMessageFindFirst.mockResolvedValue({ sequenceNumber: 7 }); + mockMessageFindFirst.mockResolvedValue({ createdAt: new Date('2024-01-01T00:00:07.000Z') }); // Even with a sibling that would normally require an envelope… - mockUserDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }]); + mockDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }]); const socket = makeSocket(USER_ID, SENDER_DEVICE); const io = makeIo(); @@ -306,7 +307,7 @@ describe('send_message — sibling device enforcement (#188)', () => { expect(socket.emit).toHaveBeenCalledWith('message_ack', { messageId: 'dup-msg', - sequenceNumber: 7, + createdAt: new Date('2024-01-01T00:00:07.000Z'), }); // Sibling check never runs — insert should not be called. expect(mockInsert).not.toHaveBeenCalled(); @@ -314,7 +315,7 @@ describe('send_message — sibling device enforcement (#188)', () => { it('freshly linked sibling causes subsequent sends to fail without its envelope', async () => { // First send: no siblings → succeeds. - mockUserDevicesFindMany.mockResolvedValueOnce([]); + mockDevicesFindMany.mockResolvedValueOnce([]); const socket = makeSocket(USER_ID, SENDER_DEVICE); const io = makeIo(); const { sendMessage } = await getHandlers(socket, io); @@ -326,10 +327,12 @@ describe('send_message — sibling device enforcement (#188)', () => { mockMemberFindFirst.mockResolvedValue(MEMBERSHIP); mockMessageFindFirst.mockResolvedValue(undefined); mockMemberFindMany.mockResolvedValue([]); - mockReturning.mockResolvedValue([{ ...BASE_MESSAGE, id: 'msg-second', sequenceNumber: 3 }]); + mockReturning.mockResolvedValue([ + { ...BASE_MESSAGE, id: 'msg-second', createdAt: new Date('2024-01-01T00:00:03.000Z') }, + ]); // Second send: sibling-B just linked → now appears in DB query → must be included. - mockUserDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }]); + mockDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }]); await sendMessage({ conversationId: CONV_ID, messageId: 'msg-second', ciphertext: 'ok' }); @@ -358,7 +361,7 @@ describe('edit_message — sibling device enforcement (#188)', () => { mockMessageFindFirst .mockResolvedValueOnce(ORIGINAL) // original message lookup .mockResolvedValueOnce(undefined); // idempotency check - mockUserDevicesFindMany + mockDevicesFindMany .mockResolvedValueOnce([{ id: SIBLING_B }]) // fetchSiblingDeviceIds .mockResolvedValueOnce([{ id: SIBLING_B, userId: USER_ID }]); // envelope fanout @@ -382,7 +385,7 @@ describe('edit_message — sibling device enforcement (#188)', () => { it('rejects an edit with device_set_mismatch when a sibling is missing', async () => { mockMessageFindFirst.mockResolvedValueOnce(ORIGINAL).mockResolvedValueOnce(undefined); - mockUserDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }, { id: SIBLING_C }]); + mockDevicesFindMany.mockResolvedValueOnce([{ id: SIBLING_B }, { id: SIBLING_C }]); const socket = makeSocket(USER_ID, SENDER_DEVICE); const io = makeIo(); @@ -407,7 +410,7 @@ describe('edit_message — sibling device enforcement (#188)', () => { it('accepts an edit when the sender has no sibling devices', async () => { mockMessageFindFirst.mockResolvedValueOnce(ORIGINAL).mockResolvedValueOnce(undefined); - mockUserDevicesFindMany.mockResolvedValueOnce([]); // no siblings + mockDevicesFindMany.mockResolvedValueOnce([]); // no siblings const socket = makeSocket(USER_ID, SENDER_DEVICE); const io = makeIo(); diff --git a/apps/backend/src/__tests__/sync.routes.test.ts b/apps/backend/src/__tests__/sync.routes.test.ts index aeb8730..e74c61c 100644 --- a/apps/backend/src/__tests__/sync.routes.test.ts +++ b/apps/backend/src/__tests__/sync.routes.test.ts @@ -11,7 +11,7 @@ const mockUpdate = vi.fn(); vi.mock('../db/index.js', () => ({ db: { query: { - userDevices: { findFirst: mockFindDevice }, + devices: { findFirst: mockFindDevice }, }, select: mockSelect, update: mockUpdate, @@ -29,11 +29,10 @@ vi.mock('../db/schema.js', () => ({ }, messages: { id: 'id', - sequenceNumber: 'sequence_number', conversationId: 'conversation_id', deletedAt: 'deleted_at', }, - userDevices: { + devices: { id: 'id', userId: 'user_id', revokedAt: 'revoked_at', @@ -42,9 +41,9 @@ vi.mock('../db/schema.js', () => ({ vi.mock('drizzle-orm', () => ({ and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), + asc: vi.fn((col: unknown) => ({ type: 'asc', col })), eq: vi.fn((col: unknown, val: unknown) => ({ type: 'eq', col, val })), gt: vi.fn((col: unknown, val: unknown) => ({ type: 'gt', col, val })), - lt: vi.fn((col: unknown, val: unknown) => ({ type: 'lt', col, val })), isNull: vi.fn((col: unknown) => ({ type: 'isNull', col })), or: vi.fn((...args: unknown[]) => ({ type: 'or', args })), inArray: vi.fn((col: unknown, vals: unknown) => ({ type: 'inArray', col, vals })), @@ -71,18 +70,28 @@ function makeApp() { // ── Helpers ────────────────────────────────────────────────────────────────── -function makeEnvelopeRow(seq: number, deliveredAt: Date | null = null) { +// `n` drives both the id and the envelope's createdAt offset, so ordering by +// (createdAt, id) matches ordering by `n` — a stand-in for the old sequenceNumber. +function makeEnvelopeRow(n: number, deliveredAt: Date | null = null) { return { - id: `env-${seq}`, - messageId: `msg-${seq}`, - ciphertext: `cipher-${seq}`, + id: `env-${String(n).padStart(4, '0')}`, + messageId: `msg-${n}`, + ciphertext: `cipher-${n}`, deliveredAt, - createdAt: new Date('2024-01-01'), - sequenceNumber: seq, + envelopeCreatedAt: new Date(2024, 0, 1, 0, 0, n), conversationId: 'conv-1', + senderId: 'user-2', + senderDeviceId: 'device-2', + contentType: 'text/plain', + messageCreatedAt: new Date(2024, 0, 1, 0, 0, n), }; } +function encodeCursor(n: number): string { + const row = makeEnvelopeRow(n); + return `${row.envelopeCreatedAt.getTime()}:${row.id}`; +} + function mockDbQuery(rows: ReturnType[]) { // Chain: db.select().from().innerJoin().where().orderBy().limit() const limitFn = vi.fn().mockResolvedValue(rows); @@ -111,9 +120,10 @@ describe('GET /sync', () => { expect(res.body.error).toMatch(/deviceId/); }); - it('returns 400 when sinceSequence is negative', async () => { - const res = await request(makeApp()).get('/sync?deviceId=e2e-device-1&sinceSequence=-1'); + it('returns 400 when cursor is malformed', async () => { + const res = await request(makeApp()).get('/sync?deviceId=e2e-device-1&cursor=not-a-cursor'); expect(res.status).toBe(400); + expect(res.body.error).toMatch(/cursor/i); }); it('returns 403 when device not owned by user', async () => { @@ -124,51 +134,53 @@ describe('GET /sync', () => { it('returns empty array when queue is empty', async () => { mockDbQuery([]); - const res = await request(makeApp()).get('/sync?deviceId=e2e-device-1&sinceSequence=0'); + const res = await request(makeApp()).get('/sync?deviceId=e2e-device-1'); expect(res.status).toBe(200); expect(res.body.envelopes).toEqual([]); expect(res.body.hasMore).toBe(false); - expect(res.body.nextCursor).toBe(0); + expect(res.body.nextCursor).toBeNull(); }); - it('returns envelopes ordered by sequenceNumber', async () => { + it('returns envelopes ordered by (createdAt, id)', async () => { mockDbQuery([makeEnvelopeRow(1), makeEnvelopeRow(2), makeEnvelopeRow(3)]); - const res = await request(makeApp()).get('/sync?deviceId=e2e-device-1&sinceSequence=0'); + const res = await request(makeApp()).get('/sync?deviceId=e2e-device-1'); expect(res.status).toBe(200); - const seqs = res.body.envelopes.map((e: { sequenceNumber: number }) => e.sequenceNumber); - expect(seqs).toEqual([1, 2, 3]); + const ids = res.body.envelopes.map((e: { id: string }) => e.id); + expect(ids).toEqual(['env-0001', 'env-0002', 'env-0003']); }); - it('returns nextCursor equal to last sequenceNumber', async () => { + it('returns nextCursor encoding the last envelope returned', async () => { mockDbQuery([makeEnvelopeRow(5), makeEnvelopeRow(7)]); - const res = await request(makeApp()).get('/sync?deviceId=e2e-device-1&sinceSequence=4'); + const res = await request(makeApp()).get( + `/sync?deviceId=e2e-device-1&cursor=${encodeCursor(4)}`, + ); expect(res.status).toBe(200); - expect(res.body.nextCursor).toBe(7); + expect(res.body.nextCursor).toBe(encodeCursor(7)); }); it('sets hasMore true when more pages exist', async () => { // Default page size is 50; return 51 rows to trigger hasMore const rows = Array.from({ length: 51 }, (_, i) => makeEnvelopeRow(i + 1)); mockDbQuery(rows); - const res = await request(makeApp()).get('/sync?deviceId=e2e-device-1&sinceSequence=0'); + const res = await request(makeApp()).get('/sync?deviceId=e2e-device-1'); expect(res.status).toBe(200); expect(res.body.hasMore).toBe(true); expect(res.body.envelopes).toHaveLength(50); // page size }); - it('respects sinceSequence cursor for partial sync', async () => { - mockDbQuery([makeEnvelopeRow(11), makeEnvelopeRow(12)]); - const res = await request(makeApp()).get('/sync?deviceId=e2e-device-1&sinceSequence=10'); - expect(res.status).toBe(200); - expect(res.body.envelopes.every((e: { sequenceNumber: number }) => e.sequenceNumber > 10)).toBe( - true, + it('passes the decoded cursor through to the query filter', async () => { + const { whereFn } = mockDbQuery([makeEnvelopeRow(11), makeEnvelopeRow(12)]); + const res = await request(makeApp()).get( + `/sync?deviceId=e2e-device-1&cursor=${encodeCursor(10)}`, ); + expect(res.status).toBe(200); + expect(whereFn).toHaveBeenCalled(); }); - it('includes already-delivered envelopes when cursor requests them', async () => { + it('includes already-delivered envelopes when within TTL', async () => { const delivered = makeEnvelopeRow(3, new Date()); mockDbQuery([delivered]); - const res = await request(makeApp()).get('/sync?deviceId=e2e-device-1&sinceSequence=2'); + const res = await request(makeApp()).get('/sync?deviceId=e2e-device-1'); expect(res.status).toBe(200); expect(res.body.envelopes).toHaveLength(1); }); @@ -182,6 +194,7 @@ describe('GET /sync', () => { expect(env).toHaveProperty('messageId'); expect(env).toHaveProperty('conversationId'); expect(env).toHaveProperty('ciphertext'); - expect(env).toHaveProperty('sequenceNumber'); + expect(env).toHaveProperty('createdAt'); + expect(env).not.toHaveProperty('sequenceNumber'); }); }); diff --git a/apps/backend/src/__tests__/users.bundle.test.ts b/apps/backend/src/__tests__/users.bundle.test.ts new file mode 100644 index 0000000..f07d1ea --- /dev/null +++ b/apps/backend/src/__tests__/users.bundle.test.ts @@ -0,0 +1,188 @@ +/** + * Tests for GET /users/:userId/devices/:deviceId/key-bundle (#110). + * + * Moved here from GET /devices/:id/bundle so the URL matches the issue's + * spec and so a caller can't fetch a bundle without already knowing which + * user owns the device (`:userId` must match `device.userId` or the route + * 404s, same as an unknown device). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockDeviceFindFirst = vi.fn(); +const mockPrekeyFindFirst = vi.fn(); +const mockTransaction = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + devices: { findFirst: mockDeviceFindFirst }, + devicePrekeys: { findFirst: mockPrekeyFindFirst }, + users: { findFirst: vi.fn() }, + wallets: { findFirst: vi.fn() }, + }, + transaction: mockTransaction, + update: vi.fn(), + select: vi.fn(), + }, +})); + +vi.mock('../db/schema.js', () => ({ + users: {}, + wallets: {}, + devices: { id: 'id', userId: 'userId', revokedAt: 'revokedAt' }, + devicePrekeys: { + id: 'id', + deviceId: 'deviceId', + keyType: 'keyType', + keyId: 'keyId', + publicKey: 'publicKey', + consumed: 'consumed', + createdAt: 'createdAt', + }, + conversationMembers: {}, +})); + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((col: unknown, val: unknown) => ({ op: 'eq', col, val })), + and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), + or: vi.fn((...args: unknown[]) => args), + ilike: vi.fn(), + exists: vi.fn(), + isNull: vi.fn((col: unknown) => ({ op: 'isNull', col })), + sql: vi.fn(), +})); + +vi.mock('../lib/redis.js', () => ({ redis: null })); +vi.mock('../services/presence.js', () => ({ isOnline: vi.fn().mockResolvedValue(false) })); +vi.mock('../lib/socket.js', () => ({ getSocketServer: vi.fn(() => null) })); + +vi.mock('../middleware/auth.js', () => ({ + requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + (req as express.Request & { auth: { userId: string } }).auth = { userId: 'caller-id' }; + next(); + }, +})); + +const { usersRouter } = await import('../routes/users.js'); + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use('/users', usersRouter); + return app; +} + +const OWNER_ID = 'owner-user-id'; +const DEVICE = { + id: 'device-2', + userId: OWNER_ID, + identityPublicKey: 'identity-key', + registrationId: 42, + revokedAt: null, +}; + +const SIGNED_PRE_KEY = { keyId: 1, publicKey: 'spk-pub', signature: 'spk-sig' }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('GET /users/:userId/devices/:deviceId/key-bundle', () => { + it('returns 404 when device does not exist', async () => { + mockDeviceFindFirst.mockResolvedValue(undefined); + + const res = await request(makeApp()).get(`/users/${OWNER_ID}/devices/nonexistent/key-bundle`); + + expect(res.status).toBe(404); + }); + + it('returns 404 when :userId does not match the device owner', async () => { + mockDeviceFindFirst.mockResolvedValue(DEVICE); + + const res = await request(makeApp()).get(`/users/someone-else/devices/device-2/key-bundle`); + + expect(res.status).toBe(404); + }); + + it('returns 404 when device is revoked', async () => { + mockDeviceFindFirst.mockResolvedValue({ ...DEVICE, revokedAt: new Date() }); + + const res = await request(makeApp()).get(`/users/${OWNER_ID}/devices/device-2/key-bundle`); + + expect(res.status).toBe(404); + }); + + it('returns 409 when device has no signed prekey', async () => { + mockDeviceFindFirst.mockResolvedValue(DEVICE); + mockPrekeyFindFirst.mockResolvedValue(undefined); + + const res = await request(makeApp()).get(`/users/${OWNER_ID}/devices/device-2/key-bundle`); + + expect(res.status).toBe(409); + }); + + it('returns a full bundle and consumes one OTP atomically', async () => { + mockDeviceFindFirst.mockResolvedValue(DEVICE); + mockPrekeyFindFirst.mockResolvedValue(SIGNED_PRE_KEY); + + const claimed = { id: 'otp-row-1', keyId: 10, publicKey: 'otp-pub' }; + const updateWhere = vi.fn().mockResolvedValue(undefined); + const tx = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + for: vi.fn().mockResolvedValue([claimed]), + }), + }), + }), + }), + }), + update: vi.fn().mockReturnValue({ set: vi.fn().mockReturnValue({ where: updateWhere }) }), + }; + mockTransaction.mockImplementation(async (cb: (txArg: typeof tx) => unknown) => cb(tx)); + + const res = await request(makeApp()).get(`/users/${OWNER_ID}/devices/device-2/key-bundle`); + + expect(res.status).toBe(200); + expect(res.body.deviceId).toBe('device-2'); + expect(res.body.identityPublicKey).toBe('identity-key'); + expect(res.body.registrationId).toBe(42); + expect(res.body.signedPreKey).toEqual(SIGNED_PRE_KEY); + expect(res.body.oneTimePreKey).toEqual({ keyId: 10, publicKey: 'otp-pub' }); + expect(updateWhere).toHaveBeenCalled(); // consumed flipped to true, not deleted + }); + + it('falls back to signed-prekey-only when OTPs are exhausted', async () => { + mockDeviceFindFirst.mockResolvedValue(DEVICE); + mockPrekeyFindFirst.mockResolvedValue(SIGNED_PRE_KEY); + + const tx = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + for: vi.fn().mockResolvedValue([]), + }), + }), + }), + }), + }), + update: vi.fn(), + }; + mockTransaction.mockImplementation(async (cb: (txArg: typeof tx) => unknown) => cb(tx)); + + const res = await request(makeApp()).get(`/users/${OWNER_ID}/devices/device-2/key-bundle`); + + expect(res.status).toBe(200); + expect(res.body.oneTimePreKey).toBeNull(); + expect(tx.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/__tests__/users.fingerprint.test.ts b/apps/backend/src/__tests__/users.fingerprint.test.ts index 2242549..db97362 100644 --- a/apps/backend/src/__tests__/users.fingerprint.test.ts +++ b/apps/backend/src/__tests__/users.fingerprint.test.ts @@ -28,7 +28,8 @@ vi.mock('../db/index.js', () => ({ vi.mock('../db/schema.js', () => ({ users: { id: 'id', username: 'username' }, wallets: {}, - devices: { userId: 'userId', isRevoked: 'isRevoked' }, + devices: { userId: 'userId', revokedAt: 'revokedAt' }, + devicePrekeys: { deviceId: 'deviceId', keyType: 'keyType', consumed: 'consumed' }, })); vi.mock('drizzle-orm', () => ({ @@ -37,6 +38,7 @@ vi.mock('drizzle-orm', () => ({ or: vi.fn((...args: unknown[]) => args), ilike: vi.fn(), exists: vi.fn(), + isNull: vi.fn((col: unknown) => ({ op: 'isNull', col })), sql: vi.fn(), })); @@ -88,7 +90,7 @@ function deriveFingerprint(identityKeys: string[]): string { beforeEach(() => { vi.clearAllMocks(); // Default: authenticated device is active. - mockDeviceFindFirst.mockResolvedValue({ id: 'caller-device', isRevoked: false }); + mockDeviceFindFirst.mockResolvedValue({ id: 'caller-device', revokedAt: null }); }); describe('GET /users/:id/key-fingerprint', () => { diff --git a/apps/backend/src/__tests__/users.test.ts b/apps/backend/src/__tests__/users.test.ts index 85c75cb..dbc5659 100644 --- a/apps/backend/src/__tests__/users.test.ts +++ b/apps/backend/src/__tests__/users.test.ts @@ -57,7 +57,7 @@ const MOCK_CREATED_AT = new Date('2026-05-31T12:00:00.000Z'); beforeEach(() => { vi.clearAllMocks(); // Default: device is active; individual tests that need 401 from device checks can override. - mockDeviceFindFirst.mockResolvedValue({ id: 'device-test-id', isRevoked: false }); + mockDeviceFindFirst.mockResolvedValue({ id: 'device-test-id', revokedAt: null }); }); describe('GET /users/me', () => { @@ -367,6 +367,14 @@ describe('GET /users/:id/presence', () => { id: 'user-uuid-123', presenceVisible: true, } as any); + // requireAuth's own device lookup succeeds; deriveDevicePresence's two + // subsequent lookups (active-device check, then most-recent fallback) + // both find nothing, since devices/deriveDevicePresence now share the + // same `devices` table/mock as requireAuth (#7 consolidation). + mockDeviceFindFirst + .mockReset() + .mockResolvedValueOnce({ id: 'device-test-id', revokedAt: null }) + .mockResolvedValue(undefined); const res = await request(app) .get('/users/user-uuid-123/presence') diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 811ddcc..46e079c 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -8,8 +8,8 @@ import { index, integer, uniqueIndex, + check, type AnyPgColumn, - bigint, } from 'drizzle-orm/pg-core'; import { relations, sql } from 'drizzle-orm'; @@ -43,7 +43,6 @@ export const conversations = pgTable('conversations', { type: conversationTypeEnum('type').notNull().default('dm'), name: text('name'), avatarUrl: text('avatar_url'), - lastSequenceNumber: bigint('last_sequence_number', { mode: 'bigint' }).notNull().default(0n), createdAt: timestamp('created_at').notNull().defaultNow(), }); @@ -105,27 +104,36 @@ export const files = pgTable('files', { createdAt: timestamp('created_at').notNull().defaultNow(), }); -export const messages = pgTable('messages', { - id: uuid('id').primaryKey().defaultRandom(), - conversationId: uuid('conversation_id') - .notNull() - .references(() => conversations.id, { onDelete: 'cascade' }), - senderId: uuid('sender_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - senderDeviceId: uuid('sender_device_id').references(() => userDevices.id, { - onDelete: 'set null', - }), - contentType: text('content_type').notNull().default('text/plain'), - sequenceNumber: bigint('sequence_number', { mode: 'bigint' }), - ciphertext: text('ciphertext'), - fileId: uuid('file_id').references(() => files.id, { onDelete: 'set null' }), - editsMessageId: uuid('edits_message_id').references((): AnyPgColumn => messages.id, { - onDelete: 'set null', - }), - createdAt: timestamp('created_at').notNull().defaultNow(), - deletedAt: timestamp('deleted_at'), -}); +// Ordering is by (createdAt, id) — a monotonic per-conversation sequence +// counter was considered (and briefly implemented) but dropped: the same +// counter can't also serve as a coherent cross-conversation cursor for +// offline sync (#137), and maintaining two separate counters for two +// separate orderings was judged not worth the complexity. `id` (uuid) is +// only a tiebreaker for same-millisecond inserts, not a chronological signal. +export const messages = pgTable( + 'messages', + { + id: uuid('id').primaryKey().defaultRandom(), + conversationId: uuid('conversation_id') + .notNull() + .references(() => conversations.id, { onDelete: 'cascade' }), + senderId: uuid('sender_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + senderDeviceId: uuid('sender_device_id').references(() => devices.id, { + onDelete: 'set null', + }), + contentType: text('content_type').notNull().default('text'), + ciphertext: text('ciphertext'), + fileId: uuid('file_id').references(() => files.id, { onDelete: 'set null' }), + editsMessageId: uuid('edits_message_id').references((): AnyPgColumn => messages.id, { + onDelete: 'set null', + }), + createdAt: timestamp('created_at').notNull().defaultNow(), + deletedAt: timestamp('deleted_at'), + }, + (table) => [index('messages_conversation_created_idx').on(table.conversationId, table.createdAt)], +); export const messageEnvelopes = pgTable( 'message_envelopes', @@ -136,7 +144,7 @@ export const messageEnvelopes = pgTable( .references(() => messages.id, { onDelete: 'cascade' }), recipientDeviceId: uuid('recipient_device_id') .notNull() - .references(() => userDevices.id, { onDelete: 'cascade' }), + .references(() => devices.id, { onDelete: 'cascade' }), recipientUserId: uuid('recipient_user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), @@ -151,12 +159,16 @@ export const messageEnvelopes = pgTable( ], ); -// ─── Devices & prekeys (issues #158, #159, #162) ───────────────────────────── +// ─── Devices & prekeys (issues #103, #104, #158, #159, #162) ───────────────── // // Each user may register multiple devices. Each device has an Ed25519 identity // key pair; the public key is stored here for fingerprint derivation and prekey -// signature validation. `isRevoked` lets the server reject stale devices -// without deleting the row (preserving audit history). +// signature validation. This is the single canonical device registry — it also +// carries the fields the realtime/messaging/push layers need (`lastSeenAt`, +// `pushEnabled`), so `messages.senderDeviceId`, `messageEnvelopes.recipientDeviceId`, +// and `pushSubscriptions.deviceId` all FK here. `revokedAt` lets the server +// reject stale devices without deleting the row (preserving audit history) and +// records *when* revocation happened, unlike a plain boolean. export const devicePlatformEnum = pgEnum('device_platform', ['web', 'ios', 'android']); @@ -174,46 +186,64 @@ export const devices = pgTable( deviceName: text('device_name'), platform: devicePlatformEnum('platform'), lastSeenAt: timestamp('last_seen_at'), - isRevoked: boolean('is_revoked').notNull().default(false), + pushEnabled: boolean('push_enabled').notNull().default(true), + revokedAt: timestamp('revoked_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, - (table) => [uniqueIndex('devices_user_identity_idx').on(table.userId, table.identityPublicKey)], + (table) => [ + uniqueIndex('devices_user_identity_idx').on(table.userId, table.identityPublicKey), + index('devices_user_id_active_idx') + .on(table.userId) + .where(sql`${table.revokedAt} IS NULL`), + ], ); -// One signed prekey per device (upserted on upload). -export const signedPreKeys = pgTable( - 'signed_pre_keys', +// Signed + one-time prekeys in a single table, discriminated by `keyType` +// (#104). One active signed prekey per device (enforced by the partial unique +// index below, replaced on upload); one-time prekeys are consumed at most +// once — `consumed` flips to true atomically instead of deleting the row, so +// bundle-fetch history stays auditable. +export const prekeyTypeEnum = pgEnum('prekey_type', ['signed', 'one_time']); + +export const devicePrekeys = pgTable( + 'device_prekeys', { id: uuid('id').primaryKey().defaultRandom(), deviceId: uuid('device_id') .notNull() .references(() => devices.id, { onDelete: 'cascade' }), - // Application-assigned integer key-id (unique per device). + keyType: prekeyTypeEnum('key_type').notNull(), + // Application-assigned integer key-id (unique per device + type). keyId: integer('key_id').notNull(), // Base64-encoded public key. publicKey: text('public_key').notNull(), // Base64-encoded Ed25519 signature over publicKey, signed by identityPublicKey. - signature: text('signature').notNull(), - createdAt: timestamp('created_at').notNull().defaultNow(), - }, - // Only one signed prekey per device at a time — upsert on this unique constraint. - (table) => [uniqueIndex('spk_device_idx').on(table.deviceId)], -); - -// One-time prekeys — each consumed at most once. -export const oneTimePreKeys = pgTable( - 'one_time_pre_keys', - { - id: uuid('id').primaryKey().defaultRandom(), - deviceId: uuid('device_id') - .notNull() - .references(() => devices.id, { onDelete: 'cascade' }), - keyId: integer('key_id').notNull(), - publicKey: text('public_key').notNull(), + // Required when keyType='signed' — enforced below by a DB check constraint. + signature: text('signature'), + consumed: boolean('consumed').notNull().default(false), createdAt: timestamp('created_at').notNull().defaultNow(), }, - (table) => [uniqueIndex('otp_device_keyid_idx').on(table.deviceId, table.keyId)], + (table) => [ + uniqueIndex('device_prekeys_device_type_keyid_idx').on( + table.deviceId, + table.keyType, + table.keyId, + ), + // Enforces "one active signed prekey per device" at the DB level; the + // upload endpoint upserts against this as its ON CONFLICT target. + uniqueIndex('device_prekeys_signed_device_idx') + .on(table.deviceId) + .where(sql`${table.keyType} = 'signed'`), + // Fast bundle assembly: unconsumed one-time prekeys per device. + index('device_prekeys_one_time_available_idx') + .on(table.deviceId) + .where(sql`${table.keyType} = 'one_time' AND ${table.consumed} = false`), + check( + 'device_prekeys_signed_requires_signature', + sql`${table.keyType} <> 'signed' OR ${table.signature} IS NOT NULL`, + ), + ], ); // ─── Token transfers (#46) ──────────────────────────────────────────────────── @@ -238,40 +268,6 @@ export const tokenTransfers = pgTable('token_transfers', { createdAt: timestamp('created_at').notNull().defaultNow(), }); -// ─── User devices (#153) ────────────────────────────────────────────────────── -// -// Device identity registry for end-to-end encryption. Each row is one device a -// user has registered, holding its long-term identity public key. A device is -// never hard-deleted — revoking sets `revokedAt` so historical sessions stay -// auditable. `(userId, deviceId)` is unique so a client re-registering the same -// device upserts instead of duplicating, and the partial index keeps lookups of -// a user's *active* devices fast. - -export const userDevices = pgTable( - 'user_devices', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - deviceId: text('device_id').notNull(), - deviceName: text('device_name').notNull(), - platform: devicePlatformEnum('platform').notNull(), - identityPublicKey: text('identity_public_key').notNull(), - registrationId: integer('registration_id'), - lastSeenAt: timestamp('last_seen_at'), - pushEnabled: boolean('push_enabled').notNull().default(true), - revokedAt: timestamp('revoked_at'), - createdAt: timestamp('created_at').notNull().defaultNow(), - }, - (table) => [ - uniqueIndex('user_devices_user_id_device_id_unique').on(table.userId, table.deviceId), - index('user_devices_user_id_active_idx') - .on(table.userId) - .where(sql`${table.revokedAt} IS NULL`), - ], -); - // ─── Treasury Proposals (#130) ──────────────────────────────────────────────── // // Synced from GROUP_TREASURY_CONTRACT_ID events by the Stellar listener. @@ -339,7 +335,7 @@ export const pushSubscriptions = pgTable('push_subscriptions', { id: uuid('id').primaryKey().defaultRandom(), deviceId: uuid('device_id') .notNull() - .references(() => userDevices.id, { onDelete: 'cascade' }), + .references(() => devices.id, { onDelete: 'cascade' }), endpoint: text('endpoint').notNull().unique(), p256dh: text('p256dh').notNull(), auth: text('auth').notNull(), @@ -401,9 +397,9 @@ export const messagesRelations = relations(messages, ({ one, many }) => ({ references: [conversations.id], }), sender: one(users, { fields: [messages.senderId], references: [users.id] }), - senderDevice: one(userDevices, { + senderDevice: one(devices, { fields: [messages.senderDeviceId], - references: [userDevices.id], + references: [devices.id], }), file: one(files, { fields: [messages.fileId], references: [files.id] }), envelopes: many(messageEnvelopes), @@ -417,9 +413,9 @@ export const messagesRelations = relations(messages, ({ one, many }) => ({ export const messageEnvelopesRelations = relations(messageEnvelopes, ({ one }) => ({ message: one(messages, { fields: [messageEnvelopes.messageId], references: [messages.id] }), - recipientDevice: one(userDevices, { + recipientDevice: one(devices, { fields: [messageEnvelopes.recipientDeviceId], - references: [userDevices.id], + references: [devices.id], }), recipientUser: one(users, { fields: [messageEnvelopes.recipientUserId], references: [users.id] }), })); @@ -437,26 +433,17 @@ export const tokenTransfersRelations = relations(tokenTransfers, ({ one }) => ({ export const devicesRelations = relations(devices, ({ one, many }) => ({ user: one(users, { fields: [devices.userId], references: [users.id] }), - signedPreKey: many(signedPreKeys), - oneTimePreKeys: many(oneTimePreKeys), -})); - -export const signedPreKeysRelations = relations(signedPreKeys, ({ one }) => ({ - device: one(devices, { fields: [signedPreKeys.deviceId], references: [devices.id] }), -})); - -export const oneTimePreKeysRelations = relations(oneTimePreKeys, ({ one }) => ({ - device: one(devices, { fields: [oneTimePreKeys.deviceId], references: [devices.id] }), -})); - -export const userDevicesRelations = relations(userDevices, ({ one, many }) => ({ - user: one(users, { fields: [userDevices.userId], references: [users.id] }), + prekeys: many(devicePrekeys), messages: many(messages), pushSubscriptions: many(pushSubscriptions), })); +export const devicePrekeysRelations = relations(devicePrekeys, ({ one }) => ({ + device: one(devices, { fields: [devicePrekeys.deviceId], references: [devices.id] }), +})); + export const pushSubscriptionsRelations = relations(pushSubscriptions, ({ one }) => ({ - device: one(userDevices, { fields: [pushSubscriptions.deviceId], references: [userDevices.id] }), + device: one(devices, { fields: [pushSubscriptions.deviceId], references: [devices.id] }), })); export const treasuryProposalsRelations = relations(treasuryProposals, ({ one, many }) => ({ @@ -494,9 +481,5 @@ export type TokenTransfer = typeof tokenTransfers.$inferSelect; export type NewTokenTransfer = typeof tokenTransfers.$inferInsert; export type Device = typeof devices.$inferSelect; export type NewDevice = typeof devices.$inferInsert; -export type SignedPreKey = typeof signedPreKeys.$inferSelect; -export type NewSignedPreKey = typeof signedPreKeys.$inferInsert; -export type OneTimePreKey = typeof oneTimePreKeys.$inferSelect; -export type NewOneTimePreKey = typeof oneTimePreKeys.$inferInsert; -export type UserDevice = typeof userDevices.$inferSelect; -export type NewUserDevice = typeof userDevices.$inferInsert; +export type DevicePrekey = typeof devicePrekeys.$inferSelect; +export type NewDevicePrekey = typeof devicePrekeys.$inferInsert; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 9e713a7..d2dedf8 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -5,7 +5,7 @@ import { createClient } from 'redis'; import dotenv from 'dotenv'; import { eq, isNull, and, inArray } from 'drizzle-orm'; import { db } from './db/index.js'; -import { conversationMembers, users, userDevices } from './db/schema.js'; +import { conversationMembers, users, devices } from './db/schema.js'; import { publishEphemeral } from './services/resumeStream.js'; import { socketAuthMiddleware, type AuthSocket } from './middleware/socketAuth.js'; import { registerMessagingHandlers } from './socket/messaging.js'; @@ -44,7 +44,7 @@ import { } from './services/stellarListener.js'; import { startFileCleanupJob } from './services/fileCleanup.js'; import { loadEnv } from './config.js'; -import { createObjectStore } from './lib/objectStore.js'; +import { getObjectStore } from './lib/objectStore.js'; import { conversationRoom, joinUserRoom, @@ -55,8 +55,8 @@ dotenv.config(); // Validate required environment variables at boot. Exits with code 1 and // logs the offending vars if anything is missing or malformed. -const env = loadEnv(); -export const objectStore = createObjectStore(env); +loadEnv(); +export const objectStore = getObjectStore(); const httpServer = createServer(app); const io = new Server(httpServer, { @@ -115,7 +115,6 @@ io.use(socketAuthMiddleware); io.on('connection', async (socket: AuthSocket) => { const userId = socket.auth!.userId; const deviceId = socket.auth!.deviceId; - const identityPublicKey = socket.identityPublicKey; console.log('User connected:', userId, socket.id); socket.data['userId'] = userId; @@ -125,24 +124,16 @@ io.on('connection', async (socket: AuthSocket) => { registerDeviceSocket(deviceId, socket.id); // Start the server-side heartbeat watchdog (90 s timeout). - startHeartbeatTimer(socket, userId, deviceId, appRedis, io, identityPublicKey); + startHeartbeatTimer(socket, userId, deviceId, appRedis, io); - // Update user_devices.lastSeenAt for device-based presence derivation. - if (identityPublicKey) { - try { - await db - .update(userDevices) - .set({ lastSeenAt: new Date() }) - .where( - and( - eq(userDevices.userId, userId), - eq(userDevices.identityPublicKey, identityPublicKey), - isNull(userDevices.revokedAt), - ), - ); - } catch { - // Non-critical update; ignore errors. - } + // Update devices.lastSeenAt for device-based presence derivation. + try { + await db + .update(devices) + .set({ lastSeenAt: new Date() }) + .where(and(eq(devices.id, deviceId), isNull(devices.revokedAt))); + } catch { + // Non-critical update; ignore errors. } // Per-socket middleware: intercept every incoming event before handlers. @@ -282,22 +273,14 @@ io.on('connection', async (socket: AuthSocket) => { unregisterForBackpressure(socket); clearViolations(socket.id); - // Update user_devices.lastSeenAt on disconnect. - if (identityPublicKey) { - try { - await db - .update(userDevices) - .set({ lastSeenAt: new Date() }) - .where( - and( - eq(userDevices.userId, userId), - eq(userDevices.identityPublicKey, identityPublicKey), - isNull(userDevices.revokedAt), - ), - ); - } catch { - // Non-critical update; ignore errors. - } + // Update devices.lastSeenAt on disconnect. + try { + await db + .update(devices) + .set({ lastSeenAt: new Date() }) + .where(and(eq(devices.id, deviceId), isNull(devices.revokedAt))); + } catch { + // Non-critical update; ignore errors. } // During a gateway restart we must NOT wipe presence — surviving devices // re-assert via heartbeat and Redis TTLs. diff --git a/apps/backend/src/lib/messages.ts b/apps/backend/src/lib/messages.ts index b44a18a..4a5bdb8 100644 --- a/apps/backend/src/lib/messages.ts +++ b/apps/backend/src/lib/messages.ts @@ -3,7 +3,6 @@ export type MessageLike = { senderId: string; senderDeviceId?: string | null; contentType: string; - sequenceNumber: number; createdAt: Date; ciphertext?: string | null; deletedAt?: Date | null; diff --git a/apps/backend/src/lib/objectStore.ts b/apps/backend/src/lib/objectStore.ts index a7e254c..dae0ca2 100644 --- a/apps/backend/src/lib/objectStore.ts +++ b/apps/backend/src/lib/objectStore.ts @@ -6,7 +6,8 @@ import { S3Client, type PutObjectCommandInput, } from '@aws-sdk/client-s3'; -import type { Env } from '../config.js'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { loadEnv, type Env } from '../config.js'; export type ObjectStoreConfig = Pick< Env, @@ -77,8 +78,39 @@ export class ObjectStore { }), ); } + + /** Real, cryptographically-signed PUT URL — only ever called in production (#166). */ + async getPresignedPutUrl(key: string, contentType: string | undefined, ttlSeconds: number) { + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + ...(contentType ? { ContentType: contentType } : {}), + }); + return getSignedUrl(this.client, command, { expiresIn: ttlSeconds }); + } + + /** Real, cryptographically-signed GET URL — only ever called in production (#166). */ + async getPresignedGetUrl(key: string, ttlSeconds: number) { + const command = new GetObjectCommand({ Bucket: this.bucket, Key: key }); + return getSignedUrl(this.client, command, { expiresIn: ttlSeconds }); + } } export function createObjectStore(config: ObjectStoreConfig): ObjectStore { return new ObjectStore(createObjectStoreClient(config), config.OBJECT_STORE_BUCKET); } + +// Lazily-constructed, process-wide singleton. `lib/storage.ts` and +// `services/fileCleanup.ts` both need the same configured client/bucket — +// building it here (rather than in `index.ts`) avoids a circular import +// (index.ts -> app.ts -> routes -> lib/storage.ts -> index.ts) and ensures +// there's exactly one S3 client/bucket pair for the whole process (#161/#168 +// previously had three independent, inconsistently-configured ones). +let singleton: ObjectStore | null = null; + +export function getObjectStore(): ObjectStore { + if (!singleton) { + singleton = createObjectStore(loadEnv()); + } + return singleton; +} diff --git a/apps/backend/src/lib/storage.ts b/apps/backend/src/lib/storage.ts index 8ded860..348c92a 100644 --- a/apps/backend/src/lib/storage.ts +++ b/apps/backend/src/lib/storage.ts @@ -1,22 +1,38 @@ import { createHash, randomUUID } from 'node:crypto'; +import { getObjectStore } from './objectStore.js'; const PRESIGNED_TTL_SECONDS = 900; // 15 minutes -// In production this would call S3/GCS SDK to generate a real presigned URL. -// The indirection keeps the route logic testable without cloud credentials. -export async function generatePresignedPut(storageKey: string, _mimeType: string): Promise { +function isProduction(): boolean { + return process.env['NODE_ENV'] === 'production'; +} + +// Development/test fallback: a structurally-plausible but fake URL, never +// backed by a network call. Keeps local dev and CI from needing a reachable +// S3/MinIO endpoint just to exercise the upload/download routes (same "no +// external services in tests" rule this project applies to Redis/Postgres). +// Production always returns a real, cryptographically-signed URL (#166). +function fakeUrl(storageKey: string, ttlSeconds: number): string { const base = process.env['STORAGE_ENDPOINT'] ?? 'https://storage.example.com'; - const expires = Math.floor(Date.now() / 1000) + PRESIGNED_TTL_SECONDS; + const expires = Math.floor(Date.now() / 1000) + ttlSeconds; return `${base}/${storageKey}?X-Expires=${expires}`; } +export async function generatePresignedPut(storageKey: string, mimeType: string): Promise { + if (!isProduction()) { + return fakeUrl(storageKey, PRESIGNED_TTL_SECONDS); + } + return getObjectStore().getPresignedPutUrl(storageKey, mimeType, PRESIGNED_TTL_SECONDS); +} + export async function generatePresignedGet( storageKey: string, ttlSeconds: number = PRESIGNED_TTL_SECONDS, ): Promise { - const base = process.env['STORAGE_ENDPOINT'] ?? 'https://storage.example.com'; - const expires = Math.floor(Date.now() / 1000) + ttlSeconds; - return `${base}/${storageKey}?X-Expires=${expires}`; + if (!isProduction()) { + return fakeUrl(storageKey, ttlSeconds); + } + return getObjectStore().getPresignedGetUrl(storageKey, ttlSeconds); } export function generateStorageKey(conversationId: string, sha256: string): string { diff --git a/apps/backend/src/middleware/auth.ts b/apps/backend/src/middleware/auth.ts index ec71ba3..1af3042 100644 --- a/apps/backend/src/middleware/auth.ts +++ b/apps/backend/src/middleware/auth.ts @@ -40,7 +40,7 @@ export async function requireAuth( where: and(eq(devices.id, payload.deviceId), eq(devices.userId, payload.userId)), }); - if (!device || device.isRevoked) { + if (!device || device.revokedAt) { res.status(401).json({ error: 'Device not found or has been revoked' }); return; } diff --git a/apps/backend/src/middleware/socketAuth.ts b/apps/backend/src/middleware/socketAuth.ts index 99c5e2e..829ce7c 100644 --- a/apps/backend/src/middleware/socketAuth.ts +++ b/apps/backend/src/middleware/socketAuth.ts @@ -35,7 +35,7 @@ export async function socketAuthMiddleware( where: and(eq(devices.id, payload.deviceId), eq(devices.userId, payload.userId)), }); - if (!device || device.isRevoked) { + if (!device || device.revokedAt) { next(new Error('Device not found or has been revoked')); return; } diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index efcbe67..a31e07d 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -4,7 +4,7 @@ import type { Request, Response, IRouter } from 'express'; import rateLimit, { type RateLimitRequestHandler } from 'express-rate-limit'; import { Keypair } from '@stellar/stellar-sdk'; import { db } from '../db/index.js'; -import { users, wallets, devices, userDevices } from '../db/schema.js'; +import { users, wallets, devices } from '../db/schema.js'; import { eq, and } from 'drizzle-orm'; import { createNonce, consumeNonce } from '../lib/nonce.js'; import { signToken } from '../lib/jwt.js'; @@ -58,7 +58,6 @@ authRouter.post( validate(VerifySchema), async (req: Request, res: Response) => { const { walletAddress, signature, nonce, identityPublicKey, device } = req.body as VerifyBody; - const clientDeviceId = device?.deviceId; const deviceName = device?.deviceName; const platform = device?.platform; const registrationId = device?.registrationId; @@ -122,7 +121,7 @@ authRouter.post( }); if (existingDevice) { - if (existingDevice.isRevoked) { + if (existingDevice.revokedAt) { res.status(401).json({ error: 'Device has been revoked' }); return; } @@ -155,42 +154,7 @@ authRouter.post( deviceId = newDevice.id; } - let tokenDeviceId = deviceId; - - if (clientDeviceId) { - const [userDevice] = await db - .insert(userDevices) - .values({ - userId, - deviceId: clientDeviceId, - deviceName: deviceName ?? 'Web browser', - platform: platform ?? 'web', - identityPublicKey, - registrationId: registrationId ? Number(registrationId) : null, - lastSeenAt: new Date(), - }) - .onConflictDoUpdate({ - target: [userDevices.userId, userDevices.deviceId], - set: { - deviceName: deviceName ?? 'Web browser', - platform: platform ?? 'web', - identityPublicKey, - registrationId: registrationId ? Number(registrationId) : null, - lastSeenAt: new Date(), - revokedAt: null, - }, - }) - .returning({ id: userDevices.id }); - - if (!userDevice) { - res.status(500).json({ error: 'Failed to register messaging device' }); - return; - } - - tokenDeviceId = userDevice.id; - } - - const token = signToken({ userId, walletAddress, deviceId: tokenDeviceId }); - res.json({ token, deviceId: tokenDeviceId }); + const token = signToken({ userId, walletAddress, deviceId }); + res.json({ token, deviceId }); }, ); diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index 10ef191..1d758f6 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import type { IRouter } from 'express'; -import { asc, and, count, desc, eq, inArray, lt, sql, ne } from 'drizzle-orm'; +import { asc, and, count, desc, eq, inArray, lt, or, sql, ne } from 'drizzle-orm'; import { db } from '../db/index.js'; import { conversationMembers, @@ -8,7 +8,7 @@ import { messages, tokenTransfers, messageEnvelopes, - userDevices, + devices, } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { redis, CONV_CACHE_TTL, convCacheKey } from '../lib/redis.js'; @@ -471,25 +471,34 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => { return; } - // Resolve cursor: look up the `createdAt` of the "before" message - let cursor: Date | undefined; + // Resolve cursor: look up the `(createdAt, id)` of the "before" message. + // `id` breaks ties for same-millisecond inserts — createdAt alone can + // silently skip or duplicate rows across pages under concurrent writes. + let cursor: { createdAt: Date; id: string } | undefined; if (before) { const ref = await db.query.messages.findFirst({ where: eq(messages.id, before), + columns: { createdAt: true, id: true }, }); if (!ref) { res.status(400).json({ error: 'Invalid cursor' }); return; } - cursor = ref.createdAt; + cursor = ref; } // Fetch one extra to determine whether there is a next page const rows = await db.query.messages.findMany({ where: cursor - ? and(eq(messages.conversationId, conversationId), lt(messages.createdAt, cursor)) + ? and( + eq(messages.conversationId, conversationId), + or( + lt(messages.createdAt, cursor.createdAt), + and(eq(messages.createdAt, cursor.createdAt), lt(messages.id, cursor.id)), + ), + ) : eq(messages.conversationId, conversationId), - orderBy: desc(messages.createdAt), + orderBy: [desc(messages.createdAt), desc(messages.id)], limit: limit + 1, with: { sender: { columns: { id: true, username: true, avatarUrl: true } }, @@ -777,11 +786,11 @@ conversationsRouter.get('/:id/devices', async (req: AuthRequest, res) => { } // Fetch all active (non-revoked) devices for every member - const deviceRows = await db.query.userDevices.findMany({ + const deviceRows = await db.query.devices.findMany({ where: and( - inArray(userDevices.userId, userIds), + inArray(devices.userId, userIds), // revokedAt IS NULL → active devices only - sql`${userDevices.revokedAt} IS NULL`, + sql`${devices.revokedAt} IS NULL`, ), columns: { id: true, diff --git a/apps/backend/src/routes/devices.ts b/apps/backend/src/routes/devices.ts index 3c3e1f7..8686b7b 100644 --- a/apps/backend/src/routes/devices.ts +++ b/apps/backend/src/routes/devices.ts @@ -1,24 +1,26 @@ /** - * Device routes — prekey management. + * Device routes — registration, listing, revocation, and prekey management. * - * Issue #159: POST /devices/:id/prekeys - * Uploads a signed prekey + batch of one-time prekeys for a device. - * Only the device owner may call this endpoint. + * `devices` is the single canonical device registry (see #103/#107 — a + * previously-separate `user_devices` table was merged into this one so the + * realtime/messaging/push layers and the auth/prekey layer share one source + * of truth instead of two tables that could silently diverge). */ import { Router, type Router as RouterType } from 'express'; -import { eq, and, ne, count, desc, sql } from 'drizzle-orm'; +import { eq, and, ne, count, desc, isNull, inArray, sql } from 'drizzle-orm'; import { z } from 'zod'; import { db } from '../db/index.js'; -import { devices, signedPreKeys, oneTimePreKeys } from '../db/schema.js'; +import { devices, devicePrekeys, conversationMembers, messages } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; -import { userDevices, conversationMembers, messages, conversations } from '../db/schema.js'; import { DeviceSchema } from '../schemas/auth.schemas.js'; import { getSocketServer } from '../lib/socket.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { SignedPreKeyEntrySchema, PreKeyEntrySchema, verifyEd25519Signature } from '../lib/keys.js'; import { conversationRoom } from '../services/roomManager.js'; +import { redis } from '../lib/redis.js'; +import { markDeviceRevoked } from '../services/deviceRevocation.js'; export const devicesRouter: RouterType = Router(); @@ -38,9 +40,6 @@ const RegisterDeviceSchema = DeviceSchema; /** Maximum number of stored one-time prekeys per device. */ const OTP_CAP = 200; -// ─── Helpers ───────────────────────────────────────────────────────────────── -// Signature verification delegated to shared verifyEd25519Signature in src/lib/keys.ts. - // ─── GET /devices ───────────────────────────────────────────────────────────── devicesRouter.get('/', async (req: AuthRequest, res) => { @@ -50,11 +49,30 @@ devicesRouter.get('/', async (req: AuthRequest, res) => { const rows = await db.query.devices.findMany({ where: eq(devices.userId, userId), orderBy: [ - sql`case when ${devices.isRevoked} = false then 0 else 1 end`, + sql`case when ${devices.revokedAt} is null then 0 else 1 end`, desc(devices.createdAt), ], }); + const otpCounts = + rows.length === 0 + ? [] + : await db + .select({ deviceId: devicePrekeys.deviceId, remaining: count() }) + .from(devicePrekeys) + .where( + and( + eq(devicePrekeys.keyType, 'one_time'), + eq(devicePrekeys.consumed, false), + inArray( + devicePrekeys.deviceId, + rows.map((d) => d.id), + ), + ), + ) + .groupBy(devicePrekeys.deviceId); + const remainingByDevice = new Map(otpCounts.map((r) => [r.deviceId, r.remaining])); + res.json( rows.map((device) => ({ id: device.id, @@ -62,7 +80,8 @@ devicesRouter.get('/', async (req: AuthRequest, res) => { deviceName: device.deviceName, platform: device.platform, lastSeenAt: device.lastSeenAt, - isRevoked: device.isRevoked, + revokedAt: device.revokedAt, + oneTimePreKeysRemaining: remainingByDevice.get(device.id) ?? 0, createdAt: device.createdAt, current: device.id === currentDeviceId, })), @@ -72,10 +91,40 @@ devicesRouter.get('/', async (req: AuthRequest, res) => { } }); +// ─── Revocation helpers ───────────────────────────────────────────────────── +// Shared by DELETE /:id and POST /logout-everywhere so both revocation paths +// get identical side effects: prekeys cleared, live sockets disconnected via +// the device_revoked:* pub/sub channel (#146), and a key-change system event. + +async function revokeDeviceRow(deviceId: string): Promise { + const revokedAt = new Date(); + + await db.update(devices).set({ revokedAt, updatedAt: revokedAt }).where(eq(devices.id, deviceId)); + await db.delete(devicePrekeys).where(eq(devicePrekeys.deviceId, deviceId)); + + // Force-disconnect any live socket for this device, on this node or any + // other (cross-node via Redis pub/sub — see services/deviceRevocation.ts). + markDeviceRevoked(deviceId); + if (redis) { + await redis.publish(`device_revoked:${deviceId}`, '1').catch(() => {}); + } + + return revokedAt; +} + +/** Count of the caller's currently-active (non-revoked) devices. */ +async function countActiveDevices(userId: string): Promise { + const [row] = await db + .select({ total: count() }) + .from(devices) + .where(and(eq(devices.userId, userId), isNull(devices.revokedAt))); + return row?.total ?? 0; +} + // ─── DELETE /devices/:id ──────────────────────────────────────────────────── -// Revoke a single device (issue #302). Idempotent — revoking an already -// revoked device just confirms the current state instead of erroring, since -// a client racing two revoke clicks shouldn't see a failure. +// Revoke a single device. Idempotent — revoking an already revoked device +// just confirms the current state instead of erroring. Refuses to revoke a +// caller's last remaining active device (there would be no way back in). devicesRouter.delete('/:id', async (req: AuthRequest, res) => { const deviceId = req.params['id'] as string; @@ -90,94 +139,49 @@ devicesRouter.delete('/:id', async (req: AuthRequest, res) => { return; } - await db - .update(devices) - .set({ isRevoked: true, updatedAt: new Date() }) - .where(eq(devices.id, deviceId)); + if (device.revokedAt) { + res.json({ id: deviceId, revokedAt: device.revokedAt.toISOString() }); + return; + } + + const activeCount = await countActiveDevices(callerId); + if (activeCount <= 1) { + res.status(409).json({ error: 'Cannot revoke your only active device' }); + return; + } + + const revokedAt = await revokeDeviceRow(deviceId); + void emitDeviceChangeEvent(callerId, 'device_revoked'); - res.json({ id: deviceId, isRevoked: true }); + res.json({ id: deviceId, revokedAt: revokedAt.toISOString() }); }); // ─── POST /devices/logout-everywhere ─────────────────────────────────────── -// Revokes every device on the account except the one making the request -// (issue #302). Mirrors a "log out everywhere" security action. +// Revokes every device on the account except the one making the request. +// Mirrors a "log out everywhere" security action — same side effects as a +// single DELETE, applied to every other active device. devicesRouter.post('/logout-everywhere', async (req: AuthRequest, res) => { const { userId, deviceId: currentDeviceId } = req.auth!; - const revoked = await db - .update(devices) - .set({ isRevoked: true, updatedAt: new Date() }) - .where( - and( - eq(devices.userId, userId), - ne(devices.id, currentDeviceId), - eq(devices.isRevoked, false), - ), - ) - .returning({ id: devices.id }); - - res.json({ revokedCount: revoked.length }); -}); - -// ─── GET /devices/:id/bundle ──────────────────────────────────────────────── -// X3DH prekey bundle (issue #305): identity key + signed prekey + one -// one-time prekey, atomically claimed so it is never handed out twice. Falls -// back to a signed-prekey-only bundle once one-time prekeys are exhausted — -// the initiator just runs 3-DH instead of 4-DH in that case. - -devicesRouter.get('/:id/bundle', async (req: AuthRequest, res) => { - const deviceId = req.params['id'] as string; - - const device = await db.query.devices.findFirst({ - where: eq(devices.id, deviceId), + const toRevoke = await db.query.devices.findMany({ + where: and( + eq(devices.userId, userId), + ne(devices.id, currentDeviceId), + isNull(devices.revokedAt), + ), + columns: { id: true }, }); - if (!device || device.isRevoked) { - res.status(404).json({ error: 'Device not found or has been revoked' }); - return; + for (const { id } of toRevoke) { + await revokeDeviceRow(id); } - const signedPreKey = await db.query.signedPreKeys.findFirst({ - where: eq(signedPreKeys.deviceId, deviceId), - }); - - if (!signedPreKey) { - res.status(409).json({ error: 'Device has not uploaded a signed prekey yet' }); - return; + if (toRevoke.length > 0) { + void emitDeviceChangeEvent(userId, 'device_revoked'); } - const claimedOneTimePreKey = await db.transaction(async (tx) => { - const [candidate] = await tx - .select({ - id: oneTimePreKeys.id, - keyId: oneTimePreKeys.keyId, - publicKey: oneTimePreKeys.publicKey, - }) - .from(oneTimePreKeys) - .where(eq(oneTimePreKeys.deviceId, deviceId)) - .orderBy(oneTimePreKeys.createdAt) - .limit(1) - .for('update', { skipLocked: true }); - - if (!candidate) return null; - - await tx.delete(oneTimePreKeys).where(eq(oneTimePreKeys.id, candidate.id)); - - return { keyId: candidate.keyId, publicKey: candidate.publicKey }; - }); - - res.json({ - deviceId: device.id, - identityPublicKey: device.identityPublicKey, - registrationId: device.registrationId, - signedPreKey: { - keyId: signedPreKey.keyId, - publicKey: signedPreKey.publicKey, - signature: signedPreKey.signature, - }, - oneTimePreKey: claimedOneTimePreKey, - }); + res.json({ revokedCount: toRevoke.length }); }); // ─── POST /devices/:id/prekeys ───────────────────────────────────────────────── @@ -201,7 +205,7 @@ devicesRouter.post('/:id/prekeys', validate(UploadPreKeysSchema), async (req: Au return; } - if (device.isRevoked) { + if (device.revokedAt) { res.status(403).json({ error: 'Device is revoked' }); return; } @@ -225,8 +229,14 @@ devicesRouter.post('/:id/prekeys', validate(UploadPreKeysSchema), async (req: Au // Enforce the one-time prekey cap before inserting. const [otpCountRow] = await db .select({ total: count() }) - .from(oneTimePreKeys) - .where(eq(oneTimePreKeys.deviceId, deviceId)); + .from(devicePrekeys) + .where( + and( + eq(devicePrekeys.deviceId, deviceId), + eq(devicePrekeys.keyType, 'one_time'), + eq(devicePrekeys.consumed, false), + ), + ); const currentCount = otpCountRow?.total ?? 0; const available = OTP_CAP - currentCount; @@ -243,15 +253,17 @@ devicesRouter.post('/:id/prekeys', validate(UploadPreKeysSchema), async (req: Au // Upsert the signed prekey (one per device — replace on keyId conflict). await db - .insert(signedPreKeys) + .insert(devicePrekeys) .values({ deviceId, + keyType: 'signed', keyId: signedPreKey.keyId, publicKey: signedPreKey.publicKey, signature: signedPreKey.signature, }) .onConflictDoUpdate({ - target: [signedPreKeys.deviceId], + target: devicePrekeys.deviceId, + targetWhere: sql`${devicePrekeys.keyType} = 'signed'`, set: { keyId: signedPreKey.keyId, publicKey: signedPreKey.publicKey, @@ -260,18 +272,21 @@ devicesRouter.post('/:id/prekeys', validate(UploadPreKeysSchema), async (req: Au }, }); - // Insert one-time prekeys, ignoring conflicts on (deviceId, keyId). + // Insert one-time prekeys, ignoring conflicts on (deviceId, keyType, keyId). if (trimmedBatch.length > 0) { await db - .insert(oneTimePreKeys) + .insert(devicePrekeys) .values( trimmedBatch.map((k) => ({ deviceId, + keyType: 'one_time' as const, keyId: k.keyId, publicKey: k.publicKey, })), ) - .onConflictDoNothing({ target: [oneTimePreKeys.deviceId, oneTimePreKeys.keyId] }); + .onConflictDoNothing({ + target: [devicePrekeys.deviceId, devicePrekeys.keyType, devicePrekeys.keyId], + }); } res.status(200).json({ @@ -287,75 +302,56 @@ devicesRouter.post('/', validate(RegisterDeviceSchema), async (req: AuthRequest, const body = req.body as z.infer; const userId = req.auth!.userId; - // Device payload validation is handled by the shared DeviceSchema. - - // Reject duplicate (userId, deviceId) - const existing = await db.query.userDevices.findFirst({ - where: eq(userDevices.deviceId, body.deviceId), + const existing = await db.query.devices.findFirst({ + where: and(eq(devices.userId, userId), eq(devices.identityPublicKey, body.identityPublicKey)), }); - if (existing && existing.userId === userId) { + if (existing && !existing.revokedAt) { res.status(409).json({ error: 'Device already registered for this user' }); return; } try { - const [row] = await db - .insert(userDevices) - .values({ - userId, - deviceId: body.deviceId, - deviceName: body.deviceName, - platform: body.platform, - identityPublicKey: body.identityPublicKey, - registrationId: body.registrationId ?? undefined, - }) - .returning({ - id: userDevices.id, - deviceId: userDevices.deviceId, - createdAt: userDevices.createdAt, - }); - - // Emit system event to each conversation the user belongs to - void emitDeviceChangeEvent(userId, 'device_added'); - - res.status(201).json({ id: row.id, deviceId: row.deviceId, createdAt: row.createdAt }); - } catch (err) { - console.error('Failed to register device:', err); - res.status(500).json({ error: 'Failed to register device' }); - } -}); - -// ─── DELETE /devices/:id — revoke a device for the authenticated user -------- -devicesRouter.delete('/:id', async (req: AuthRequest, res) => { - const userId = req.auth!.userId; - const deviceId = req.params['id'] as string; - - try { - const result = await db - .update(userDevices) - .set({ revokedAt: new Date() }) - .where(eq(userDevices.deviceId, deviceId)) - .returning(); - - if (!result || result.length === 0) { - res.status(404).json({ error: 'Device not found' }); - return; + let row: { id: string; createdAt: Date } | undefined; + + if (existing) { + // Re-registering a previously-revoked identity key re-activates it + // rather than creating a duplicate row for the same crypto identity. + [row] = await db + .update(devices) + .set({ + deviceName: body.deviceName, + platform: body.platform, + registrationId: body.registrationId ?? null, + revokedAt: null, + updatedAt: new Date(), + }) + .where(eq(devices.id, existing.id)) + .returning({ id: devices.id, createdAt: devices.createdAt }); + } else { + [row] = await db + .insert(devices) + .values({ + userId, + identityPublicKey: body.identityPublicKey, + deviceName: body.deviceName, + platform: body.platform, + registrationId: body.registrationId ?? null, + }) + .returning({ id: devices.id, createdAt: devices.createdAt }); } - // Only emit if the device belonged to the user (safety: check last row) - if (result[0].userId !== userId) { - res.status(403).json({ error: 'Not allowed to revoke this device' }); + if (!row) { + res.status(500).json({ error: 'Failed to register device' }); return; } - // Emit system event to each conversation the user belongs to - void emitDeviceChangeEvent(userId, 'device_revoked'); + void emitDeviceChangeEvent(userId, 'device_added'); - res.status(200).json({ revoked: true }); + res.status(201).json({ id: row.id, createdAt: row.createdAt }); } catch (err) { - console.error('Failed to revoke device:', err); - res.status(500).json({ error: 'Failed to revoke device' }); + console.error('Failed to register device:', err); + res.status(500).json({ error: 'Failed to register device' }); } }); @@ -369,19 +365,7 @@ async function emitDeviceChangeEvent(userId: string, change: 'device_added' | 'd if (memberships.length === 0) return; for (const m of memberships) { - const [msg] = await db.transaction(async (tx) => { - const [updatedConv] = await tx - .update(conversations) - .set({ - lastSequenceNumber: sql`${conversations.lastSequenceNumber} + 1`, - }) - .where(eq(conversations.id, m.conversationId)) - .returning({ newSeq: conversations.lastSequenceNumber }); - - if (!updatedConv) { - throw new Error('Conversation not found'); - } - + const msg = await db.transaction(async (tx) => { const [insertedMessage] = await tx .insert(messages) .values({ @@ -389,7 +373,6 @@ async function emitDeviceChangeEvent(userId: string, change: 'device_added' | 'd senderId: userId, contentType: 'system', ciphertext: JSON.stringify({ userId, change }), - sequenceNumber: updatedConv.newSeq, }) .returning(); return insertedMessage; diff --git a/apps/backend/src/routes/messages.ts b/apps/backend/src/routes/messages.ts index b24eac8..9a0b548 100644 --- a/apps/backend/src/routes/messages.ts +++ b/apps/backend/src/routes/messages.ts @@ -1,14 +1,8 @@ import { Router } from 'express'; import type { IRouter } from 'express'; -import { and, eq, inArray, sql } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { - conversationMembers, - messages, - messageEnvelopes, - userDevices, - conversations, -} from '../db/schema.js'; +import { conversationMembers, messages, messageEnvelopes, devices } from '../db/schema.js'; import { softDeleteFile } from '../services/fileCleanup.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; @@ -64,11 +58,11 @@ messagesRouter.post('/', validate(SendMessageSchema), async (req: AuthRequest, r // ── idempotency ──────────────────────────────────────────────────────────── const existing = await db.query.messages.findFirst({ where: eq(messages.id, messageId), - columns: { sequenceNumber: true }, + columns: { createdAt: true }, }); if (existing) { - res.status(200).json({ messageId, sequenceNumber: existing.sequenceNumber }); + res.status(200).json({ messageId, createdAt: existing.createdAt }); return; } @@ -76,18 +70,6 @@ messagesRouter.post('/', validate(SendMessageSchema), async (req: AuthRequest, r let message; try { message = await db.transaction(async (tx) => { - const [updatedConv] = await tx - .update(conversations) - .set({ - lastSequenceNumber: sql`${conversations.lastSequenceNumber} + 1`, - }) - .where(eq(conversations.id, conversationId)) - .returning({ newSeq: conversations.lastSequenceNumber }); - - if (!updatedConv) { - throw new Error('Conversation not found'); - } - const [insertedMessage] = await tx .insert(messages) .values({ @@ -98,14 +80,13 @@ messagesRouter.post('/', validate(SendMessageSchema), async (req: AuthRequest, r contentType: contentType?.trim().toLowerCase() || 'text', ciphertext: ciphertext || null, fileId: fileId ?? null, - sequenceNumber: updatedConv.newSeq, }) .returning(); if (envelopes && envelopes.length > 0) { const deviceIds = envelopes.map((e) => e.recipientDeviceId); - const devicesList = await tx.query.userDevices.findMany({ - where: inArray(userDevices.id, deviceIds), + const devicesList = await tx.query.devices.findMany({ + where: inArray(devices.id, deviceIds), columns: { id: true, userId: true }, }); const deviceToUser = new Map(devicesList.map((d) => [d.id, d.userId])); diff --git a/apps/backend/src/routes/sync.ts b/apps/backend/src/routes/sync.ts index 6ce34b4..be32b21 100644 --- a/apps/backend/src/routes/sync.ts +++ b/apps/backend/src/routes/sync.ts @@ -1,7 +1,7 @@ import { Router, type Router as RouterType } from 'express'; -import { and, eq, gt, isNull, or, inArray } from 'drizzle-orm'; +import { and, asc, eq, gt, isNull, or, inArray } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { messageEnvelopes, messages, userDevices } from '../db/schema.js'; +import { messageEnvelopes, messages, devices } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; export const syncRouter: RouterType = Router(); @@ -13,28 +13,53 @@ const ENVELOPE_TTL_MS = parseInt(process.env['ENVELOPE_TTL_SECONDS'] ?? '604800' const SYNC_PAGE_SIZE = parseInt(process.env['SYNC_PAGE_SIZE'] ?? '50', 10); +interface SyncCursor { + createdAt: Date; + id: string; +} + +// Cursor is the envelope's own (createdAt, id) — not messages.sequenceNumber. +// A per-conversation sequence number (#128) can't serve as a coherent cursor +// across a device's *different* conversations, which is exactly what an +// offline-catchup sync needs (#137); the envelope's own creation instant, +// tie-broken by its id, is comparable across every conversation the device +// has envelopes in. +function encodeCursor(c: SyncCursor): string { + return `${c.createdAt.getTime()}:${c.id}`; +} + +function decodeCursor(raw: string): SyncCursor | null { + const sep = raw.indexOf(':'); + if (sep === -1) return null; + const millis = Number(raw.slice(0, sep)); + const id = raw.slice(sep + 1); + if (!Number.isFinite(millis) || !id) return null; + return { createdAt: new Date(millis), id }; +} + // ─── GET /sync ──────────────────────────────────────────────────────────────── // // Returns message envelopes addressed to a device that are newer than the -// provided sinceSequence cursor, ordered deterministically by sequenceNumber. -// Supports cursor-based pagination stable under concurrent inserts. +// provided cursor, ordered deterministically by (createdAt, id) across every +// conversation the device has envelopes in. Cursor-based, stable under +// concurrent inserts (id tie-breaks same-millisecond envelopes). // // Query params: -// deviceId — UUID of the userDevices entry (E2E encryption device) -// sinceSequence — integer cursor; only envelopes with sequenceNumber > this -// value are returned. Defaults to 0 (return everything). -// limit — page size (max SYNC_PAGE_SIZE) +// deviceId — UUID of the devices entry (E2E encryption device) +// cursor — opaque cursor from a previous response's `nextCursor`; +// omit to fetch from the beginning of the retention window +// limit — page size (max SYNC_PAGE_SIZE) syncRouter.get('/', async (req: AuthRequest, res) => { const { userId } = req.auth!; const { deviceId, - sinceSequence, + cursor: cursorParam, limit: limitParam, } = req.query as { deviceId?: string; - sinceSequence?: string; + cursor?: string; limit?: string; }; @@ -43,10 +68,14 @@ syncRouter.get('/', async (req: AuthRequest, res) => { return; } - const cursor = parseInt(sinceSequence ?? '0', 10); - if (isNaN(cursor) || cursor < 0) { - res.status(400).json({ error: 'sinceSequence must be a non-negative integer' }); - return; + let cursor: SyncCursor | undefined; + if (cursorParam) { + const decoded = decodeCursor(cursorParam); + if (!decoded) { + res.status(400).json({ error: 'Invalid cursor' }); + return; + } + cursor = decoded; } const pageSize = Math.min( @@ -55,12 +84,12 @@ syncRouter.get('/', async (req: AuthRequest, res) => { ); // Verify the requesting user owns this E2E device. - const userDevice = await db.query.userDevices.findFirst({ - where: and(eq(userDevices.id, deviceId), eq(userDevices.userId, userId)), + const ownedDevice = await db.query.devices.findFirst({ + where: and(eq(devices.id, deviceId), eq(devices.userId, userId)), columns: { id: true, revokedAt: true }, }); - if (!userDevice) { + if (!ownedDevice) { res.status(403).json({ error: 'Device not found or not owned by this user' }); return; } @@ -68,17 +97,13 @@ syncRouter.get('/', async (req: AuthRequest, res) => { // TTL cutoff — envelopes older than this are considered expired. const ttlCutoff = new Date(Date.now() - ENVELOPE_TTL_MS); - // Join messageEnvelopes → messages to get sequenceNumber for cursor-based - // pagination. Only return envelopes within TTL that haven't been delivered, - // OR that the client explicitly requests again via cursor. const rows = await db .select({ id: messageEnvelopes.id, messageId: messageEnvelopes.messageId, ciphertext: messageEnvelopes.ciphertext, deliveredAt: messageEnvelopes.deliveredAt, - createdAt: messageEnvelopes.createdAt, - sequenceNumber: messages.sequenceNumber, + envelopeCreatedAt: messageEnvelopes.createdAt, conversationId: messages.conversationId, senderId: messages.senderId, senderDeviceId: messages.senderDeviceId, @@ -90,19 +115,30 @@ syncRouter.get('/', async (req: AuthRequest, res) => { .where( and( eq(messageEnvelopes.recipientDeviceId, deviceId), - gt(messages.sequenceNumber, cursor), + cursor + ? or( + gt(messageEnvelopes.createdAt, cursor.createdAt), + and( + eq(messageEnvelopes.createdAt, cursor.createdAt), + gt(messageEnvelopes.id, cursor.id), + ), + ) + : undefined, // Exclude TTL-expired envelopes (already delivered AND past retention). or(isNull(messageEnvelopes.deliveredAt), gt(messageEnvelopes.createdAt, ttlCutoff)), isNull(messages.deletedAt), ), ) - .orderBy(messages.sequenceNumber) + .orderBy(asc(messageEnvelopes.createdAt), asc(messageEnvelopes.id)) .limit(pageSize + 1); // fetch one extra to detect hasMore const hasMore = rows.length > pageSize; const page = hasMore ? rows.slice(0, pageSize) : rows; - const nextCursor = page.length > 0 ? page[page.length - 1]!.sequenceNumber : cursor; + const lastRow = page[page.length - 1]; + const nextCursor = lastRow + ? encodeCursor({ createdAt: lastRow.envelopeCreatedAt, id: lastRow.id }) + : (cursorParam ?? null); // Mark returned envelopes as delivered (best-effort — do not block response). if (page.length > 0) { @@ -124,12 +160,8 @@ syncRouter.get('/', async (req: AuthRequest, res) => { senderDeviceId: r.senderDeviceId, contentType: r.contentType, ciphertext: r.ciphertext, - sequenceNumber: r.sequenceNumber, - senderId: r.senderId, - senderDeviceId: r.senderDeviceId, - contentType: r.contentType, deliveredAt: r.deliveredAt, - createdAt: r.createdAt, + createdAt: r.envelopeCreatedAt, messageCreatedAt: r.messageCreatedAt, })), nextCursor, diff --git a/apps/backend/src/routes/userDevices.ts b/apps/backend/src/routes/userDevices.ts index a12ecd3..fa9b6c3 100644 --- a/apps/backend/src/routes/userDevices.ts +++ b/apps/backend/src/routes/userDevices.ts @@ -9,7 +9,7 @@ import { Router, type Router as RouterType } from 'express'; import { and, eq, inArray, isNull } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { conversationMembers, userDevices } from '../db/schema.js'; +import { conversationMembers, devices } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; export const userDevicesRouter: RouterType = Router(); @@ -21,8 +21,8 @@ userDevicesRouter.get('/:id/public-key', async (req: AuthRequest, res) => { const requesterId = req.auth!.userId; try { - const device = await db.query.userDevices.findFirst({ - where: and(eq(userDevices.id, senderDeviceId), isNull(userDevices.revokedAt)), + const device = await db.query.devices.findFirst({ + where: and(eq(devices.id, senderDeviceId), isNull(devices.revokedAt)), columns: { id: true, userId: true, diff --git a/apps/backend/src/routes/users.ts b/apps/backend/src/routes/users.ts index 39cf294..e4798a1 100644 --- a/apps/backend/src/routes/users.ts +++ b/apps/backend/src/routes/users.ts @@ -1,8 +1,8 @@ import { createHash } from 'node:crypto'; import { Router, type Router as RouterType } from 'express'; -import { eq, and, or, ilike, exists, sql } from 'drizzle-orm'; +import { eq, and, or, ilike, exists, sql, isNull } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { users, wallets, devices, conversationMembers } from '../db/schema.js'; +import { users, wallets, devices, devicePrekeys, conversationMembers } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { redis } from '../lib/redis.js'; import { isOnline, deriveDevicePresence } from '../services/presence.js'; @@ -173,7 +173,7 @@ usersRouter.get('/:id/presence', async (req: AuthRequest, res) => { } } - // Fall back to device-based presence from user_devices.lastSeenAt. + // Fall back to device-based presence from devices.lastSeenAt. try { const { online, lastSeen } = await deriveDevicePresence(id); res.json({ online, ...(lastSeen ? { lastSeen } : {}) }); @@ -185,6 +185,80 @@ usersRouter.get('/:id/presence', async (req: AuthRequest, res) => { } }); +/** + * GET /users/:userId/devices/:deviceId/key-bundle + * + * X3DH prekey bundle (issue #110/#305): identity key + signed prekey + one + * one-time prekey, atomically claimed so it is never handed out twice. Falls + * back to a signed-prekey-only bundle once one-time prekeys are exhausted — + * the initiator just runs 3-DH instead of 4-DH in that case. `:deviceId` must + * belong to `:userId` — this route is the narrower, other-user-facing lookup; + * callers checking their own devices use GET /devices instead. + */ +usersRouter.get('/:userId/devices/:deviceId/key-bundle', async (req: AuthRequest, res) => { + const targetUserId = req.params['userId'] as string; + const deviceId = req.params['deviceId'] as string; + + const device = await db.query.devices.findFirst({ + where: eq(devices.id, deviceId), + }); + + if (!device || device.userId !== targetUserId || device.revokedAt) { + res.status(404).json({ error: 'Device not found or has been revoked' }); + return; + } + + const signedPreKey = await db.query.devicePrekeys.findFirst({ + where: and(eq(devicePrekeys.deviceId, deviceId), eq(devicePrekeys.keyType, 'signed')), + }); + + if (!signedPreKey) { + res.status(409).json({ error: 'Device has not uploaded a signed prekey yet' }); + return; + } + + const claimedOneTimePreKey = await db.transaction(async (tx) => { + const [candidate] = await tx + .select({ + id: devicePrekeys.id, + keyId: devicePrekeys.keyId, + publicKey: devicePrekeys.publicKey, + }) + .from(devicePrekeys) + .where( + and( + eq(devicePrekeys.deviceId, deviceId), + eq(devicePrekeys.keyType, 'one_time'), + eq(devicePrekeys.consumed, false), + ), + ) + .orderBy(devicePrekeys.createdAt) + .limit(1) + .for('update', { skipLocked: true }); + + if (!candidate) return null; + + await tx + .update(devicePrekeys) + .set({ consumed: true }) + .where(eq(devicePrekeys.id, candidate.id)); + + return { keyId: candidate.keyId, publicKey: candidate.publicKey }; + }); + + res.json({ + deviceId: device.id, + identityPublicKey: device.identityPublicKey, + registrationId: device.registrationId, + signedPreKey: { + keyId: signedPreKey.keyId, + publicKey: signedPreKey.publicKey, + signature: signedPreKey.signature, + }, + oneTimePreKey: claimedOneTimePreKey, + }); +}); + /** * GET /users/:id/key-fingerprint * @@ -224,7 +298,7 @@ usersRouter.get('/:id/key-fingerprint', async (req: AuthRequest, res) => { // Fetch all active (non-revoked) device identity public keys. const activeDevices = await db.query.devices.findMany({ - where: and(eq(devices.userId, id), eq(devices.isRevoked, false)), + where: and(eq(devices.userId, id), isNull(devices.revokedAt)), columns: { identityPublicKey: true }, }); diff --git a/apps/backend/src/schemas/auth.schemas.ts b/apps/backend/src/schemas/auth.schemas.ts index e0e8f40..8d2c698 100644 --- a/apps/backend/src/schemas/auth.schemas.ts +++ b/apps/backend/src/schemas/auth.schemas.ts @@ -6,7 +6,6 @@ export const ChallengeSchema = z.object({ }); export const DeviceSchema = z.object({ - deviceId: z.string().uuid('deviceId must be a valid UUID'), deviceName: z .string() .min(1, 'deviceName is required') diff --git a/apps/backend/src/services/deliveryAggregation.ts b/apps/backend/src/services/deliveryAggregation.ts index d35477f..acc301f 100644 --- a/apps/backend/src/services/deliveryAggregation.ts +++ b/apps/backend/src/services/deliveryAggregation.ts @@ -1,7 +1,7 @@ import { and, eq, isNull } from 'drizzle-orm'; import type { Server } from 'socket.io'; import { db } from '../db/index.js'; -import { messageEnvelopes, userDevices, conversationMembers, messages } from '../db/schema.js'; +import { messageEnvelopes, devices, conversationMembers, messages } from '../db/schema.js'; import { publishEphemeral } from './resumeStream.js'; import type { Redis } from 'ioredis'; import { conversationRoom } from './roomManager.js'; @@ -16,9 +16,9 @@ export async function isMessageFullyDeliveredToUser( ): Promise { // Get all active devices for this user const activeDevices = await db - .select({ id: userDevices.id }) - .from(userDevices) - .where(and(eq(userDevices.userId, recipientUserId), isNull(userDevices.revokedAt))); + .select({ id: devices.id }) + .from(devices) + .where(and(eq(devices.userId, recipientUserId), isNull(devices.revokedAt))); if (activeDevices.length === 0) { // No active devices, consider message delivered by default diff --git a/apps/backend/src/services/deliveryPipeline.ts b/apps/backend/src/services/deliveryPipeline.ts index ef45443..2a5ae47 100644 --- a/apps/backend/src/services/deliveryPipeline.ts +++ b/apps/backend/src/services/deliveryPipeline.ts @@ -1,7 +1,7 @@ import { and, eq, inArray, isNull } from 'drizzle-orm'; import type { Server } from 'socket.io'; import { db } from '../db/index.js'; -import { conversationMembers, messageEnvelopes, userDevices } from '../db/schema.js'; +import { conversationMembers, messageEnvelopes, devices } from '../db/schema.js'; import type { Message } from '../db/schema.js'; import { conversationRoom } from './roomManager.js'; @@ -41,9 +41,9 @@ export async function deliverMessage( // Step 2: active devices only — revokedAt IS NULL. const activeDevices = await db - .select({ id: userDevices.id, userId: userDevices.userId }) - .from(userDevices) - .where(and(inArray(userDevices.userId, userIds), isNull(userDevices.revokedAt))); + .select({ id: devices.id, userId: devices.userId }) + .from(devices) + .where(and(inArray(devices.userId, userIds), isNull(devices.revokedAt))); if (activeDevices.length === 0) { io.to(conversationId).emit('new_message', message); @@ -81,7 +81,6 @@ export async function deliverMessage( senderId: message.senderId, senderDeviceId: message.senderDeviceId, contentType: message.contentType, - sequenceNumber: message.sequenceNumber, createdAt: message.createdAt, envelopeId: envelope.id, ciphertext: envelope.ciphertext, @@ -96,7 +95,6 @@ export async function deliverMessage( senderId: message.senderId, senderDeviceId: message.senderDeviceId, contentType: message.contentType, - sequenceNumber: message.sequenceNumber, createdAt: message.createdAt, deletedAt: message.deletedAt, ciphertext: null, diff --git a/apps/backend/src/services/deviceDelivery.ts b/apps/backend/src/services/deviceDelivery.ts index a9e3cbe..bad04b8 100644 --- a/apps/backend/src/services/deviceDelivery.ts +++ b/apps/backend/src/services/deviceDelivery.ts @@ -4,7 +4,6 @@ export interface DeviceDeliveryPayload { messageId: string; conversationId: string; ciphertext: string; - sequenceNumber: number; } export const deviceChannel = (deviceId: string): string => `deliver:device:${deviceId}`; diff --git a/apps/backend/src/services/fanout.ts b/apps/backend/src/services/fanout.ts index 1ade540..08bf9bb 100644 --- a/apps/backend/src/services/fanout.ts +++ b/apps/backend/src/services/fanout.ts @@ -9,15 +9,9 @@ // instead of guessing or dropping ciphertext. On success, the message and // its envelopes are persisted atomically. -import { and, eq, inArray, isNull, sql } from 'drizzle-orm'; +import { and, eq, inArray, isNull } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { - conversationMembers, - messages, - messageEnvelopes, - userDevices, - conversations, -} from '../db/schema.js'; +import { conversationMembers, messages, messageEnvelopes, devices } from '../db/schema.js'; import type { Message, NewMessage } from '../db/schema.js'; export interface FanoutSuccess { @@ -54,8 +48,8 @@ export async function fanoutMessage( }); const memberIds = members.map((m) => m.userId); - const activeDevices = await db.query.userDevices.findMany({ - where: and(inArray(userDevices.userId, memberIds), isNull(userDevices.revokedAt)), + const activeDevices = await db.query.devices.findMany({ + where: and(inArray(devices.userId, memberIds), isNull(devices.revokedAt)), columns: { id: true, userId: true }, }); @@ -77,25 +71,7 @@ export async function fanoutMessage( } const message = await db.transaction(async (tx) => { - const [updatedConv] = await tx - .update(conversations) - .set({ - lastSequenceNumber: sql`${conversations.lastSequenceNumber} + 1`, - }) - .where(eq(conversations.id, newMessage.conversationId)) - .returning({ newSeq: conversations.lastSequenceNumber }); - - if (!updatedConv) { - throw new Error('Conversation not found'); - } - - const [inserted] = await tx - .insert(messages) - .values({ - ...newMessage, - sequenceNumber: updatedConv.newSeq, - }) - .returning(); + const [inserted] = await tx.insert(messages).values(newMessage).returning(); const persisted = inserted!; const envelopeRows = providedDeviceIds.map((deviceId) => ({ diff --git a/apps/backend/src/services/fileCleanup.ts b/apps/backend/src/services/fileCleanup.ts index dc80ef8..d0284ea 100644 --- a/apps/backend/src/services/fileCleanup.ts +++ b/apps/backend/src/services/fileCleanup.ts @@ -8,15 +8,12 @@ * The job is idempotent: it sets hardDeletedAt only after a successful S3 * delete, so a crash between steps is safe to retry. */ -import { S3Client, DeleteObjectCommand } from '@aws-sdk/client-s3'; import { isNotNull, isNull, sql, and, eq, lt } from 'drizzle-orm'; import { db } from '../db/index.js'; import { files } from '../db/schema.js'; +import { getObjectStore } from '../lib/objectStore.js'; import { reenableExpiredBackoffs } from './pushNotification.js'; -const s3 = new S3Client({ region: process.env['AWS_REGION'] ?? 'us-east-1' }); -const BUCKET = process.env['AWS_BUCKET'] ?? 'clicked-files'; - const CLEANUP_INTERVAL_MS = 5 * 60 * 1_000; // every 5 minutes /** @@ -57,12 +54,12 @@ export async function runHardDeletePass(): Promise { if ((liveRef as unknown[]).length > 0) continue; try { - await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: file.storageKey })); + await getObjectStore().deleteObject(file.storageKey); await db .update(files) .set({ hardDeletedAt: new Date() }) .where(sql`${files.id} = ${file.id}`); - console.log(`[file-cleanup] hard-deleted s3://${BUCKET}/${file.storageKey}`); + console.log(`[file-cleanup] hard-deleted ${file.storageKey}`); } catch (err) { console.error(`[file-cleanup] failed to delete ${file.storageKey}:`, err); } @@ -77,9 +74,9 @@ export async function runHardDeletePass(): Promise { for (const file of pendingCandidates) { try { - await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: file.storageKey })); + await getObjectStore().deleteObject(file.storageKey); await db.delete(files).where(eq(files.id, file.id)); - console.log(`[file-cleanup] deleted pending file s3://${BUCKET}/${file.storageKey}`); + console.log(`[file-cleanup] deleted pending file ${file.storageKey}`); } catch (err) { console.error(`[file-cleanup] failed to delete pending file ${file.storageKey}:`, err); } diff --git a/apps/backend/src/services/heartbeat.ts b/apps/backend/src/services/heartbeat.ts index 9695cbe..ef163cc 100644 --- a/apps/backend/src/services/heartbeat.ts +++ b/apps/backend/src/services/heartbeat.ts @@ -2,7 +2,7 @@ import type { Server } from 'socket.io'; import type { Redis } from 'ioredis'; import type { AuthSocket } from '../middleware/socketAuth.js'; import { db } from '../db/index.js'; -import { devices, userDevices } from '../db/schema.js'; +import { devices } from '../db/schema.js'; import { eq, and, isNull } from 'drizzle-orm'; import { refreshPresence, @@ -23,7 +23,6 @@ export function startHeartbeatTimer( deviceId: string, redis: Redis | null, io: Server, - identityPublicKey?: string, ): void { const schedule = () => { clearTimeout(timers.get(socket.id)); @@ -76,28 +75,13 @@ export function startHeartbeatTimer( if (now - last >= LAST_SEEN_THROTTLE_MS) { lastSeenAt.set(deviceId, now); try { - await db.update(devices).set({ updatedAt: new Date() }).where(eq(devices.id, deviceId)); + await db + .update(devices) + .set({ lastSeenAt: new Date(), updatedAt: new Date() }) + .where(and(eq(devices.id, deviceId), isNull(devices.revokedAt))); } catch { // Non-critical update; ignore errors. } - - // Update user_devices.lastSeenAt for device-based presence derivation. - if (identityPublicKey) { - try { - await db - .update(userDevices) - .set({ lastSeenAt: new Date() }) - .where( - and( - eq(userDevices.userId, userId), - eq(userDevices.identityPublicKey, identityPublicKey), - isNull(userDevices.revokedAt), - ), - ); - } catch { - // Non-critical update; ignore errors. - } - } } schedule(); diff --git a/apps/backend/src/services/presence.ts b/apps/backend/src/services/presence.ts index c680e96..f58c785 100644 --- a/apps/backend/src/services/presence.ts +++ b/apps/backend/src/services/presence.ts @@ -11,7 +11,7 @@ * - GET /users/:id/presence → { online: boolean, lastSeen?: string } * * User presence is derived from device presence: a user is online when any - * non-expired device entry exists (Redis OR user_devices.lastSeenAt within + * non-expired device entry exists (Redis OR devices.lastSeenAt within * the window). When offline, lastSeen reflects the most recent device activity. * - On connect: upsert device entry in `presence:user:{userId}` and refresh TTL * - On heartbeat: update lastSeen and refresh the device TTL @@ -31,7 +31,7 @@ import type { Server } from 'socket.io'; import type { Redis } from 'ioredis'; import { isNull, eq, and, gte, desc } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { userDevices, conversationMembers } from '../db/schema.js'; +import { devices, conversationMembers } from '../db/schema.js'; const PRESENCE_TTL = 90; // seconds const SOCKET_MAPPING_PREFIX = 'presence:sockets:'; @@ -229,11 +229,11 @@ export async function deriveDevicePresence( ): Promise<{ online: boolean; lastSeen: string | null }> { const windowStart = new Date(Date.now() - DEVICE_PRESENCE_WINDOW_MS); - const activeDevice = await db.query.userDevices.findFirst({ + const activeDevice = await db.query.devices.findFirst({ where: and( - eq(userDevices.userId, userId), - isNull(userDevices.revokedAt), - gte(userDevices.lastSeenAt, windowStart), + eq(devices.userId, userId), + isNull(devices.revokedAt), + gte(devices.lastSeenAt, windowStart), ), columns: { id: true }, }); @@ -242,9 +242,9 @@ export async function deriveDevicePresence( return { online: true, lastSeen: null }; } - const mostRecent = await db.query.userDevices.findFirst({ - where: and(eq(userDevices.userId, userId), isNull(userDevices.revokedAt)), - orderBy: desc(userDevices.lastSeenAt), + const mostRecent = await db.query.devices.findFirst({ + where: and(eq(devices.userId, userId), isNull(devices.revokedAt)), + orderBy: desc(devices.lastSeenAt), columns: { lastSeenAt: true }, }); diff --git a/apps/backend/src/services/push.ts b/apps/backend/src/services/push.ts index 3569e64..bca9bf7 100644 --- a/apps/backend/src/services/push.ts +++ b/apps/backend/src/services/push.ts @@ -1,19 +1,9 @@ -import webpush from 'web-push'; import { eq, and, isNull } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { conversationMembers, pushSubscriptions, userDevices } from '../db/schema.js'; +import { conversationMembers, devices } from '../db/schema.js'; import { redis } from '../lib/redis.js'; import { isOnline } from './presence.js'; - -const VAPID_SUBJECT = process.env['VAPID_SUBJECT'] || 'mailto:admin@clicked.app'; - -if (process.env['VAPID_PUBLIC_KEY'] && process.env['VAPID_PRIVATE_KEY']) { - webpush.setVapidDetails( - VAPID_SUBJECT, - process.env['VAPID_PUBLIC_KEY'], - process.env['VAPID_PRIVATE_KEY'], - ); -} +import { queueCoalescedPush } from './pushNotification.js'; export interface PushContext { conversationId: string; @@ -21,11 +11,22 @@ export interface PushContext { senderId: string; } +/** + * Push dispatch for file/image/video/audio messages (send_file_message). + * + * Shares queueCoalescedPush with the text-message path (#176) so a burst of + * file messages coalesces into one push per device instead of one per + * message, and gets the same per-device rate limit and dead-subscription + * pruning/backoff hygiene (services/pushNotification.ts) — previously this + * sent one uncoalesced webpush.sendNotification per file message and + * silently swallowed all failures, never cleaning up dead subscriptions. + * + * The recipient-resolution logic here (mute check, per-device pushEnabled, + * cross-node online check via Redis) stays local to this call site since + * send_file_message doesn't build a per-device envelope/recipient list the + * way send_message does — there's no recipientDeviceIds to reuse. + */ export async function sendPushForMessage(ctx: PushContext): Promise { - if (!process.env['VAPID_PUBLIC_KEY'] || !process.env['VAPID_PRIVATE_KEY']) { - return; - } - try { const allMembers = await db.query.conversationMembers.findMany({ where: eq(conversationMembers.conversationId, ctx.conversationId), @@ -43,38 +44,17 @@ export async function sendPushForMessage(ctx: PushContext): Promise { } // Get non-revoked devices with push enabled. - const devices = await db.query.userDevices.findMany({ + const memberDevices = await db.query.devices.findMany({ where: and( - eq(userDevices.userId, member.userId), - eq(userDevices.pushEnabled, true), - isNull(userDevices.revokedAt), + eq(devices.userId, member.userId), + eq(devices.pushEnabled, true), + isNull(devices.revokedAt), ), columns: { id: true }, }); - for (const device of devices) { - const sub = await db.query.pushSubscriptions.findFirst({ - where: eq(pushSubscriptions.deviceId, device.id), - columns: { endpoint: true, p256dh: true, auth: true }, - }); - - if (!sub) continue; - - try { - await webpush.sendNotification( - { - endpoint: sub.endpoint, - keys: { p256dh: sub.p256dh, auth: sub.auth }, - }, - JSON.stringify({ - type: 'new_message', - conversationId: ctx.conversationId, - messageId: ctx.messageId, - }), - ); - } catch { - // Push delivery failures are non-critical. - } + for (const device of memberDevices) { + queueCoalescedPush(device.id, ctx.conversationId, ctx.messageId); } } } catch { diff --git a/apps/backend/src/services/pushNotification.ts b/apps/backend/src/services/pushNotification.ts index b49bc22..6899202 100644 --- a/apps/backend/src/services/pushNotification.ts +++ b/apps/backend/src/services/pushNotification.ts @@ -63,8 +63,17 @@ export async function dispatchOfflinePush( export { FILE_CONTENT_TYPES }; // ── #239 Coalescing ─────────────────────────────────────────────────────────── +// +// Exported so other send paths (e.g. services/push.ts's file-message push) +// can share the same coalescing window, per-device rate limit, and pruning +// hygiene (#176) instead of sending one uncoalesced push per message with +// no dead-subscription cleanup. -function queueCoalescedPush(deviceId: string, conversationId: string, messageId: string): void { +export function queueCoalescedPush( + deviceId: string, + conversationId: string, + messageId: string, +): void { const key = `${deviceId}:${conversationId}`; const existing = pendingCoalesce.get(key); diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index cf2488e..3b76ffb 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -1,5 +1,5 @@ import type { Server } from 'socket.io'; -import { and, eq, lt, desc, sql, inArray, isNull, ne } from 'drizzle-orm'; +import { and, eq, lt, desc, sql, inArray, isNull, ne, or } from 'drizzle-orm'; import { db } from '../db/index.js'; import { @@ -7,7 +7,7 @@ import { conversationMembers, messages, messageEnvelopes, - userDevices, + devices, files, } from '../db/schema.js'; import type { AuthSocket } from '../middleware/socketAuth.js'; @@ -26,17 +26,17 @@ import { EventDispatcher } from './dispatcher.js'; const PAGE_SIZE = 30; /** - * Returns the UUIDs of all active (non-revoked) user_devices that belong to + * Returns the UUIDs of all active (non-revoked) devices that belong to * `userId` but are NOT the sending device (`senderDeviceId`). These are the * "sibling" devices that must each receive their own envelope so they can * decrypt the message locally. Issue #188. */ async function fetchSiblingDeviceIds(userId: string, senderDeviceId: string): Promise { - const siblings = await db.query.userDevices.findMany({ + const siblings = await db.query.devices.findMany({ where: and( - eq(userDevices.userId, userId), - ne(userDevices.id, senderDeviceId), - isNull(userDevices.revokedAt), + eq(devices.userId, userId), + ne(devices.id, senderDeviceId), + isNull(devices.revokedAt), ), columns: { id: true }, }); @@ -128,7 +128,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void } const effectiveCiphertext = ciphertext ?? content ?? undefined; - const resolvedContentType = contentType || 'text/plain'; + const resolvedContentType = contentType || 'text'; const validation = validateMessagePayload({ contentType: resolvedContentType, @@ -159,11 +159,11 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void const existing = await db.query.messages.findFirst({ where: eq(messages.id, messageId), - columns: { sequenceNumber: true }, + columns: { createdAt: true }, }); if (existing) { - socket.emit('message_ack', { messageId, sequenceNumber: existing.sequenceNumber }); + socket.emit('message_ack', { messageId, createdAt: existing.createdAt }); return; } @@ -203,18 +203,6 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void let recipientDeviceIds: string[] = []; try { message = await db.transaction(async (tx) => { - const [updatedConv] = await tx - .update(conversations) - .set({ - lastSequenceNumber: sql`${conversations.lastSequenceNumber} + 1`, - }) - .where(eq(conversations.id, conversationId)) - .returning({ newSeq: conversations.lastSequenceNumber }); - - if (!updatedConv) { - throw new Error('Conversation not found'); - } - const [insertedMessage] = await tx .insert(messages) .values({ @@ -225,14 +213,13 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void contentType: resolvedContentType, ciphertext: effectiveCiphertext || null, fileId: fileId, - sequenceNumber: updatedConv.newSeq, }) .returning(); if (envelopes && envelopes.length > 0) { const deviceIds = envelopes.map((e) => e.recipientDeviceId); - const devicesList = await tx.query.userDevices.findMany({ - where: inArray(userDevices.id, deviceIds), + const devicesList = await tx.query.devices.findMany({ + where: inArray(devices.id, deviceIds), columns: { id: true, userId: true }, }); const deviceToUser = new Map(devicesList.map((d) => [d.id, d.userId])); @@ -252,7 +239,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void } } - return insertedMessage; + return insertedMessage!; }); } catch (error) { console.error('Transaction failed for message insert:', error); @@ -261,7 +248,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void } if (message) { - socket.emit('message_ack', { messageId, sequenceNumber: message.sequenceNumber }); + socket.emit('message_ack', { messageId, createdAt: message.createdAt }); await deliverMessage(io, message, conversationId); const members = await db.query.conversationMembers.findMany({ @@ -319,11 +306,11 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void const existing = await db.query.messages.findFirst({ where: eq(messages.id, messageId), - columns: { sequenceNumber: true }, + columns: { createdAt: true }, }); if (existing) { - socket.emit('message_ack', { messageId, sequenceNumber: existing.sequenceNumber }); + socket.emit('message_ack', { messageId, createdAt: existing.createdAt }); return; } @@ -348,18 +335,6 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void let message; try { message = await db.transaction(async (tx) => { - const [updatedConv] = await tx - .update(conversations) - .set({ - lastSequenceNumber: sql`${conversations.lastSequenceNumber} + 1`, - }) - .where(eq(conversations.id, conversationId)) - .returning({ newSeq: conversations.lastSequenceNumber }); - - if (!updatedConv) { - throw new Error('Conversation not found'); - } - const [insertedMessage] = await tx .insert(messages) .values({ @@ -370,14 +345,13 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void contentType: contentType || original.contentType, ciphertext: ciphertext || null, editsMessageId: rootMessageId, - sequenceNumber: updatedConv.newSeq, }) .returning(); if (envelopes && envelopes.length > 0) { const deviceIds = envelopes.map((e) => e.recipientDeviceId); - const devicesList = await tx.query.userDevices.findMany({ - where: inArray(userDevices.id, deviceIds), + const devicesList = await tx.query.devices.findMany({ + where: inArray(devices.id, deviceIds), columns: { id: true, userId: true }, }); const deviceToUser = new Map(devicesList.map((d) => [d.id, d.userId])); @@ -396,7 +370,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void } } - return insertedMessage; + return insertedMessage!; }); } catch (error) { console.error('Transaction failed for message edit:', error); @@ -405,7 +379,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void } if (message) { - socket.emit('message_ack', { messageId, sequenceNumber: message.sequenceNumber }); + socket.emit('message_ack', { messageId, createdAt: message.createdAt }); await deliverMessage(io, message, conversationId); const members = await db.query.conversationMembers.findMany({ @@ -500,18 +474,6 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void let message; try { message = await db.transaction(async (tx) => { - const [updatedConv] = await tx - .update(conversations) - .set({ - lastSequenceNumber: sql`${conversations.lastSequenceNumber} + 1`, - }) - .where(eq(conversations.id, conversationId)) - .returning({ newSeq: conversations.lastSequenceNumber }); - - if (!updatedConv) { - throw new Error('Conversation not found'); - } - const [insertedMessage] = await tx .insert(messages) .values({ @@ -520,7 +482,6 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void ciphertext: content.trim(), contentType, fileId, - sequenceNumber: updatedConv.newSeq, }) .returning(); @@ -575,21 +536,27 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void return; } - let cursor: bigint | undefined; + let cursor: { createdAt: Date; id: string } | undefined; if (before) { const ref = await db.query.messages.findFirst({ where: eq(messages.id, before), - columns: { sequenceNumber: true }, + columns: { createdAt: true, id: true }, }); - cursor = ref?.sequenceNumber || undefined; + cursor = ref ?? undefined; } const history = await db.query.messages.findMany({ where: cursor - ? and(eq(messages.conversationId, conversationId), lt(messages.sequenceNumber, cursor)) + ? and( + eq(messages.conversationId, conversationId), + or( + lt(messages.createdAt, cursor.createdAt), + and(eq(messages.createdAt, cursor.createdAt), lt(messages.id, cursor.id)), + ), + ) : eq(messages.conversationId, conversationId), - orderBy: desc(messages.sequenceNumber), + orderBy: [desc(messages.createdAt), desc(messages.id)], limit: PAGE_SIZE, with: { envelopes: true, @@ -1014,27 +981,14 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void ON CONFLICT DO NOTHING `); - const [replyMessage] = await db.transaction(async (tx) => { - const [updatedConv] = await tx - .update(conversations) - .set({ - lastSequenceNumber: sql`${conversations.lastSequenceNumber} + 1`, - }) - .where(eq(conversations.id, conversationId)) - .returning({ newSeq: conversations.lastSequenceNumber }); - - if (!updatedConv) { - throw new Error('Conversation not found'); - } - + const replyMessage = await db.transaction(async (tx) => { const [inserted] = await tx .insert(messages) .values({ conversationId, senderId: ASSISTANT_USER_ID, - contentType: 'text/plain', + contentType: 'text', ciphertext: data.reply, - sequenceNumber: updatedConv.newSeq, }) .returning(); return inserted; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de13702..b36568c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@types/express': specifier: ^5.0.6 version: 5.0.6 + '@types/ioredis-mock': + specifier: 8.2.7 + version: 8.2.7(ioredis@5.11.0) '@types/jsonwebtoken': specifier: ^9.0.10 version: 9.0.10 @@ -120,6 +123,9 @@ importers: eslint: specifier: ^9.39.4 version: 9.39.4(jiti@2.6.1) + ioredis-mock: + specifier: 8.13.1 + version: 8.13.1(@types/ioredis-mock@8.2.7(ioredis@5.11.0))(ioredis@5.11.0) prettier: specifier: ^3.8.3 version: 3.8.3 @@ -1208,6 +1214,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/as-callback@3.0.0': + resolution: {integrity: sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==} + '@ioredis/commands@1.10.0': resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} @@ -1829,6 +1838,11 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/ioredis-mock@8.2.7': + resolution: {integrity: sha512-YsGiaOIYBKeVvu/7GYziAD8qX3LJem5LK00d5PKykzsQJMLysAqXA61AkNuYWCekYl64tbMTqVOMF4SYoCPbQg==} + peerDependencies: + ioredis: '>=5' + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2972,6 +2986,14 @@ packages: feaxios@0.0.23: resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} + fengari-interop@0.1.4: + resolution: {integrity: sha512-4/CW/3PJUo3ebD4ACgE1g/3NGEYSq7OQAyETyypsAl/WeySDBbxExikkayNkZzbpgyC9GyJp8v1DU2VOXxNq7Q==} + peerDependencies: + fengari: ^0.1.0 + + fengari@0.1.5: + resolution: {integrity: sha512-0DS4Nn4rV8qyFlQCpKK8brT61EUtswynrpfFTcgLErcilBIBskSMQ86fO2WVuybr14ywyKdRjv91FiRZwnEuvQ==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3215,6 +3237,13 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ioredis-mock@8.13.1: + resolution: {integrity: sha512-Wsi50AU+cMiI32nAgfwpUaJVBtb4iQdVsOHl9M6R3tePCO/8vGsToCVIG82XWAxN4Se55TZoOzVseu+QngFLyw==} + engines: {node: '>=12.22'} + peerDependencies: + '@types/ioredis-mock': ^8 + ioredis: ^5 + ioredis@5.11.0: resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} engines: {node: '>=12.22.0'} @@ -3946,6 +3975,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readline-sync@1.4.10: + resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} + engines: {node: '>= 0.8.0'} + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -4154,6 +4187,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -4295,6 +4331,10 @@ packages: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + to-buffer@1.2.2: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} @@ -5525,6 +5565,8 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@ioredis/as-callback@3.0.0': {} + '@ioredis/commands@1.10.0': {} '@jest/schemas@29.6.3': @@ -6039,6 +6081,10 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/ioredis-mock@8.2.7(ioredis@5.11.0)': + dependencies: + ioredis: 5.11.0 + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -7479,6 +7525,16 @@ snapshots: dependencies: is-retry-allowed: 3.0.0 + fengari-interop@0.1.4(fengari@0.1.5): + dependencies: + fengari: 0.1.5 + + fengari@0.1.5: + dependencies: + readline-sync: 1.4.10 + sprintf-js: 1.1.3 + tmp: 0.2.7 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -7718,6 +7774,16 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ioredis-mock@8.13.1(@types/ioredis-mock@8.2.7(ioredis@5.11.0))(ioredis@5.11.0): + dependencies: + '@ioredis/as-callback': 3.0.0 + '@ioredis/commands': 1.10.0 + '@types/ioredis-mock': 8.2.7(ioredis@5.11.0) + fengari: 0.1.5 + fengari-interop: 0.1.4(fengari@0.1.5) + ioredis: 5.11.0 + semver: 7.7.4 + ioredis@5.11.0: dependencies: '@ioredis/commands': 1.10.0 @@ -8415,6 +8481,8 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readline-sync@1.4.10: {} + redis-errors@1.2.0: {} redis-parser@3.0.0: @@ -8801,6 +8869,8 @@ snapshots: source-map@0.6.1: {} + sprintf-js@1.1.3: {} + stable-hash@0.0.5: {} stackback@0.0.2: {} @@ -8976,6 +9046,8 @@ snapshots: tinyspy@2.2.1: {} + tmp@0.2.7: {} + to-buffer@1.2.2: dependencies: isarray: 2.0.5