From 06df6d5d1c39109bf70dcf90378b0173898c9ae0 Mon Sep 17 00:00:00 2001 From: Olorunfemi20 Date: Thu, 30 Jul 2026 08:27:05 +0100 Subject: [PATCH] @ feat(backend): MLS group state recovery and new-device join A newly-linked device now joins existing groups through a proposal/commit and receives every epoch from its join onwards. Group messages from before its join surface as unavailable placeholders rather than as errors. Schema: - mls_groups holds the public group identifiers and current epoch, one row per group conversation - mls_group_members records a device leaf as an epoch interval [joinedAtEpoch, removedAtEpoch) rather than a boolean, so a device decryption window is derivable and a re-added device does not regain the epochs it was absent for - mls_commits is the append-only epoch log a device replays to catch up - mls_welcomes holds Welcome messages for devices that are offline when they are added - messages.mls_epoch records which epoch encrypted a group message Routes under /conversations/:id/mls: - POST /group records group identifiers, seats the founder at 0 - GET /group epoch, membership window, pending Welcome and the epoch this device history starts at - GET /pending-devices member devices with no leaf yet - POST /commits publishes a commit plus its adds, removes and Welcomes; epoch must be currentEpoch + 1 and the group row is locked so a concurrent commit loses with 409 instead of both applying - GET /commits replay, clamped to the device join epoch - GET /welcome claims the pending Welcome Read paths: - GET /conversations/:id/messages and the socket message_history event both blank the ciphertext of messages outside the device epoch window and tag them with unavailable plus a reason code, preserving sender and timestamp so the client renders a placeholder in position - the send path accepts mlsEpoch; an MLS group message carries one group ciphertext instead of per-device envelopes, so the envelope requirement and the sibling-coverage check do not apply to it Adds docs/mls-group-membership.md documenting the join flow, the endpoints and the no-history-for-new-device property. @ --- apps/backend/docs/mls-group-membership.md | 293 +++ apps/backend/drizzle/0001_mls_group_state.sql | 55 + apps/backend/drizzle/meta/0001_snapshot.json | 1974 +++++++++++++++++ apps/backend/drizzle/meta/_journal.json | 7 + .../backend/src/__tests__/mls.history.test.ts | 207 ++ apps/backend/src/__tests__/mls.routes.test.ts | 500 +++++ .../src/__tests__/mlsGroups.service.test.ts | 290 +++ .../src/__tests__/mlsVisibility.test.ts | 128 ++ .../__tests__/validateMessagePayload.test.ts | 79 + apps/backend/src/app.ts | 4 + apps/backend/src/db/schema.ts | 171 +- apps/backend/src/lib/mlsVisibility.ts | 88 + .../backend/src/lib/validateMessagePayload.ts | 38 + apps/backend/src/routes/conversations.ts | 11 +- apps/backend/src/routes/messages.ts | 20 +- apps/backend/src/routes/mls.ts | 461 ++++ apps/backend/src/schemas/message.schemas.ts | 7 + apps/backend/src/services/mlsGroups.ts | 335 +++ apps/backend/src/socket/messaging.ts | 26 +- 19 files changed, 4681 insertions(+), 13 deletions(-) create mode 100644 apps/backend/docs/mls-group-membership.md create mode 100644 apps/backend/drizzle/0001_mls_group_state.sql create mode 100644 apps/backend/drizzle/meta/0001_snapshot.json create mode 100644 apps/backend/src/__tests__/mls.history.test.ts create mode 100644 apps/backend/src/__tests__/mls.routes.test.ts create mode 100644 apps/backend/src/__tests__/mlsGroups.service.test.ts create mode 100644 apps/backend/src/__tests__/mlsVisibility.test.ts create mode 100644 apps/backend/src/lib/mlsVisibility.ts create mode 100644 apps/backend/src/routes/mls.ts create mode 100644 apps/backend/src/services/mlsGroups.ts diff --git a/apps/backend/docs/mls-group-membership.md b/apps/backend/docs/mls-group-membership.md new file mode 100644 index 00000000..2b053777 --- /dev/null +++ b/apps/backend/docs/mls-group-membership.md @@ -0,0 +1,293 @@ +# MLS group membership and new-device join + +Group conversations use MLS (RFC 9420). This document describes how a device +joins a group it was not originally part of — most often a device a user has +just linked to their account — and what that device can and cannot read +afterwards. + +The short version, and the property users need to understand: + +> **A newly-linked device cannot read group messages sent before it joined.** +> It reads everything from its join epoch onwards. Earlier messages appear in +> the timeline as "unavailable on this device", not as errors. + +## Why history does not transfer + +MLS derives the key for each message from the group's secrets at a particular +**epoch**. Those secrets are derived from the ratchet tree, and a device's +contribution to the tree — its leaf — only exists from the commit that added it. + +A device added by the commit that produces epoch 7 therefore holds the epoch-7 +secrets and every later epoch's, and has no way to derive the epoch-6 secrets or +anything before them. There is no key on the server to send it, because the +server never had one: it stores ciphertext and public group state only. + +This is the same property that makes removal meaningful. If a joining device +could reconstruct old epochs, then so could a device that was removed and +re-added, and post-removal forward secrecy would not hold. + +Backfilling history would require an existing device to re-encrypt and re-send +past plaintext to the new device. That is a separate deferred feature (a +history/recovery service), and until it exists the "no history for new device" +property above is the behaviour. + +## What the server stores + +The server is a transport and an ordering service. It holds the public ledger +the group needs to agree on and no group secrets. + +| Table | Purpose | +| ------------------- | ----------------------------------------------------------------------- | +| `mls_groups` | One row per group conversation: group id, cipher suite, current epoch. | +| `mls_group_members` | A device's leaf, recorded as the epoch interval it can decrypt. | +| `mls_commits` | Append-only log of the commit that produced each epoch. | +| `mls_welcomes` | Welcome messages held for devices that are offline when they are added. | + +`messages.mls_epoch` records which epoch encrypted a group message. It is null +for DMs, system events, and any message sent before the conversation adopted +MLS. + +### Membership is an epoch interval, not a flag + +`mls_group_members` stores `joined_at_epoch` and a nullable `removed_at_epoch`. +A device can read epoch `e` when: + +```text +joined_at_epoch <= e < (removed_at_epoch ?? infinity) +``` + +A device that is removed and later re-added gets a second row with a later +`joined_at_epoch`. The intervals are deliberately not merged: the device +genuinely cannot read the epochs it was absent for. + +## Join flow for a newly-linked device + +```text +New device Backend Existing member device + | | | + |-- POST /devices -------->| | + |-- publish MLS key -------| | + | packages |-- device_added system event --->| + | | | + | |<-- GET /conversations/:id/mls/ -| + | | pending-devices | + | |--- [{ deviceId, ... }] -------->| + | | | + | | claims a key package, builds + | | Add proposal + Commit + Welcome + | | | + | |<-- POST /conversations/:id/mls/-| + | | commits { epoch, commit, | + | | addedDevices: [{ welcome }]}| + | | | + |<- mls_welcome_available -|--- mls_commit ----------------->| + | | | + |-- GET /conversations/:id | | + | /mls/welcome --------->| | + |<- { epoch, welcome } ----| | + | | | + |-- GET /conversations/:id | | + | /mls/commits --------->| | + |<- commits since join ----| | + | | | + | now decrypts every message from its join epoch onwards | +``` + +1. The device registers and publishes MLS key packages (see + [mls-key-packages.md](./mls-key-packages.md)). +2. It appears in `GET /conversations/:id/mls/pending-devices` for every group + its user belongs to. +3. An existing member claims one of its key packages, builds an Add proposal and + a Commit, and publishes both — together with the Welcome — via + `POST /conversations/:id/mls/commits`. +4. The new device claims the Welcome and replays commits from its join epoch. + +Steps 3 and 4 are decoupled by design: the Welcome is held until the device +comes online, so a device can be added to groups while it is offline. + +## Endpoints + +All require `Authorization: Bearer ` and conversation membership. Routes +that need group state return `404` when the conversation has no MLS group. + +### `POST /conversations/:id/mls/group` + +Records the public group identifiers after the founding client creates the group +locally, and seats the founder's device at epoch 0. + +```json +{ "groupId": "base64", "cipherSuite": 1 } +``` + +`409` if the conversation already has a group; `400` for a DM. + +### `GET /conversations/:id/mls/group` + +```json +{ + "conversationId": "uuid", + "groupId": "base64", + "cipherSuite": 1, + "currentEpoch": 7, + "membership": { "joinedAtEpoch": 7, "removedAtEpoch": null, "active": true }, + "pendingWelcome": null, + "historyAvailableFromEpoch": 7 +} +``` + +`historyAvailableFromEpoch` is what a client should drive its UI from. Anything +before it is permanently unreadable on this device, so the client can render a +one-time explanation at the top of the timeline rather than a row of broken +messages. It is `null` until the device has a leaf. + +### `GET /conversations/:id/mls/pending-devices` + +Active devices of conversation members that hold no leaf yet — the set a +committer has to Add. + +```json +{ + "currentEpoch": 7, + "devices": [{ "deviceId": "uuid", "userId": "uuid", "identityPublicKey": "base64" }] +} +``` + +### `POST /conversations/:id/mls/commits` + +```json +{ + "epoch": 8, + "commit": "base64", + "addedDevices": [{ "deviceId": "uuid", "welcome": "base64" }], + "removedDeviceIds": ["uuid"] +} +``` + +- `epoch` must be exactly `currentEpoch + 1`. Two members committing at the same + moment both claim the same epoch; the group row is locked for the duration of + the write, so one applies and the other gets: + + ```json + { + "error": "Commit epoch conflict — another commit was applied first", + "currentEpoch": 8, + "expectedEpoch": 9 + } + ``` + + The loser re-derives its commit against the new epoch and retries. + +- Only a device that currently holds a leaf may commit (`403` otherwise). +- Added devices must be active devices of conversation members (`400` + otherwise), so a member cannot smuggle an arbitrary device into the group. +- A device cannot be added and removed by the same commit. + +On success the server emits `mls_commit` to the conversation room and +`mls_welcome_available` to each added device's room. + +### `GET /conversations/:id/mls/commits` + +Commit replay for a device catching up. + +- `?sinceEpoch=` defaults to the device's join epoch and is clamped to it — + earlier commits belong to a tree the device had no leaf in and cannot be + applied. +- `?limit=` up to 200. + +```json +{ "conversationId": "uuid", "sinceEpoch": 7, "currentEpoch": 9, "commits": [...], "hasMore": false } +``` + +### `GET /conversations/:id/mls/welcome` + +Claims the device's pending Welcome and marks it consumed. + +```json +{ + "conversationId": "uuid", + "groupId": "base64", + "cipherSuite": 1, + "epoch": 7, + "welcome": "base64", + "currentEpoch": 9 +} +``` + +`404` when nothing is pending. If several Welcomes queued up while the device +was offline, the newest is returned — it is the only one still usable. + +## How unavailable messages are surfaced + +Both message read paths — `GET /conversations/:id/messages` and the socket +`message_history` event — apply the same rule. A message whose `mlsEpoch` falls +outside the requesting device's window comes back with its ciphertext blanked +and a reason attached: + +```json +{ + "id": "uuid", + "senderId": "uuid", + "createdAt": "2026-01-01T00:00:00.000Z", + "mlsEpoch": 4, + "ciphertext": null, + "unavailable": true, + "unavailableReason": "mls_no_key_before_join" +} +``` + +Reason codes: + +| Code | Meaning | +| -------------------------- | --------------------------------------------- | +| `mls_no_key_before_join` | Sent before this device joined the group. | +| `mls_no_key_after_removal` | Sent after this device was removed. | +| `mls_not_a_group_member` | This device holds no leaf in the group (yet). | + +Three deliberate choices here: + +- **Not an error.** The server knows in advance that the device cannot decrypt + these messages. Returning ciphertext and letting decryption fail would be + indistinguishable, on the client, from tampering — and training users to + dismiss decryption failures destroys a signal that is supposed to mean + something. +- **Not omitted.** Sender, timestamp and epoch are preserved so the client can + render a placeholder in the correct position. A gap in the timeline is more + alarming than a labelled placeholder, and it hides that a conversation + happened at all. +- **Applied server-side.** The client cannot forget to check. + +Suggested client copy: _"This message was sent before you added this device."_ +For the group as a whole, use `historyAvailableFromEpoch` to show a single +divider rather than repeating the notice on every message. + +## Sending group messages + +An MLS group message carries one ciphertext encrypted to the group's epoch +secrets, and the sender includes `mlsEpoch` on the send: + +```json +{ + "conversationId": "uuid", + "messageId": "uuid", + "contentType": "text", + "ciphertext": "base64", + "mlsEpoch": 8 +} +``` + +When `mlsEpoch` is present the per-device envelope rules do not apply — there is +one ciphertext for the whole group, not one per recipient device. Sending +`envelopes` alongside `mlsEpoch` is rejected: mixing the two key-distribution +models on a single message leaves recipients with no unambiguous rule for which +ciphertext to decrypt. The sibling-device coverage check (#188) is likewise +skipped, since a group ciphertext already reaches every device in the tree. + +## Implementation references + +- schema: `apps/backend/src/db/schema.ts` (`mlsGroups`, `mlsGroupMembers`, `mlsCommits`, `mlsWelcomes`) +- migration: `apps/backend/drizzle/0001_mls_group_state.sql` +- group state service: `apps/backend/src/services/mlsGroups.ts` +- epoch visibility rule: `apps/backend/src/lib/mlsVisibility.ts` +- routes: `apps/backend/src/routes/mls.ts` +- history read paths: `apps/backend/src/routes/conversations.ts`, `apps/backend/src/socket/messaging.ts` +- tests: `apps/backend/src/__tests__/mls.routes.test.ts`, `mlsGroups.service.test.ts`, `mlsVisibility.test.ts`, `mls.history.test.ts` diff --git a/apps/backend/drizzle/0001_mls_group_state.sql b/apps/backend/drizzle/0001_mls_group_state.sql new file mode 100644 index 00000000..065356a9 --- /dev/null +++ b/apps/backend/drizzle/0001_mls_group_state.sql @@ -0,0 +1,55 @@ +CREATE TABLE "mls_commits" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "mls_group_id" uuid NOT NULL, + "epoch" bigint NOT NULL, + "committer_device_id" uuid, + "commit" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mls_group_members" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "mls_group_id" uuid NOT NULL, + "device_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "joined_at_epoch" bigint NOT NULL, + "removed_at_epoch" bigint, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mls_groups" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "conversation_id" uuid NOT NULL, + "group_id" text NOT NULL, + "cipher_suite" integer NOT NULL, + "current_epoch" bigint DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mls_welcomes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "mls_group_id" uuid NOT NULL, + "device_id" uuid NOT NULL, + "epoch" bigint NOT NULL, + "welcome" text NOT NULL, + "claimed_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "messages" ADD COLUMN "mls_epoch" bigint;--> statement-breakpoint +ALTER TABLE "mls_commits" ADD CONSTRAINT "mls_commits_mls_group_id_mls_groups_id_fk" FOREIGN KEY ("mls_group_id") REFERENCES "public"."mls_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_commits" ADD CONSTRAINT "mls_commits_committer_device_id_devices_id_fk" FOREIGN KEY ("committer_device_id") REFERENCES "public"."devices"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_group_members" ADD CONSTRAINT "mls_group_members_mls_group_id_mls_groups_id_fk" FOREIGN KEY ("mls_group_id") REFERENCES "public"."mls_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_group_members" ADD CONSTRAINT "mls_group_members_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_group_members" ADD CONSTRAINT "mls_group_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 "mls_groups" ADD CONSTRAINT "mls_groups_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_welcomes" ADD CONSTRAINT "mls_welcomes_mls_group_id_mls_groups_id_fk" FOREIGN KEY ("mls_group_id") REFERENCES "public"."mls_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mls_welcomes" ADD CONSTRAINT "mls_welcomes_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "mls_commits_group_epoch_idx" ON "mls_commits" USING btree ("mls_group_id","epoch");--> statement-breakpoint +CREATE UNIQUE INDEX "mls_group_members_active_idx" ON "mls_group_members" USING btree ("mls_group_id","device_id") WHERE "mls_group_members"."removed_at_epoch" IS NULL;--> statement-breakpoint +CREATE INDEX "mls_group_members_device_idx" ON "mls_group_members" USING btree ("device_id");--> statement-breakpoint +CREATE UNIQUE INDEX "mls_groups_conversation_idx" ON "mls_groups" USING btree ("conversation_id");--> statement-breakpoint +CREATE UNIQUE INDEX "mls_groups_group_id_idx" ON "mls_groups" USING btree ("group_id");--> statement-breakpoint +CREATE UNIQUE INDEX "mls_welcomes_group_device_epoch_idx" ON "mls_welcomes" USING btree ("mls_group_id","device_id","epoch");--> statement-breakpoint +CREATE INDEX "mls_welcomes_pending_idx" ON "mls_welcomes" USING btree ("device_id") WHERE "mls_welcomes"."claimed_at" IS NULL; \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0001_snapshot.json b/apps/backend/drizzle/meta/0001_snapshot.json new file mode 100644 index 00000000..8498b5a0 --- /dev/null +++ b/apps/backend/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1974 @@ +{ + "id": "4d8474b1-4de4-49e3-93a2-a8a320119373", + "prevId": "d5682005-cccf-4e2e-992d-d66a5d6d3f4c", + "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 + }, + "mls_epoch": { + "name": "mls_epoch", + "type": "bigint", + "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.mls_commits": { + "name": "mls_commits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mls_group_id": { + "name": "mls_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "epoch": { + "name": "epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "committer_device_id": { + "name": "committer_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "commit": { + "name": "commit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mls_commits_group_epoch_idx": { + "name": "mls_commits_group_epoch_idx", + "columns": [ + { + "expression": "mls_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mls_commits_mls_group_id_mls_groups_id_fk": { + "name": "mls_commits_mls_group_id_mls_groups_id_fk", + "tableFrom": "mls_commits", + "tableTo": "mls_groups", + "columnsFrom": [ + "mls_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mls_commits_committer_device_id_devices_id_fk": { + "name": "mls_commits_committer_device_id_devices_id_fk", + "tableFrom": "mls_commits", + "tableTo": "devices", + "columnsFrom": [ + "committer_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mls_group_members": { + "name": "mls_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mls_group_id": { + "name": "mls_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "joined_at_epoch": { + "name": "joined_at_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "removed_at_epoch": { + "name": "removed_at_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mls_group_members_active_idx": { + "name": "mls_group_members_active_idx", + "columns": [ + { + "expression": "mls_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mls_group_members\".\"removed_at_epoch\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mls_group_members_device_idx": { + "name": "mls_group_members_device_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mls_group_members_mls_group_id_mls_groups_id_fk": { + "name": "mls_group_members_mls_group_id_mls_groups_id_fk", + "tableFrom": "mls_group_members", + "tableTo": "mls_groups", + "columnsFrom": [ + "mls_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mls_group_members_device_id_devices_id_fk": { + "name": "mls_group_members_device_id_devices_id_fk", + "tableFrom": "mls_group_members", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mls_group_members_user_id_users_id_fk": { + "name": "mls_group_members_user_id_users_id_fk", + "tableFrom": "mls_group_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mls_groups": { + "name": "mls_groups", + "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 + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cipher_suite": { + "name": "cipher_suite", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "current_epoch": { + "name": "current_epoch", + "type": "bigint", + "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": { + "mls_groups_conversation_idx": { + "name": "mls_groups_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mls_groups_group_id_idx": { + "name": "mls_groups_group_id_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mls_groups_conversation_id_conversations_id_fk": { + "name": "mls_groups_conversation_id_conversations_id_fk", + "tableFrom": "mls_groups", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mls_welcomes": { + "name": "mls_welcomes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mls_group_id": { + "name": "mls_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "epoch": { + "name": "epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "welcome": { + "name": "welcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mls_welcomes_group_device_epoch_idx": { + "name": "mls_welcomes_group_device_epoch_idx", + "columns": [ + { + "expression": "mls_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mls_welcomes_pending_idx": { + "name": "mls_welcomes_pending_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mls_welcomes\".\"claimed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mls_welcomes_mls_group_id_mls_groups_id_fk": { + "name": "mls_welcomes_mls_group_id_mls_groups_id_fk", + "tableFrom": "mls_welcomes", + "tableTo": "mls_groups", + "columnsFrom": [ + "mls_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mls_welcomes_device_id_devices_id_fk": { + "name": "mls_welcomes_device_id_devices_id_fk", + "tableFrom": "mls_welcomes", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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": "", + "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()" + }, + "send_read_receipts": { + "name": "send_read_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "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.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": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 52ed62d6..e156c17b 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1784899426825, "tag": "0000_stale_mandarin", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1785395646991, + "tag": "0001_mls_group_state", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/src/__tests__/mls.history.test.ts b/apps/backend/src/__tests__/mls.history.test.ts new file mode 100644 index 00000000..0c85c480 --- /dev/null +++ b/apps/backend/src/__tests__/mls.history.test.ts @@ -0,0 +1,207 @@ +/** + * Tests that GET /conversations/:id/messages surfaces MLS group messages a + * device has no key for as placeholders rather than as errors (#372). + * + * This is the acceptance criterion a newly-linked device depends on: it joins + * at epoch N, sees everything from N on, and sees the earlier history as + * explicitly unavailable instead of as a failed decryption. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockMemberFindFirst = vi.fn(); +const mockMemberFindMany = vi.fn(); +const mockMessageFindMany = vi.fn(); +const mockMessageFindFirst = vi.fn(); +const mockGetConversationEpochWindow = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + conversations: { findFirst: vi.fn(), findMany: vi.fn() }, + conversationMembers: { findFirst: mockMemberFindFirst, findMany: mockMemberFindMany }, + messages: { findMany: mockMessageFindMany, findFirst: mockMessageFindFirst }, + devices: { findMany: vi.fn() }, + }, + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + select: vi.fn(), + transaction: vi.fn(), + }, +})); + +vi.mock('../db/schema.js', () => ({ + conversationMembers: { conversationId: 'conversationId', userId: 'userId' }, + conversations: { id: 'id' }, + messages: { id: 'id', conversationId: 'conversationId', createdAt: 'createdAt' }, + messageEnvelopes: { recipientDeviceId: 'recipientDeviceId' }, + tokenTransfers: {}, + devices: {}, +})); + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), + asc: vi.fn((col: unknown) => col), + count: vi.fn(), + desc: vi.fn((col: unknown) => col), + eq: vi.fn((col: unknown, val: unknown) => ({ op: 'eq', col, val })), + inArray: vi.fn(), + lt: vi.fn((col: unknown, val: unknown) => ({ op: 'lt', col, val })), + ne: vi.fn(), + or: vi.fn((...args: unknown[]) => ({ op: 'or', args })), + sql: vi.fn(), +})); + +vi.mock('../lib/redis.js', () => ({ + redis: null, + CONV_CACHE_TTL: 60, + convCacheKey: (id: string) => `conv:${id}`, +})); +vi.mock('../lib/conversationCache.js', () => ({ invalidateConversationCaches: vi.fn() })); +vi.mock('../lib/socket.js', () => ({ getSocketServer: vi.fn(() => null) })); +vi.mock('../services/roomManager.js', () => ({ + conversationRoom: (id: string) => `room:conversation:${id}`, +})); +vi.mock('../services/mlsGroups.js', () => ({ + getConversationEpochWindow: mockGetConversationEpochWindow, +})); + +const DEVICE_ID = 'device-new'; + +vi.mock('../middleware/auth.js', () => ({ + requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { + userId: 'user-1', + deviceId: DEVICE_ID, + }; + next(); + }, +})); + +const { conversationsRouter } = await import('../routes/conversations.js'); + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use('/conversations', conversationsRouter); + return app; +} + +function message(id: string, mlsEpoch: number | null) { + return { + id, + conversationId: 'conv-1', + senderId: 'user-2', + ciphertext: `ciphertext-${id}`, + mlsEpoch, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockMemberFindFirst.mockResolvedValue({ id: 'membership-1' }); +}); + +describe('GET /conversations/:id/messages — MLS epoch visibility', () => { + it('leaves messages untouched for a conversation with no MLS group', async () => { + mockGetConversationEpochWindow.mockResolvedValue({ hasGroup: false, window: null }); + mockMessageFindMany.mockResolvedValue([message('m1', null)]); + + const res = await request(makeApp()).get('/conversations/conv-1/messages'); + + expect(res.status).toBe(200); + expect(res.body.messages[0].ciphertext).toBe('ciphertext-m1'); + expect(res.body.messages[0].unavailable).toBeUndefined(); + }); + + it('returns pre-join epochs as unavailable and post-join epochs intact', async () => { + mockGetConversationEpochWindow.mockResolvedValue({ + hasGroup: true, + window: { joinedAtEpoch: 3, removedAtEpoch: null }, + }); + // Handler returns oldest-first, so this arrives reversed. + mockMessageFindMany.mockResolvedValue([message('m3', 4), message('m2', 3), message('m1', 1)]); + + const res = await request(makeApp()).get('/conversations/conv-1/messages'); + + expect(res.status).toBe(200); + const [oldest, atJoin, afterJoin] = res.body.messages; + + expect(oldest.id).toBe('m1'); + expect(oldest.ciphertext).toBeNull(); + expect(oldest.unavailable).toBe(true); + expect(oldest.unavailableReason).toBe('mls_no_key_before_join'); + + expect(atJoin.id).toBe('m2'); + expect(atJoin.ciphertext).toBe('ciphertext-m2'); + + expect(afterJoin.id).toBe('m3'); + expect(afterJoin.ciphertext).toBe('ciphertext-m3'); + }); + + it('keeps unavailable messages in the timeline with their metadata', async () => { + mockGetConversationEpochWindow.mockResolvedValue({ + hasGroup: true, + window: { joinedAtEpoch: 3, removedAtEpoch: null }, + }); + mockMessageFindMany.mockResolvedValue([message('m1', 1)]); + + const res = await request(makeApp()).get('/conversations/conv-1/messages'); + + // The message is a placeholder, not a hole: the client can still render it + // in the right position with the right sender. + expect(res.body.messages).toHaveLength(1); + expect(res.body.messages[0]).toMatchObject({ + id: 'm1', + senderId: 'user-2', + mlsEpoch: 1, + unavailable: true, + }); + }); + + it('does not touch non-MLS messages interleaved in an MLS conversation', async () => { + mockGetConversationEpochWindow.mockResolvedValue({ + hasGroup: true, + window: { joinedAtEpoch: 3, removedAtEpoch: null }, + }); + // A system event written before the device joined has no epoch. + mockMessageFindMany.mockResolvedValue([message('sys', null)]); + + const res = await request(makeApp()).get('/conversations/conv-1/messages'); + + expect(res.body.messages[0].ciphertext).toBe('ciphertext-sys'); + expect(res.body.messages[0].unavailable).toBeUndefined(); + }); + + it('marks every MLS message unavailable for a device that has not joined yet', async () => { + mockGetConversationEpochWindow.mockResolvedValue({ hasGroup: true, window: null }); + mockMessageFindMany.mockResolvedValue([message('m1', 4)]); + + const res = await request(makeApp()).get('/conversations/conv-1/messages'); + + expect(res.status).toBe(200); + expect(res.body.messages[0].unavailableReason).toBe('mls_not_a_group_member'); + }); + + it('stops at the removal epoch for a device that was removed', async () => { + mockGetConversationEpochWindow.mockResolvedValue({ + hasGroup: true, + window: { joinedAtEpoch: 1, removedAtEpoch: 4 }, + }); + mockMessageFindMany.mockResolvedValue([message('m2', 4), message('m1', 3)]); + + const res = await request(makeApp()).get('/conversations/conv-1/messages'); + + const [before, after] = res.body.messages; + expect(before.ciphertext).toBe('ciphertext-m1'); + expect(after.ciphertext).toBeNull(); + expect(after.unavailableReason).toBe('mls_no_key_after_removal'); + }); +}); diff --git a/apps/backend/src/__tests__/mls.routes.test.ts b/apps/backend/src/__tests__/mls.routes.test.ts new file mode 100644 index 00000000..115b2b21 --- /dev/null +++ b/apps/backend/src/__tests__/mls.routes.test.ts @@ -0,0 +1,500 @@ +/** + * Tests for the MLS group routes (#372) under /conversations/:id/mls/*. + * + * The group-state service is mocked here so these cases stay focused on the + * HTTP contract: authorisation, request validation, status codes and the + * socket events a commit fans out. The service's own transaction behaviour is + * covered in mlsGroups.service.test.ts. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockMemberFindFirst = vi.fn(); +const mockMemberFindMany = vi.fn(); +const mockConversationFindFirst = vi.fn(); +const mockDeviceFindMany = vi.fn(); +const mockTransaction = vi.fn(); +const mockEmit = vi.fn(); +const mockTo = vi.fn(() => ({ emit: mockEmit })); + +const mockGetGroupByConversation = vi.fn(); +const mockGetEpochWindow = vi.fn(); +const mockIsActiveMember = vi.fn(); +const mockListDevicesAwaitingJoin = vi.fn(); +const mockRecordCommit = vi.fn(); +const mockListCommitsSince = vi.fn(); +const mockClaimWelcome = vi.fn(); +const mockPendingWelcomeEpoch = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + conversationMembers: { findFirst: mockMemberFindFirst, findMany: mockMemberFindMany }, + conversations: { findFirst: mockConversationFindFirst }, + devices: { findMany: mockDeviceFindMany }, + }, + transaction: mockTransaction, + insert: vi.fn(), + update: vi.fn(), + select: vi.fn(), + }, +})); + +vi.mock('../db/schema.js', () => ({ + conversationMembers: { conversationId: 'conversationId', userId: 'userId' }, + conversations: { id: 'id' }, + devices: { id: 'id', userId: 'userId', revokedAt: 'revokedAt' }, + mlsGroups: { id: 'id', currentEpoch: 'currentEpoch' }, + mlsGroupMembers: {}, +})); + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((col: unknown, val: unknown) => ({ op: 'eq', col, val })), + and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), + inArray: vi.fn(), + isNull: vi.fn(), +})); + +vi.mock('../lib/socket.js', () => ({ getSocketServer: vi.fn(() => ({ to: mockTo })) })); +vi.mock('../services/roomManager.js', () => ({ + conversationRoom: (id: string) => `room:conversation:${id}`, +})); +vi.mock('../services/deliveryPipeline.js', () => ({ + deviceRoom: (id: string) => `room:device:${id}`, +})); + +vi.mock('../services/mlsGroups.js', () => ({ + MLS_COMMIT_PAGE_SIZE: 200, + MLS_MAX_COMMIT_MEMBER_CHANGES: 100, + getGroupByConversation: mockGetGroupByConversation, + getEpochWindow: mockGetEpochWindow, + isActiveMember: mockIsActiveMember, + listDevicesAwaitingJoin: mockListDevicesAwaitingJoin, + recordCommit: mockRecordCommit, + listCommitsSince: mockListCommitsSince, + claimWelcome: mockClaimWelcome, + pendingWelcomeEpoch: mockPendingWelcomeEpoch, +})); + +const USER_ID = 'user-1'; +const DEVICE_ID = '11111111-1111-4111-8111-111111111111'; +const OTHER_DEVICE_ID = '22222222-2222-4222-8222-222222222222'; +const CONVERSATION_ID = 'conv-1'; + +vi.mock('../middleware/auth.js', () => ({ + requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { + userId: USER_ID, + deviceId: DEVICE_ID, + }; + next(); + }, +})); + +const { mlsRouter } = await import('../routes/mls.js'); + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use('/conversations', mlsRouter); + return app; +} + +/** Base64 stand-in for an opaque MLS wire-format blob. */ +const BLOB = Buffer.alloc(64, 'x').toString('base64'); + +const GROUP = { + id: 'mls-group-1', + conversationId: CONVERSATION_ID, + groupId: BLOB, + cipherSuite: 1, + currentEpoch: 4, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockTo.mockReturnValue({ emit: mockEmit }); + mockMemberFindFirst.mockResolvedValue({ id: 'membership-1' }); + mockGetGroupByConversation.mockResolvedValue(GROUP); + mockPendingWelcomeEpoch.mockResolvedValue(null); +}); + +// ── Shared authorisation behaviour ──────────────────────────────────────────── + +describe('MLS routes: authorisation', () => { + it('returns 403 on every route when the caller is not a conversation member', async () => { + mockMemberFindFirst.mockResolvedValue(undefined); + + const app = makeApp(); + const responses = await Promise.all([ + request(app).get(`/conversations/${CONVERSATION_ID}/mls/group`), + request(app).get(`/conversations/${CONVERSATION_ID}/mls/pending-devices`), + request(app).get(`/conversations/${CONVERSATION_ID}/mls/commits`), + request(app).get(`/conversations/${CONVERSATION_ID}/mls/welcome`), + ]); + + for (const res of responses) { + expect(res.status).toBe(403); + } + }); + + it('returns 404 when the conversation has no MLS group', async () => { + mockGetGroupByConversation.mockResolvedValue(null); + + const res = await request(makeApp()).get(`/conversations/${CONVERSATION_ID}/mls/group`); + + expect(res.status).toBe(404); + expect(res.body.error).toMatch(/no MLS group/i); + }); +}); + +// ── Group initialisation ────────────────────────────────────────────────────── + +describe('POST /conversations/:id/mls/group', () => { + const body = { groupId: BLOB, cipherSuite: 1 }; + + it('seats the founding device at epoch 0', async () => { + mockGetGroupByConversation.mockResolvedValue(null); + mockConversationFindFirst.mockResolvedValue({ type: 'group' }); + + const memberValues = vi.fn().mockResolvedValue(undefined); + const tx = { + insert: vi + .fn() + .mockReturnValueOnce({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: 'mls-group-1', currentEpoch: 0 }]), + }), + }) + .mockReturnValueOnce({ values: memberValues }), + }; + mockTransaction.mockImplementation(async (cb: (t: typeof tx) => unknown) => cb(tx)); + + const res = await request(makeApp()) + .post(`/conversations/${CONVERSATION_ID}/mls/group`) + .send(body); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ groupId: BLOB, cipherSuite: 1, currentEpoch: 0 }); + expect(memberValues).toHaveBeenCalledWith( + expect.objectContaining({ deviceId: DEVICE_ID, userId: USER_ID, joinedAtEpoch: 0 }), + ); + }); + + it('returns 409 when the conversation already has a group', async () => { + const res = await request(makeApp()) + .post(`/conversations/${CONVERSATION_ID}/mls/group`) + .send(body); + + expect(res.status).toBe(409); + expect(res.body.currentEpoch).toBe(4); + }); + + it('refuses to create a group for a DM', async () => { + mockGetGroupByConversation.mockResolvedValue(null); + mockConversationFindFirst.mockResolvedValue({ type: 'dm' }); + + const res = await request(makeApp()) + .post(`/conversations/${CONVERSATION_ID}/mls/group`) + .send(body); + + expect(res.status).toBe(400); + expect(mockTransaction).not.toHaveBeenCalled(); + }); + + it('rejects a groupId that is not base64', async () => { + mockGetGroupByConversation.mockResolvedValue(null); + + const res = await request(makeApp()) + .post(`/conversations/${CONVERSATION_ID}/mls/group`) + .send({ groupId: 'not base64!', cipherSuite: 1 }); + + expect(res.status).toBe(400); + }); +}); + +// ── Group state ─────────────────────────────────────────────────────────────── + +describe('GET /conversations/:id/mls/group', () => { + it('reports the device epoch window and where its readable history starts', async () => { + mockGetEpochWindow.mockResolvedValue({ joinedAtEpoch: 3, removedAtEpoch: null }); + + const res = await request(makeApp()).get(`/conversations/${CONVERSATION_ID}/mls/group`); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + groupId: BLOB, + cipherSuite: 1, + currentEpoch: 4, + membership: { joinedAtEpoch: 3, removedAtEpoch: null, active: true }, + historyAvailableFromEpoch: 3, + }); + }); + + it('reports a null membership and a pending Welcome for a device awaiting join', async () => { + mockGetEpochWindow.mockResolvedValue(null); + mockPendingWelcomeEpoch.mockResolvedValue(5); + + const res = await request(makeApp()).get(`/conversations/${CONVERSATION_ID}/mls/group`); + + expect(res.status).toBe(200); + expect(res.body.membership).toBeNull(); + expect(res.body.pendingWelcome).toEqual({ epoch: 5 }); + expect(res.body.historyAvailableFromEpoch).toBeNull(); + }); + + it('reports a removed device as inactive', async () => { + mockGetEpochWindow.mockResolvedValue({ joinedAtEpoch: 1, removedAtEpoch: 3 }); + + const res = await request(makeApp()).get(`/conversations/${CONVERSATION_ID}/mls/group`); + + expect(res.body.membership).toEqual({ joinedAtEpoch: 1, removedAtEpoch: 3, active: false }); + }); +}); + +// ── Devices awaiting join ───────────────────────────────────────────────────── + +describe('GET /conversations/:id/mls/pending-devices', () => { + it('lists member devices that hold no leaf yet', async () => { + mockListDevicesAwaitingJoin.mockResolvedValue([ + { deviceId: OTHER_DEVICE_ID, userId: USER_ID, identityPublicKey: 'idk' }, + ]); + + const res = await request(makeApp()).get( + `/conversations/${CONVERSATION_ID}/mls/pending-devices`, + ); + + expect(res.status).toBe(200); + expect(res.body.currentEpoch).toBe(4); + expect(res.body.devices).toEqual([ + { deviceId: OTHER_DEVICE_ID, userId: USER_ID, identityPublicKey: 'idk' }, + ]); + }); +}); + +// ── Commits ─────────────────────────────────────────────────────────────────── + +describe('POST /conversations/:id/mls/commits', () => { + const url = `/conversations/${CONVERSATION_ID}/mls/commits`; + + beforeEach(() => { + mockIsActiveMember.mockResolvedValue(true); + mockMemberFindMany.mockResolvedValue([{ userId: USER_ID }]); + mockDeviceFindMany.mockResolvedValue([{ id: OTHER_DEVICE_ID, userId: USER_ID }]); + mockRecordCommit.mockResolvedValue({ ok: true, epoch: 5 }); + }); + + it('refuses a commit from a device that holds no leaf', async () => { + mockIsActiveMember.mockResolvedValue(false); + + const res = await request(makeApp()).post(url).send({ epoch: 5, commit: BLOB }); + + expect(res.status).toBe(403); + expect(mockRecordCommit).not.toHaveBeenCalled(); + }); + + it('adds a device, records the Welcome and notifies both rooms', async () => { + const res = await request(makeApp()) + .post(url) + .send({ + epoch: 5, + commit: BLOB, + addedDevices: [{ deviceId: OTHER_DEVICE_ID, welcome: BLOB }], + }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ epoch: 5, addedDeviceIds: [OTHER_DEVICE_ID] }); + + expect(mockRecordCommit).toHaveBeenCalledWith( + expect.objectContaining({ + epoch: 5, + committerDeviceId: DEVICE_ID, + addedDevices: [{ deviceId: OTHER_DEVICE_ID, userId: USER_ID, welcome: BLOB }], + }), + ); + + expect(mockTo).toHaveBeenCalledWith(`room:conversation:${CONVERSATION_ID}`); + expect(mockEmit).toHaveBeenCalledWith('mls_commit', expect.objectContaining({ epoch: 5 })); + expect(mockTo).toHaveBeenCalledWith(`room:device:${OTHER_DEVICE_ID}`); + expect(mockEmit).toHaveBeenCalledWith( + 'mls_welcome_available', + expect.objectContaining({ epoch: 5, conversationId: CONVERSATION_ID }), + ); + }); + + it('returns 409 with the epoch to rebase on when another commit won the race', async () => { + mockRecordCommit.mockResolvedValue({ ok: false, reason: 'epoch_conflict', currentEpoch: 5 }); + + const res = await request(makeApp()).post(url).send({ epoch: 5, commit: BLOB }); + + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ currentEpoch: 5, expectedEpoch: 6 }); + expect(mockEmit).not.toHaveBeenCalled(); + }); + + it('rejects adding a device that belongs to a non-member', async () => { + mockDeviceFindMany.mockResolvedValue([{ id: OTHER_DEVICE_ID, userId: 'outsider' }]); + + const res = await request(makeApp()) + .post(url) + .send({ + epoch: 5, + commit: BLOB, + addedDevices: [{ deviceId: OTHER_DEVICE_ID, welcome: BLOB }], + }); + + expect(res.status).toBe(400); + expect(res.body.invalidDeviceIds).toEqual([OTHER_DEVICE_ID]); + expect(mockRecordCommit).not.toHaveBeenCalled(); + }); + + it('rejects adding a device that does not exist or is revoked', async () => { + mockDeviceFindMany.mockResolvedValue([]); + + const res = await request(makeApp()) + .post(url) + .send({ + epoch: 5, + commit: BLOB, + addedDevices: [{ deviceId: OTHER_DEVICE_ID, welcome: BLOB }], + }); + + expect(res.status).toBe(400); + expect(mockRecordCommit).not.toHaveBeenCalled(); + }); + + it('rejects a commit that both adds and removes the same device', async () => { + const res = await request(makeApp()) + .post(url) + .send({ + epoch: 5, + commit: BLOB, + addedDevices: [{ deviceId: OTHER_DEVICE_ID, welcome: BLOB }], + removedDeviceIds: [OTHER_DEVICE_ID], + }); + + expect(res.status).toBe(400); + expect(mockRecordCommit).not.toHaveBeenCalled(); + }); + + it('rejects duplicate deviceIds in addedDevices', async () => { + const res = await request(makeApp()) + .post(url) + .send({ + epoch: 5, + commit: BLOB, + addedDevices: [ + { deviceId: OTHER_DEVICE_ID, welcome: BLOB }, + { deviceId: OTHER_DEVICE_ID, welcome: BLOB }, + ], + }); + + expect(res.status).toBe(400); + expect(mockRecordCommit).not.toHaveBeenCalled(); + }); + + it('passes removals through to the service', async () => { + const res = await request(makeApp()) + .post(url) + .send({ epoch: 5, commit: BLOB, removedDeviceIds: [OTHER_DEVICE_ID] }); + + expect(res.status).toBe(201); + expect(mockRecordCommit).toHaveBeenCalledWith( + expect.objectContaining({ removedDeviceIds: [OTHER_DEVICE_ID] }), + ); + }); + + it('rejects epoch 0 — a commit always advances the group', async () => { + const res = await request(makeApp()).post(url).send({ epoch: 0, commit: BLOB }); + + expect(res.status).toBe(400); + }); +}); + +// ── Commit replay ───────────────────────────────────────────────────────────── + +describe('GET /conversations/:id/mls/commits', () => { + const url = `/conversations/${CONVERSATION_ID}/mls/commits`; + + it('returns 403 for a device that holds no leaf', async () => { + mockGetEpochWindow.mockResolvedValue(null); + + const res = await request(makeApp()).get(url); + + expect(res.status).toBe(403); + }); + + it('defaults sinceEpoch to the device join epoch', async () => { + mockGetEpochWindow.mockResolvedValue({ joinedAtEpoch: 3, removedAtEpoch: null }); + mockListCommitsSince.mockResolvedValue([{ epoch: 4, commit: BLOB, createdAt: new Date() }]); + + const res = await request(makeApp()).get(url); + + expect(res.status).toBe(200); + expect(res.body.sinceEpoch).toBe(3); + expect(mockListCommitsSince).toHaveBeenCalledWith('mls-group-1', 3, 200); + expect(res.body.hasMore).toBe(false); + }); + + it('never replays behind the join epoch even when asked to', async () => { + mockGetEpochWindow.mockResolvedValue({ joinedAtEpoch: 3, removedAtEpoch: null }); + mockListCommitsSince.mockResolvedValue([]); + + const res = await request(makeApp()).get(`${url}?sinceEpoch=0`); + + expect(res.body.sinceEpoch).toBe(3); + expect(mockListCommitsSince).toHaveBeenCalledWith('mls-group-1', 3, 200); + }); + + it('reports hasMore when the page stops short of the current epoch', async () => { + mockGetEpochWindow.mockResolvedValue({ joinedAtEpoch: 0, removedAtEpoch: null }); + mockListCommitsSince.mockResolvedValue([{ epoch: 2, commit: BLOB, createdAt: new Date() }]); + + const res = await request(makeApp()).get(`${url}?limit=1`); + + expect(res.body.hasMore).toBe(true); + expect(mockListCommitsSince).toHaveBeenCalledWith('mls-group-1', 0, 1); + }); + + it('rejects a negative sinceEpoch', async () => { + mockGetEpochWindow.mockResolvedValue({ joinedAtEpoch: 0, removedAtEpoch: null }); + + const res = await request(makeApp()).get(`${url}?sinceEpoch=-1`); + + expect(res.status).toBe(400); + }); +}); + +// ── Welcome ─────────────────────────────────────────────────────────────────── + +describe('GET /conversations/:id/mls/welcome', () => { + const url = `/conversations/${CONVERSATION_ID}/mls/welcome`; + + it('returns the Welcome together with the group parameters needed to import it', async () => { + mockClaimWelcome.mockResolvedValue({ epoch: 5, welcome: BLOB }); + + const res = await request(makeApp()).get(url); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + groupId: BLOB, + cipherSuite: 1, + epoch: 5, + welcome: BLOB, + currentEpoch: 4, + }); + expect(mockClaimWelcome).toHaveBeenCalledWith('mls-group-1', DEVICE_ID); + }); + + it('returns 404 when nothing is pending', async () => { + mockClaimWelcome.mockResolvedValue(null); + + const res = await request(makeApp()).get(url); + + expect(res.status).toBe(404); + }); +}); diff --git a/apps/backend/src/__tests__/mlsGroups.service.test.ts b/apps/backend/src/__tests__/mlsGroups.service.test.ts new file mode 100644 index 00000000..6d83b6bf --- /dev/null +++ b/apps/backend/src/__tests__/mlsGroups.service.test.ts @@ -0,0 +1,290 @@ +/** + * Tests for the MLS group state service (#372). + * + * The behaviour that matters here is transactional: a commit either applies + * completely at the epoch it claims or not at all, and a Welcome is handed out + * once. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockTransaction = vi.fn(); +const mockMlsGroupMemberFindFirst = vi.fn(); +const mockMlsWelcomeFindFirst = vi.fn(); +const mockMemberFindMany = vi.fn(); +const mockDeviceFindMany = vi.fn(); +const mockGroupMemberFindMany = vi.fn(); +const mockSelect = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + mlsGroups: { findFirst: vi.fn() }, + mlsGroupMembers: { + findFirst: mockMlsGroupMemberFindFirst, + findMany: mockGroupMemberFindMany, + }, + mlsWelcomes: { findFirst: mockMlsWelcomeFindFirst }, + conversationMembers: { findMany: mockMemberFindMany }, + devices: { findMany: mockDeviceFindMany }, + }, + transaction: mockTransaction, + select: mockSelect, + }, +})); + +vi.mock('../db/schema.js', () => ({ + conversationMembers: { conversationId: 'conversationId', userId: 'userId' }, + devices: { id: 'id', userId: 'userId', revokedAt: 'revokedAt' }, + mlsCommits: { id: 'id', mlsGroupId: 'mlsGroupId', epoch: 'epoch', commit: 'commit' }, + mlsGroupMembers: { + id: 'id', + mlsGroupId: 'mlsGroupId', + deviceId: 'deviceId', + joinedAtEpoch: 'joinedAtEpoch', + removedAtEpoch: 'removedAtEpoch', + }, + mlsGroups: { id: 'id', currentEpoch: 'currentEpoch' }, + mlsWelcomes: { + id: 'id', + mlsGroupId: 'mlsGroupId', + deviceId: 'deviceId', + epoch: 'epoch', + welcome: 'welcome', + claimedAt: 'claimedAt', + }, +})); + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), + asc: vi.fn((col: unknown) => col), + desc: vi.fn((col: unknown) => col), + eq: vi.fn((col: unknown, val: unknown) => ({ op: 'eq', col, val })), + gt: vi.fn((col: unknown, val: unknown) => ({ op: 'gt', col, val })), + inArray: vi.fn((col: unknown, vals: unknown) => ({ op: 'inArray', col, vals })), + isNull: vi.fn((col: unknown) => ({ op: 'isNull', col })), +})); + +const { claimWelcome, getEpochWindow, listDevicesAwaitingJoin, recordCommit } = + await import('../services/mlsGroups.js'); + +const GROUP_ID = 'mls-group-1'; +const DEVICE_A = 'device-a'; +const DEVICE_B = 'device-b'; + +/** Transaction double for recordCommit. */ +function setupCommitTx(currentEpoch: number | null) { + const commitValues = vi.fn().mockResolvedValue(undefined); + const memberValues = vi.fn().mockResolvedValue(undefined); + const welcomeValues = vi.fn().mockResolvedValue(undefined); + const removeWhere = vi.fn().mockResolvedValue(undefined); + const groupWhere = vi.fn().mockResolvedValue(undefined); + + const inserts = [{ values: commitValues }, { values: memberValues }, { values: welcomeValues }]; + let insertCall = 0; + + const tx = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + for: vi + .fn() + .mockResolvedValue(currentEpoch === null ? [] : [{ id: GROUP_ID, currentEpoch }]), + }), + }), + }), + }), + insert: vi.fn(() => inserts[insertCall++] ?? { values: vi.fn().mockResolvedValue(undefined) }), + update: vi + .fn() + .mockReturnValueOnce({ set: vi.fn().mockReturnValue({ where: removeWhere }) }) + .mockReturnValue({ set: vi.fn().mockReturnValue({ where: groupWhere }) }), + }; + + mockTransaction.mockImplementation(async (cb: (t: typeof tx) => unknown) => cb(tx)); + + return { tx, commitValues, memberValues, welcomeValues, removeWhere, groupWhere }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('recordCommit', () => { + const base = { + mlsGroupId: GROUP_ID, + committerDeviceId: DEVICE_A, + commit: 'commit-blob', + addedDevices: [], + removedDeviceIds: [], + }; + + it('applies a commit that claims exactly currentEpoch + 1', async () => { + const { commitValues } = setupCommitTx(4); + + const result = await recordCommit({ ...base, epoch: 5 }); + + expect(result).toEqual({ ok: true, epoch: 5 }); + expect(commitValues).toHaveBeenCalledWith( + expect.objectContaining({ mlsGroupId: GROUP_ID, epoch: 5, committerDeviceId: DEVICE_A }), + ); + }); + + it('rejects a commit that skips an epoch', async () => { + const { commitValues } = setupCommitTx(4); + + const result = await recordCommit({ ...base, epoch: 7 }); + + expect(result).toEqual({ ok: false, reason: 'epoch_conflict', currentEpoch: 4 }); + expect(commitValues).not.toHaveBeenCalled(); + }); + + it('rejects a commit for an epoch that was already applied', async () => { + // The losing side of a two-committer race: the group moved to 5 first. + const { commitValues } = setupCommitTx(5); + + const result = await recordCommit({ ...base, epoch: 5 }); + + expect(result).toEqual({ ok: false, reason: 'epoch_conflict', currentEpoch: 5 }); + expect(commitValues).not.toHaveBeenCalled(); + }); + + it('opens the added device window at the commit epoch and queues its Welcome', async () => { + const { memberValues, welcomeValues } = setupCommitTx(4); + + await recordCommit({ + ...base, + epoch: 5, + addedDevices: [{ deviceId: DEVICE_B, userId: 'user-b', welcome: 'welcome-blob' }], + }); + + expect(memberValues).toHaveBeenCalledWith([ + expect.objectContaining({ deviceId: DEVICE_B, userId: 'user-b', joinedAtEpoch: 5 }), + ]); + expect(welcomeValues).toHaveBeenCalledWith([ + expect.objectContaining({ deviceId: DEVICE_B, epoch: 5, welcome: 'welcome-blob' }), + ]); + }); + + it('closes the removed device window at the commit epoch', async () => { + const { tx, removeWhere } = setupCommitTx(4); + + await recordCommit({ ...base, epoch: 5, removedDeviceIds: [DEVICE_B] }); + + expect(tx.update).toHaveBeenCalled(); + expect(removeWhere).toHaveBeenCalled(); + const setArg = tx.update.mock.results[0]!.value.set.mock.calls[0]![0] as Record< + string, + unknown + >; + expect(setArg).toEqual({ removedAtEpoch: 5 }); + }); + + it('does not touch membership when the commit changes no members', async () => { + const { tx, memberValues } = setupCommitTx(4); + + await recordCommit({ ...base, epoch: 5 }); + + expect(memberValues).not.toHaveBeenCalled(); + // Only the group's currentEpoch bump. + expect(tx.update).toHaveBeenCalledTimes(1); + }); + + it('treats a missing group as a conflict rather than throwing', async () => { + setupCommitTx(null); + + const result = await recordCommit({ ...base, epoch: 1 }); + + expect(result).toEqual({ ok: false, reason: 'epoch_conflict', currentEpoch: 0 }); + }); +}); + +describe('getEpochWindow', () => { + it('returns the device epoch interval', async () => { + mockMlsGroupMemberFindFirst.mockResolvedValue({ joinedAtEpoch: 3, removedAtEpoch: null }); + + expect(await getEpochWindow(GROUP_ID, DEVICE_A)).toEqual({ + joinedAtEpoch: 3, + removedAtEpoch: null, + }); + }); + + it('returns null for a device with no leaf in the group', async () => { + mockMlsGroupMemberFindFirst.mockResolvedValue(undefined); + + expect(await getEpochWindow(GROUP_ID, DEVICE_A)).toBeNull(); + }); +}); + +describe('listDevicesAwaitingJoin', () => { + it('excludes devices that already hold a leaf', async () => { + mockMemberFindMany.mockResolvedValue([{ userId: 'user-a' }]); + mockDeviceFindMany.mockResolvedValue([ + { id: DEVICE_A, userId: 'user-a', identityPublicKey: 'idk-a' }, + { id: DEVICE_B, userId: 'user-a', identityPublicKey: 'idk-b' }, + ]); + mockGroupMemberFindMany.mockResolvedValue([{ deviceId: DEVICE_A }]); + + const pending = await listDevicesAwaitingJoin('conv-1', GROUP_ID); + + expect(pending).toEqual([{ deviceId: DEVICE_B, userId: 'user-a', identityPublicKey: 'idk-b' }]); + }); + + it('returns an empty list when the conversation has no members', async () => { + mockMemberFindMany.mockResolvedValue([]); + + expect(await listDevicesAwaitingJoin('conv-1', GROUP_ID)).toEqual([]); + expect(mockDeviceFindMany).not.toHaveBeenCalled(); + }); + + it('returns an empty list when every member device is already seated', async () => { + mockMemberFindMany.mockResolvedValue([{ userId: 'user-a' }]); + mockDeviceFindMany.mockResolvedValue([ + { id: DEVICE_A, userId: 'user-a', identityPublicKey: 'idk-a' }, + ]); + mockGroupMemberFindMany.mockResolvedValue([{ deviceId: DEVICE_A }]); + + expect(await listDevicesAwaitingJoin('conv-1', GROUP_ID)).toEqual([]); + }); +}); + +describe('claimWelcome', () => { + function setupWelcomeTx(pending: { id: string; epoch: number; welcome: string } | null) { + 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(pending ? [pending] : []), + }), + }), + }), + }), + }), + update: vi.fn().mockReturnValue({ set: vi.fn().mockReturnValue({ where: updateWhere }) }), + }; + mockTransaction.mockImplementation(async (cb: (t: typeof tx) => unknown) => cb(tx)); + return { tx, updateWhere }; + } + + it('returns the Welcome and marks it claimed in the same transaction', async () => { + const { updateWhere } = setupWelcomeTx({ id: 'w1', epoch: 5, welcome: 'welcome-blob' }); + + const claimed = await claimWelcome(GROUP_ID, DEVICE_B); + + expect(claimed).toEqual({ epoch: 5, welcome: 'welcome-blob' }); + expect(updateWhere).toHaveBeenCalled(); + }); + + it('returns null and leaves nothing to update when none is pending', async () => { + const { tx } = setupWelcomeTx(null); + + expect(await claimWelcome(GROUP_ID, DEVICE_B)).toBeNull(); + expect(tx.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/__tests__/mlsVisibility.test.ts b/apps/backend/src/__tests__/mlsVisibility.test.ts new file mode 100644 index 00000000..4f819850 --- /dev/null +++ b/apps/backend/src/__tests__/mlsVisibility.test.ts @@ -0,0 +1,128 @@ +/** + * Tests for MLS epoch visibility (#372). + * + * The property under test: a device reads exactly the epochs its leaf existed + * for, and everything else comes back as a placeholder rather than as an error + * or as ciphertext it cannot decrypt. + */ + +import { describe, it, expect } from 'vitest'; +import { + MLS_UNAVAILABLE_AFTER_REMOVAL, + MLS_UNAVAILABLE_BEFORE_JOIN, + MLS_UNAVAILABLE_NOT_A_MEMBER, + applyMlsVisibility, + mlsUnavailableReason, +} from '../lib/mlsVisibility.js'; + +describe('mlsUnavailableReason', () => { + it('allows the exact epoch a device joined at', () => { + expect(mlsUnavailableReason(5, { joinedAtEpoch: 5, removedAtEpoch: null })).toBeNull(); + }); + + it('allows every epoch after the join', () => { + expect(mlsUnavailableReason(9, { joinedAtEpoch: 5, removedAtEpoch: null })).toBeNull(); + }); + + it('rejects the epoch immediately before the join', () => { + expect(mlsUnavailableReason(4, { joinedAtEpoch: 5, removedAtEpoch: null })).toBe( + MLS_UNAVAILABLE_BEFORE_JOIN, + ); + }); + + it('allows the last epoch before removal', () => { + expect(mlsUnavailableReason(7, { joinedAtEpoch: 2, removedAtEpoch: 8 })).toBeNull(); + }); + + it('rejects the removal epoch itself', () => { + // The commit that removes a device rekeys the group, so the epoch it + // produces is already out of reach. + expect(mlsUnavailableReason(8, { joinedAtEpoch: 2, removedAtEpoch: 8 })).toBe( + MLS_UNAVAILABLE_AFTER_REMOVAL, + ); + }); + + it('rejects epochs after removal', () => { + expect(mlsUnavailableReason(12, { joinedAtEpoch: 2, removedAtEpoch: 8 })).toBe( + MLS_UNAVAILABLE_AFTER_REMOVAL, + ); + }); + + it('rejects everything for a device with no leaf in the group', () => { + expect(mlsUnavailableReason(0, null)).toBe(MLS_UNAVAILABLE_NOT_A_MEMBER); + expect(mlsUnavailableReason(99, null)).toBe(MLS_UNAVAILABLE_NOT_A_MEMBER); + }); +}); + +describe('applyMlsVisibility', () => { + const window = { joinedAtEpoch: 5, removedAtEpoch: null }; + + it('passes non-MLS messages through untouched', () => { + const message = { id: 'm1', mlsEpoch: null, ciphertext: 'dm-ciphertext' }; + + expect(applyMlsVisibility(message, window)).toBe(message); + }); + + it('passes messages inside the window through untouched', () => { + const message = { id: 'm1', mlsEpoch: 6, ciphertext: 'group-ciphertext' }; + + expect(applyMlsVisibility(message, window)).toBe(message); + }); + + it('blanks the ciphertext of a pre-join message and explains why', () => { + const message = { id: 'm1', mlsEpoch: 2, ciphertext: 'group-ciphertext' }; + + const result = applyMlsVisibility(message, window); + + expect(result.ciphertext).toBeNull(); + expect(result.unavailable).toBe(true); + expect(result.unavailableReason).toBe(MLS_UNAVAILABLE_BEFORE_JOIN); + }); + + it('preserves ordering metadata so the client can render a placeholder in place', () => { + const createdAt = new Date('2026-01-01T00:00:00.000Z'); + const message = { + id: 'm1', + mlsEpoch: 2, + ciphertext: 'group-ciphertext', + senderId: 'user-9', + createdAt, + }; + + const result = applyMlsVisibility(message, window); + + expect(result.id).toBe('m1'); + expect(result.senderId).toBe('user-9'); + expect(result.createdAt).toBe(createdAt); + expect(result.mlsEpoch).toBe(2); + }); + + it('empties the envelopes array when one is present', () => { + const message = { id: 'm1', mlsEpoch: 2, ciphertext: 'c', envelopes: [{ ciphertext: 'e' }] }; + + expect(applyMlsVisibility(message, window).envelopes).toEqual([]); + }); + + it('does not add an envelopes key to messages that never had one', () => { + const message = { id: 'm1', mlsEpoch: 2, ciphertext: 'c' }; + + expect('envelopes' in applyMlsVisibility(message, window)).toBe(false); + }); + + it('marks every MLS message unavailable for a device with no leaf', () => { + const message = { id: 'm1', mlsEpoch: 42, ciphertext: 'group-ciphertext' }; + + const result = applyMlsVisibility(message, null); + + expect(result.ciphertext).toBeNull(); + expect(result.unavailableReason).toBe(MLS_UNAVAILABLE_NOT_A_MEMBER); + }); + + it('does not mutate the input message', () => { + const message = { id: 'm1', mlsEpoch: 2, ciphertext: 'group-ciphertext' }; + + applyMlsVisibility(message, window); + + expect(message.ciphertext).toBe('group-ciphertext'); + }); +}); diff --git a/apps/backend/src/__tests__/validateMessagePayload.test.ts b/apps/backend/src/__tests__/validateMessagePayload.test.ts index e6283c63..61a193a8 100644 --- a/apps/backend/src/__tests__/validateMessagePayload.test.ts +++ b/apps/backend/src/__tests__/validateMessagePayload.test.ts @@ -157,3 +157,82 @@ describe('acceptance criteria', () => { if (!result.ok) expect(result.code).toBe(400); }); }); + +// ─── MLS group messages (#372) ─────────────────────────────────────────────── +// +// An MLS group message carries one ciphertext encrypted to the group's epoch +// secrets, so the per-device envelope requirement does not apply to it. + +describe('MLS group messages', () => { + it('accepts a text message with mlsEpoch and no envelopes', () => { + const result = validateMessagePayload({ + contentType: 'text', + ciphertext: 'group-ciphertext', + mlsEpoch: 7, + }); + + expect(result.ok).toBe(true); + }); + + it('accepts epoch 0 — the founding epoch is a real epoch', () => { + const result = validateMessagePayload({ + contentType: 'text', + ciphertext: 'group-ciphertext', + mlsEpoch: 0, + }); + + expect(result.ok).toBe(true); + }); + + it('rejects an MLS message that also carries per-device envelopes', () => { + const result = validateMessagePayload({ + contentType: 'text', + ciphertext: 'group-ciphertext', + envelopes: [envelope], + mlsEpoch: 7, + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe(400); + }); + + it('rejects an MLS message with no ciphertext', () => { + const result = validateMessagePayload({ contentType: 'text', mlsEpoch: 7 }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe(400); + }); + + it('still requires a fileId for MLS file messages', () => { + const result = validateMessagePayload({ + contentType: 'image', + ciphertext: 'group-ciphertext', + mlsEpoch: 7, + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe(400); + }); + + it('accepts an MLS file message with a fileId', () => { + const result = validateMessagePayload({ + contentType: 'image', + ciphertext: 'group-ciphertext', + fileId: 'file-1', + mlsEpoch: 7, + }); + + expect(result.ok).toBe(true); + }); + + it('still refuses system messages from clients', () => { + const result = validateMessagePayload({ + contentType: 'system', + ciphertext: 'c', + mlsEpoch: 7, + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe(403); + }); +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 468c21af..d14c0d3b 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -16,6 +16,7 @@ import { filesRouter } from './routes/files.js'; import { pushRouter } from './routes/push.js'; import { syncRouter } from './routes/sync.js'; import { userDevicesRouter } from './routes/userDevices.js'; +import { mlsRouter } from './routes/mls.js'; import { requireAuth, type AuthRequest } from './middleware/auth.js'; const packageJson = JSON.parse( @@ -52,6 +53,9 @@ app.get('/health', async (_req, res) => { app.use('/auth', authRouter); app.use('/conversations', conversationsRouter); +// MLS group state lives on /conversations/:id/mls/* but in its own router so +// conversations.ts does not keep growing (#372). +app.use('/conversations', mlsRouter); app.use('/devices', devicesRouter); app.use('/messages', messagesRouter); app.use('/users', usersRouter); diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 46e079c8..12299297 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -7,6 +7,7 @@ import { pgEnum, index, integer, + bigint, uniqueIndex, check, type AnyPgColumn, @@ -129,6 +130,12 @@ export const messages = pgTable( editsMessageId: uuid('edits_message_id').references((): AnyPgColumn => messages.id, { onDelete: 'set null', }), + // MLS epoch whose secrets encrypted `ciphertext` (#372). Null for messages + // that are not MLS group messages — DMs, system events, and anything sent + // before the conversation adopted MLS. Devices that joined the group after + // this epoch cannot derive the key, so the read paths surface those + // messages as unavailable instead of handing back undecryptable bytes. + mlsEpoch: bigint('mls_epoch', { mode: 'number' }), createdAt: timestamp('created_at').notNull().defaultNow(), deletedAt: timestamp('deleted_at'), }, @@ -246,6 +253,130 @@ export const devicePrekeys = pgTable( ], ); +// ─── MLS group state (#372) ────────────────────────────────────────────────── +// +// Group conversations run MLS (RFC 9420). The server is a transport and an +// ordering service — it never holds group secrets. What it does hold is the +// *public* ledger every member needs to agree on: which epoch the group is at, +// which devices are in the ratchet tree, the commit that produced each epoch, +// and the Welcome messages addressed to devices being added. +// +// Epochs are the unit of membership. A device added by the commit that +// produces epoch N can decrypt messages from epoch N onwards and nothing +// before it, because the group secrets for earlier epochs were derived before +// its leaf existed. That is a property of MLS, not a gap in this schema — see +// docs/mls-group-membership.md. + +export const mlsGroups = pgTable( + 'mls_groups', + { + id: uuid('id').primaryKey().defaultRandom(), + conversationId: uuid('conversation_id') + .notNull() + .references(() => conversations.id, { onDelete: 'cascade' }), + // Base64 of the MLS group id chosen by the founding client. + groupId: text('group_id').notNull(), + cipherSuite: integer('cipher_suite').notNull(), + // Epoch of the most recent accepted commit. Starts at 0 for a fresh group. + currentEpoch: bigint('current_epoch', { mode: 'number' }).notNull().default(0), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + // One MLS group per conversation — the conversation *is* the group. + uniqueIndex('mls_groups_conversation_idx').on(table.conversationId), + uniqueIndex('mls_groups_group_id_idx').on(table.groupId), + ], +); + +// Device membership, recorded as an epoch interval rather than a boolean so a +// device's decryption window is derivable: it can read epochs in +// [joinedAtEpoch, removedAtEpoch). A device that is removed and later re-added +// gets a second row with a later joinedAtEpoch, which is exactly right — it +// still cannot read the epochs it was absent for. +export const mlsGroupMembers = pgTable( + 'mls_group_members', + { + id: uuid('id').primaryKey().defaultRandom(), + mlsGroupId: uuid('mls_group_id') + .notNull() + .references(() => mlsGroups.id, { onDelete: 'cascade' }), + deviceId: uuid('device_id') + .notNull() + .references(() => devices.id, { onDelete: 'cascade' }), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + joinedAtEpoch: bigint('joined_at_epoch', { mode: 'number' }).notNull(), + // Null while the device is still in the group. + removedAtEpoch: bigint('removed_at_epoch', { mode: 'number' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => [ + // A device can hold at most one *active* leaf in a group at a time. + uniqueIndex('mls_group_members_active_idx') + .on(table.mlsGroupId, table.deviceId) + .where(sql`${table.removedAtEpoch} IS NULL`), + index('mls_group_members_device_idx').on(table.deviceId), + ], +); + +// Append-only commit log. A device that was offline replays from its last +// known epoch to catch back up, which is what makes group state recoverable +// without the server ever seeing plaintext. +export const mlsCommits = pgTable( + 'mls_commits', + { + id: uuid('id').primaryKey().defaultRandom(), + mlsGroupId: uuid('mls_group_id') + .notNull() + .references(() => mlsGroups.id, { onDelete: 'cascade' }), + // Epoch this commit produces (i.e. the group's epoch after applying it). + epoch: bigint('epoch', { mode: 'number' }).notNull(), + committerDeviceId: uuid('committer_device_id').references(() => devices.id, { + onDelete: 'set null', + }), + // Base64 of the TLS-serialised MLSMessage carrying the Commit. + commit: text('commit').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + // Two members committing concurrently must not both win — the unique index + // makes the epoch race resolve in the database. + (table) => [uniqueIndex('mls_commits_group_epoch_idx').on(table.mlsGroupId, table.epoch)], +); + +// Welcome messages addressed to a device being added. Held until the device +// comes online and claims it, which is what lets a newly-linked device join +// groups it was invited to while it was offline. +export const mlsWelcomes = pgTable( + 'mls_welcomes', + { + id: uuid('id').primaryKey().defaultRandom(), + mlsGroupId: uuid('mls_group_id') + .notNull() + .references(() => mlsGroups.id, { onDelete: 'cascade' }), + deviceId: uuid('device_id') + .notNull() + .references(() => devices.id, { onDelete: 'cascade' }), + // Epoch the recipient joins at — the epoch the accompanying commit produced. + epoch: bigint('epoch', { mode: 'number' }).notNull(), + // Base64 of the TLS-serialised MLSMessage carrying the Welcome. + welcome: text('welcome').notNull(), + claimedAt: timestamp('claimed_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('mls_welcomes_group_device_epoch_idx').on( + table.mlsGroupId, + table.deviceId, + table.epoch, + ), + index('mls_welcomes_pending_idx') + .on(table.deviceId) + .where(sql`${table.claimedAt} IS NULL`), + ], +); + // ─── Token transfers (#46) ──────────────────────────────────────────────────── // // One row per Soroban `transfer` event the listener (services/stellarListener.ts) @@ -362,12 +493,42 @@ export const walletsRelations = relations(wallets, ({ one }) => ({ user: one(users, { fields: [wallets.userId], references: [users.id] }), })); -export const conversationsRelations = relations(conversations, ({ many }) => ({ +export const conversationsRelations = relations(conversations, ({ one, many }) => ({ members: many(conversationMembers), messages: many(messages), transfers: many(tokenTransfers), treasuryProposals: many(treasuryProposals), files: many(files), + mlsGroup: one(mlsGroups), +})); + +export const mlsGroupsRelations = relations(mlsGroups, ({ one, many }) => ({ + conversation: one(conversations, { + fields: [mlsGroups.conversationId], + references: [conversations.id], + }), + members: many(mlsGroupMembers), + commits: many(mlsCommits), + welcomes: many(mlsWelcomes), +})); + +export const mlsGroupMembersRelations = relations(mlsGroupMembers, ({ one }) => ({ + group: one(mlsGroups, { fields: [mlsGroupMembers.mlsGroupId], references: [mlsGroups.id] }), + device: one(devices, { fields: [mlsGroupMembers.deviceId], references: [devices.id] }), + user: one(users, { fields: [mlsGroupMembers.userId], references: [users.id] }), +})); + +export const mlsCommitsRelations = relations(mlsCommits, ({ one }) => ({ + group: one(mlsGroups, { fields: [mlsCommits.mlsGroupId], references: [mlsGroups.id] }), + committerDevice: one(devices, { + fields: [mlsCommits.committerDeviceId], + references: [devices.id], + }), +})); + +export const mlsWelcomesRelations = relations(mlsWelcomes, ({ one }) => ({ + group: one(mlsGroups, { fields: [mlsWelcomes.mlsGroupId], references: [mlsGroups.id] }), + device: one(devices, { fields: [mlsWelcomes.deviceId], references: [devices.id] }), })); export const filesRelations = relations(files, ({ one, many }) => ({ @@ -483,3 +644,11 @@ export type Device = typeof devices.$inferSelect; export type NewDevice = typeof devices.$inferInsert; export type DevicePrekey = typeof devicePrekeys.$inferSelect; export type NewDevicePrekey = typeof devicePrekeys.$inferInsert; +export type MlsGroup = typeof mlsGroups.$inferSelect; +export type NewMlsGroup = typeof mlsGroups.$inferInsert; +export type MlsGroupMember = typeof mlsGroupMembers.$inferSelect; +export type NewMlsGroupMember = typeof mlsGroupMembers.$inferInsert; +export type MlsCommit = typeof mlsCommits.$inferSelect; +export type NewMlsCommit = typeof mlsCommits.$inferInsert; +export type MlsWelcome = typeof mlsWelcomes.$inferSelect; +export type NewMlsWelcome = typeof mlsWelcomes.$inferInsert; diff --git a/apps/backend/src/lib/mlsVisibility.ts b/apps/backend/src/lib/mlsVisibility.ts new file mode 100644 index 00000000..0acd19fd --- /dev/null +++ b/apps/backend/src/lib/mlsVisibility.ts @@ -0,0 +1,88 @@ +/** + * MLS epoch visibility (#372). + * + * A device can only derive the key for an MLS group message if its leaf was in + * the ratchet tree at the epoch that encrypted it. A device added by the commit + * that produced epoch N therefore reads epoch N onwards and nothing earlier, + * and a device removed at epoch M stops reading at epoch M. + * + * That is a property of MLS, not a bug in the read path — so the read paths + * hand back a message marked `unavailable` rather than shipping ciphertext the + * client is guaranteed to fail on. Failed decryption is indistinguishable from + * tampering on the client, so surfacing it as an error would train users to + * ignore a signal that is supposed to mean something. + * + * These helpers are pure so both the REST history endpoint and the socket + * `message_history` handler apply exactly the same rule. + */ + +/** The epoch interval a device can read: `[joinedAtEpoch, removedAtEpoch)`. */ +export interface MlsEpochWindow { + joinedAtEpoch: number; + /** Null while the device is still in the group. */ + removedAtEpoch: number | null; +} + +/** Stable reason codes clients can branch on for copy. */ +export const MLS_UNAVAILABLE_BEFORE_JOIN = 'mls_no_key_before_join'; +export const MLS_UNAVAILABLE_AFTER_REMOVAL = 'mls_no_key_after_removal'; +export const MLS_UNAVAILABLE_NOT_A_MEMBER = 'mls_not_a_group_member'; + +export type MlsUnavailableReason = + | typeof MLS_UNAVAILABLE_BEFORE_JOIN + | typeof MLS_UNAVAILABLE_AFTER_REMOVAL + | typeof MLS_UNAVAILABLE_NOT_A_MEMBER; + +/** + * Why a device cannot read `mlsEpoch`, or `null` when it can. + * + * `window === null` means the device holds no leaf in the group at all — a + * device that has been invited but has not yet processed its Welcome, for + * instance. + */ +export function mlsUnavailableReason( + mlsEpoch: number, + window: MlsEpochWindow | null, +): MlsUnavailableReason | null { + if (window === null) return MLS_UNAVAILABLE_NOT_A_MEMBER; + if (mlsEpoch < window.joinedAtEpoch) return MLS_UNAVAILABLE_BEFORE_JOIN; + if (window.removedAtEpoch !== null && mlsEpoch >= window.removedAtEpoch) { + return MLS_UNAVAILABLE_AFTER_REMOVAL; + } + return null; +} + +export interface MlsVisibilityFields { + mlsEpoch?: number | null; + ciphertext?: string | null; + envelopes?: unknown[]; +} + +/** + * Blanks out the ciphertext of a message the device has no key for and tags it + * with `unavailable` plus a reason code. Messages with no `mlsEpoch` are not + * MLS group messages (DMs, system events, pre-MLS history) and pass through + * untouched, as do messages inside the device's epoch window. + * + * Metadata — sender, timestamps, ordering — is deliberately preserved so the + * client can render a placeholder in the right place in the timeline instead + * of showing a gap. + */ +export function applyMlsVisibility( + message: T, + window: MlsEpochWindow | null, +): T & { unavailable?: true; unavailableReason?: MlsUnavailableReason } { + const epoch = message.mlsEpoch; + if (epoch === null || epoch === undefined) return message; + + const reason = mlsUnavailableReason(epoch, window); + if (reason === null) return message; + + return { + ...message, + ciphertext: null, + ...(message.envelopes === undefined ? {} : { envelopes: [] }), + unavailable: true as const, + unavailableReason: reason, + }; +} diff --git a/apps/backend/src/lib/validateMessagePayload.ts b/apps/backend/src/lib/validateMessagePayload.ts index 9ac37c4e..61405767 100644 --- a/apps/backend/src/lib/validateMessagePayload.ts +++ b/apps/backend/src/lib/validateMessagePayload.ts @@ -12,6 +12,15 @@ * video|audio (the envelope ciphertext carries the encrypted file key) * system – server-generated only; any client submission is rejected (403) * – rejected (400) + * + * MLS group messages (#372) + * ───────────────────────── + * When `mlsEpoch` is present the message is encrypted once to the group's + * epoch secrets rather than once per recipient device, so the per-device + * envelope requirement does not apply — a non-empty `ciphertext` is required + * instead. Sending envelopes alongside `mlsEpoch` is rejected: the two + * key-distribution models must not be mixed on one message, or recipients have + * no unambiguous rule for which ciphertext to decrypt. */ export interface MessagePayload { @@ -23,6 +32,8 @@ export interface MessagePayload { envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }> | undefined; /** UUID referencing the uploaded file (required for file/image/video/audio) */ fileId?: string | undefined; + /** MLS epoch that encrypted `ciphertext`; marks this as an MLS group message */ + mlsEpoch?: number | undefined; } export type MessagePayloadValidationResult = @@ -66,6 +77,33 @@ export function validateMessagePayload(payload: MessagePayload): MessagePayloadV const hasEnvelopes = Array.isArray(payload.envelopes) && payload.envelopes.length > 0; + // ── MLS group message ──────────────────────────────────────────────────────── + // One ciphertext for the whole group; per-device envelopes do not apply. + if (payload.mlsEpoch !== undefined) { + if (hasEnvelopes) { + return { + ok: false, + code: 400, + message: 'MLS group messages carry a single group ciphertext, not per-device envelopes', + }; + } + if (!payload.ciphertext?.trim()) { + return { + ok: false, + code: 400, + message: 'ciphertext is required for MLS group messages', + }; + } + if (FILE_CONTENT_TYPES.has(contentType as any) && !payload.fileId?.trim()) { + return { + ok: false, + code: 400, + message: 'fileId is required for file-type messages', + }; + } + return { ok: true }; + } + // ── file / image / video / audio ───────────────────────────────────────────── if (FILE_CONTENT_TYPES.has(contentType as any)) { if (!payload.fileId?.trim()) { diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index 1d758f6c..dca48790 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -16,6 +16,8 @@ import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { serializeMessage } from '../lib/messages.js'; import { getSocketServer } from '../lib/socket.js'; import { MAX_MESSAGES_LIMIT, DEFAULT_MESSAGES_LIMIT } from '../constants.js'; +import { applyMlsVisibility } from '../lib/mlsVisibility.js'; +import { getConversationEpochWindow } from '../services/mlsGroups.js'; export const conversationsRouter: IRouter = Router(); @@ -517,7 +519,14 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => { const nextCursor = hasMore ? (page[0]?.id ?? null) : null; - res.json({ messages: page, nextCursor }); + // #372 — MLS group messages from epochs outside this device's membership + // window are returned as placeholders rather than as ciphertext the device + // is guaranteed to fail on. Non-MLS conversations skip the lookup entirely. + const { hasGroup, window } = await getConversationEpochWindow(conversationId, req.auth!.deviceId); + + const visible = hasGroup ? page.map((message) => applyMlsVisibility(message, window)) : page; + + res.json({ messages: visible, nextCursor }); }); conversationsRouter.get('/:id/search', async (req: AuthRequest, res) => { diff --git a/apps/backend/src/routes/messages.ts b/apps/backend/src/routes/messages.ts index 9a0b5482..2e46d868 100644 --- a/apps/backend/src/routes/messages.ts +++ b/apps/backend/src/routes/messages.ts @@ -21,14 +21,16 @@ messagesRouter.use(requireAuth); messagesRouter.post('/', validate(SendMessageSchema), async (req: AuthRequest, res) => { const userId = req.auth!.userId; const deviceId = req.auth!.deviceId as string | undefined; - const { conversationId, messageId, contentType, ciphertext, envelopes, fileId } = req.body as { - conversationId: string; - messageId: string; - contentType?: string; - ciphertext?: string; - envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; - fileId?: string; - }; + const { conversationId, messageId, contentType, ciphertext, envelopes, fileId, mlsEpoch } = + req.body as { + conversationId: string; + messageId: string; + contentType?: string; + ciphertext?: string; + envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; + fileId?: string; + mlsEpoch?: number; + }; // ── content-type-specific validation ────────────────────────────────────── const validation = validateMessagePayload({ @@ -36,6 +38,7 @@ messagesRouter.post('/', validate(SendMessageSchema), async (req: AuthRequest, r ...(ciphertext !== undefined ? { ciphertext } : {}), ...(envelopes !== undefined ? { envelopes } : {}), ...(fileId !== undefined ? { fileId } : {}), + ...(mlsEpoch !== undefined ? { mlsEpoch } : {}), }); if (!validation.ok) { res.status(validation.code).json({ error: validation.message }); @@ -80,6 +83,7 @@ messagesRouter.post('/', validate(SendMessageSchema), async (req: AuthRequest, r contentType: contentType?.trim().toLowerCase() || 'text', ciphertext: ciphertext || null, fileId: fileId ?? null, + mlsEpoch: mlsEpoch ?? null, }) .returning(); diff --git a/apps/backend/src/routes/mls.ts b/apps/backend/src/routes/mls.ts new file mode 100644 index 00000000..2931bb96 --- /dev/null +++ b/apps/backend/src/routes/mls.ts @@ -0,0 +1,461 @@ +/** + * MLS group routes (#372) — mounted alongside `conversationsRouter` on + * `/conversations` so the paths stay `/conversations/:id/mls/...` without + * growing that already large file. + * + * These endpoints move public MLS artefacts between members. The server orders + * commits and holds Welcomes for offline devices; it never derives, stores, or + * inspects group secrets. + * + * The flow a newly-linked device follows: + * + * 1. registers (POST /devices) and publishes MLS key packages + * 2. shows up in GET /conversations/:id/mls/pending-devices for every group + * its user belongs to + * 3. an existing member commits an Add for it, attaching a Welcome + * 4. the new device claims that Welcome and replays commits from its join + * epoch onwards + * + * After step 4 it decrypts everything from its join epoch on. It cannot + * decrypt anything before that, by design — see docs/mls-group-membership.md. + */ + +import { Router, type Router as RouterType } from 'express'; +import { and, eq, inArray, isNull } from 'drizzle-orm'; +import { z } from 'zod'; +import { db } from '../db/index.js'; +import { + conversationMembers, + conversations, + devices, + mlsGroupMembers, + mlsGroups, +} from '../db/schema.js'; +import { requireAuth, type AuthRequest } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; +import { getSocketServer } from '../lib/socket.js'; +import { conversationRoom } from '../services/roomManager.js'; +import { deviceRoom } from '../services/deliveryPipeline.js'; +import { + MLS_COMMIT_PAGE_SIZE, + MLS_MAX_COMMIT_MEMBER_CHANGES, + claimWelcome, + getEpochWindow, + getGroupByConversation, + isActiveMember, + listCommitsSince, + listDevicesAwaitingJoin, + pendingWelcomeEpoch, + recordCommit, +} from '../services/mlsGroups.js'; + +export const mlsRouter: RouterType = Router(); + +mlsRouter.use(requireAuth); + +// ─── Schemas ────────────────────────────────────────────────────────────────── + +/** Base64 blob of an MLS wire-format message. Opaque to the server. */ +const MlsBlobSchema = z + .string() + .min(1) + .max(262144, 'MLS message exceeds the 256 KiB limit') + .regex(/^[A-Za-z0-9+/]*={0,2}$/, 'must be valid base64'); + +const InitGroupSchema = z.object({ + groupId: MlsBlobSchema, + cipherSuite: z.number().int().positive(), +}); + +const PublishCommitSchema = z.object({ + epoch: z.number().int().positive(), + commit: MlsBlobSchema, + addedDevices: z + .array(z.object({ deviceId: z.string().uuid(), welcome: MlsBlobSchema })) + .max(MLS_MAX_COMMIT_MEMBER_CHANGES) + .optional() + .default([]), + removedDeviceIds: z + .array(z.string().uuid()) + .max(MLS_MAX_COMMIT_MEMBER_CHANGES) + .optional() + .default([]), +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Every route here needs the same three facts: the conversation exists, the + * caller belongs to it, and (usually) it has MLS state. Resolving them in one + * place keeps the failure codes consistent across the endpoints. + */ +async function resolveContext( + req: AuthRequest, + opts: { requireGroup: boolean }, +): Promise< + | { + ok: true; + conversationId: string; + deviceId: string; + group: Awaited>; + } + | { ok: false; status: number; error: string } +> { + const conversationId = req.params['id'] as string | undefined; + const userId = req.auth!.userId; + const deviceId = req.auth!.deviceId as string | undefined; + + if (!conversationId) { + return { ok: false, status: 400, error: 'Conversation id is required' }; + } + + if (!deviceId) { + return { ok: false, status: 400, error: 'Token is missing a deviceId' }; + } + + const membership = await db.query.conversationMembers.findFirst({ + where: and( + eq(conversationMembers.conversationId, conversationId), + eq(conversationMembers.userId, userId), + ), + columns: { id: true }, + }); + + if (!membership) { + return { ok: false, status: 403, error: 'Not a member of this conversation' }; + } + + const group = await getGroupByConversation(conversationId); + + if (opts.requireGroup && !group) { + return { ok: false, status: 404, error: 'Conversation has no MLS group' }; + } + + return { ok: true, conversationId, deviceId, group }; +} + +// ─── POST /conversations/:id/mls/group ──────────────────────────────────────── +// +// Publishes the group state for a conversation. The founding client has +// already created the group locally; this records the public identifiers and +// seats the founder's device at epoch 0. + +mlsRouter.post('/:id/mls/group', validate(InitGroupSchema), async (req: AuthRequest, res) => { + const ctx = await resolveContext(req, { requireGroup: false }); + if (!ctx.ok) { + res.status(ctx.status).json({ error: ctx.error }); + return; + } + + if (ctx.group) { + res.status(409).json({ + error: 'Conversation already has an MLS group', + groupId: ctx.group.groupId, + currentEpoch: ctx.group.currentEpoch, + }); + return; + } + + const conversation = await db.query.conversations.findFirst({ + where: eq(conversations.id, ctx.conversationId), + columns: { type: true }, + }); + + if (conversation?.type !== 'group') { + res.status(400).json({ error: 'MLS groups are only available for group conversations' }); + return; + } + + const { groupId, cipherSuite } = req.body as z.infer; + const userId = req.auth!.userId; + + try { + const created = await db.transaction(async (tx) => { + const [group] = await tx + .insert(mlsGroups) + .values({ conversationId: ctx.conversationId, groupId, cipherSuite }) + .returning({ id: mlsGroups.id, currentEpoch: mlsGroups.currentEpoch }); + + if (!group) return null; + + // The founder is the group's only leaf at epoch 0. + await tx.insert(mlsGroupMembers).values({ + mlsGroupId: group.id, + deviceId: ctx.deviceId, + userId, + joinedAtEpoch: group.currentEpoch, + }); + + return group; + }); + + if (!created) { + res.status(500).json({ error: 'Failed to create MLS group' }); + return; + } + + res.status(201).json({ + conversationId: ctx.conversationId, + groupId, + cipherSuite, + currentEpoch: created.currentEpoch, + }); + } catch { + // Unique index on conversation_id / group_id — another client won the race. + res.status(409).json({ error: 'Conversation already has an MLS group' }); + } +}); + +// ─── GET /conversations/:id/mls/group ───────────────────────────────────────── +// +// The state a device needs on open: where the group is, where the device sits +// in it, whether a Welcome is waiting, and — importantly for the UI — the +// earliest epoch this device will ever be able to read. + +mlsRouter.get('/:id/mls/group', async (req: AuthRequest, res) => { + const ctx = await resolveContext(req, { requireGroup: true }); + if (!ctx.ok) { + res.status(ctx.status).json({ error: ctx.error }); + return; + } + + const group = ctx.group!; + const [window, welcomeEpoch] = await Promise.all([ + getEpochWindow(group.id, ctx.deviceId), + pendingWelcomeEpoch(group.id, ctx.deviceId), + ]); + + res.json({ + conversationId: ctx.conversationId, + groupId: group.groupId, + cipherSuite: group.cipherSuite, + currentEpoch: group.currentEpoch, + membership: window + ? { + joinedAtEpoch: window.joinedAtEpoch, + removedAtEpoch: window.removedAtEpoch, + active: window.removedAtEpoch === null, + } + : null, + pendingWelcome: welcomeEpoch === null ? null : { epoch: welcomeEpoch }, + // Null until the device has a leaf. Everything strictly before this epoch + // is permanently unreadable on this device. + historyAvailableFromEpoch: window?.joinedAtEpoch ?? null, + }); +}); + +// ─── GET /conversations/:id/mls/pending-devices ─────────────────────────────── +// +// Devices of conversation members that hold no leaf yet. A member polls this +// (or reacts to the `device_added` system event) and commits an Add. + +mlsRouter.get('/:id/mls/pending-devices', async (req: AuthRequest, res) => { + const ctx = await resolveContext(req, { requireGroup: true }); + if (!ctx.ok) { + res.status(ctx.status).json({ error: ctx.error }); + return; + } + + const pending = await listDevicesAwaitingJoin(ctx.conversationId, ctx.group!.id); + + res.json({ currentEpoch: ctx.group!.currentEpoch, devices: pending }); +}); + +// ─── POST /conversations/:id/mls/commits ────────────────────────────────────── +// +// Publishes a commit, the membership changes it encodes, and the Welcome for +// each added device. `epoch` must be exactly `currentEpoch + 1`; a client that +// raced another committer gets 409 with the epoch to rebase on. + +mlsRouter.post('/:id/mls/commits', validate(PublishCommitSchema), async (req: AuthRequest, res) => { + const ctx = await resolveContext(req, { requireGroup: true }); + if (!ctx.ok) { + res.status(ctx.status).json({ error: ctx.error }); + return; + } + + const group = ctx.group!; + const body = req.body as z.infer; + + // Only a device already in the tree can commit — an outsider has no state to + // commit from, and letting one write would corrupt every member's view. + if (!(await isActiveMember(group.id, ctx.deviceId))) { + res.status(403).json({ error: 'Only an active group member device may publish a commit' }); + return; + } + + const addedIds = body.addedDevices.map((d) => d.deviceId); + + if (new Set(addedIds).size !== addedIds.length) { + res.status(400).json({ error: 'addedDevices contains duplicate deviceIds' }); + return; + } + + if (addedIds.some((id) => body.removedDeviceIds.includes(id))) { + res.status(400).json({ error: 'A device cannot be added and removed by the same commit' }); + return; + } + + // Added devices must belong to users who are actually in the conversation — + // otherwise a member could smuggle an arbitrary device into the group. + let addedDevices: Array<{ deviceId: string; userId: string; welcome: string }> = []; + + if (addedIds.length > 0) { + const memberRows = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.conversationId, ctx.conversationId), + columns: { userId: true }, + }); + const memberUserIds = new Set(memberRows.map((m) => m.userId)); + + const deviceRows = await db.query.devices.findMany({ + where: and(inArray(devices.id, addedIds), isNull(devices.revokedAt)), + columns: { id: true, userId: true }, + }); + const deviceOwner = new Map(deviceRows.map((d) => [d.id, d.userId])); + + const invalid = addedIds.filter((id) => { + const owner = deviceOwner.get(id); + return owner === undefined || !memberUserIds.has(owner); + }); + + if (invalid.length > 0) { + res.status(400).json({ + error: 'Added devices must be active devices of conversation members', + invalidDeviceIds: invalid, + }); + return; + } + + addedDevices = body.addedDevices.map((d) => ({ + deviceId: d.deviceId, + userId: deviceOwner.get(d.deviceId)!, + welcome: d.welcome, + })); + } + + const result = await recordCommit({ + mlsGroupId: group.id, + epoch: body.epoch, + committerDeviceId: ctx.deviceId, + commit: body.commit, + addedDevices, + removedDeviceIds: body.removedDeviceIds, + }); + + if (!result.ok) { + res.status(409).json({ + error: 'Commit epoch conflict — another commit was applied first', + currentEpoch: result.currentEpoch, + expectedEpoch: result.currentEpoch + 1, + }); + return; + } + + const io = getSocketServer(); + if (io) { + io.to(conversationRoom(ctx.conversationId)).emit('mls_commit', { + conversationId: ctx.conversationId, + epoch: result.epoch, + commit: body.commit, + }); + + // Added devices are not in the conversation room's MLS state yet, so they + // get a direct nudge to come and claim their Welcome. + for (const added of addedDevices) { + io.to(deviceRoom(added.deviceId)).emit('mls_welcome_available', { + conversationId: ctx.conversationId, + epoch: result.epoch, + }); + } + } + + res.status(201).json({ + conversationId: ctx.conversationId, + epoch: result.epoch, + addedDeviceIds: addedIds, + removedDeviceIds: body.removedDeviceIds, + }); +}); + +// ─── GET /conversations/:id/mls/commits ─────────────────────────────────────── +// +// Commit replay for a device catching up. `?sinceEpoch=` defaults to the +// device's join epoch, which is the correct starting point after processing a +// Welcome — earlier commits are not applicable to a tree it was not in. + +mlsRouter.get('/:id/mls/commits', async (req: AuthRequest, res) => { + const ctx = await resolveContext(req, { requireGroup: true }); + if (!ctx.ok) { + res.status(ctx.status).json({ error: ctx.error }); + return; + } + + const group = ctx.group!; + const window = await getEpochWindow(group.id, ctx.deviceId); + + if (!window) { + res.status(403).json({ error: 'Device is not a member of this MLS group' }); + return; + } + + const raw = req.query['sinceEpoch']; + let sinceEpoch = window.joinedAtEpoch; + + if (typeof raw === 'string' && raw !== '') { + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 0) { + res.status(400).json({ error: 'sinceEpoch must be a non-negative integer' }); + return; + } + // Never replay behind the device's join epoch: those commits belong to a + // tree it had no leaf in and it cannot apply them. + sinceEpoch = Math.max(parsed, window.joinedAtEpoch); + } + + const rawLimit = Number(req.query['limit']); + const limit = + Number.isInteger(rawLimit) && rawLimit > 0 + ? Math.min(rawLimit, MLS_COMMIT_PAGE_SIZE) + : MLS_COMMIT_PAGE_SIZE; + + const commits = await listCommitsSince(group.id, sinceEpoch, limit); + const lastEpoch = commits[commits.length - 1]?.epoch ?? sinceEpoch; + + res.json({ + conversationId: ctx.conversationId, + sinceEpoch, + currentEpoch: group.currentEpoch, + commits, + hasMore: lastEpoch < group.currentEpoch, + }); +}); + +// ─── GET /conversations/:id/mls/welcome ─────────────────────────────────────── +// +// A newly-added device claims the Welcome that seats it in the tree. Claiming +// marks it consumed; the device then replays commits from its join epoch. + +mlsRouter.get('/:id/mls/welcome', async (req: AuthRequest, res) => { + const ctx = await resolveContext(req, { requireGroup: true }); + if (!ctx.ok) { + res.status(ctx.status).json({ error: ctx.error }); + return; + } + + const group = ctx.group!; + const claimed = await claimWelcome(group.id, ctx.deviceId); + + if (!claimed) { + res.status(404).json({ error: 'No pending Welcome for this device' }); + return; + } + + res.json({ + conversationId: ctx.conversationId, + groupId: group.groupId, + cipherSuite: group.cipherSuite, + epoch: claimed.epoch, + welcome: claimed.welcome, + currentEpoch: group.currentEpoch, + }); +}); diff --git a/apps/backend/src/schemas/message.schemas.ts b/apps/backend/src/schemas/message.schemas.ts index 804819ed..f03e0024 100644 --- a/apps/backend/src/schemas/message.schemas.ts +++ b/apps/backend/src/schemas/message.schemas.ts @@ -23,6 +23,13 @@ export const SendMessageSchema = z.object({ envelopes: z.array(EnvelopeSchema).optional(), /** UUID of an already-uploaded file; required when contentType is file/image/video/audio */ fileId: z.string().uuid('fileId must be a valid UUID').optional(), + /** + * MLS epoch whose secrets encrypted `ciphertext` (#372). Present only on MLS + * group messages, which carry one group ciphertext instead of per-device + * envelopes. Recorded so the history read paths know which devices can + * derive the key. + */ + mlsEpoch: z.number().int().nonnegative().optional(), }); export type SendMessageBody = z.infer; diff --git a/apps/backend/src/services/mlsGroups.ts b/apps/backend/src/services/mlsGroups.ts new file mode 100644 index 00000000..3541de78 --- /dev/null +++ b/apps/backend/src/services/mlsGroups.ts @@ -0,0 +1,335 @@ +/** + * MLS group state (#372). + * + * The server keeps the public ledger a group needs to agree on — current + * epoch, which device leaves are in the tree, the commit that produced each + * epoch, and pending Welcome messages — and never sees group secrets. + * + * The two things this module exists to make correct: + * + * 1. **Epoch races.** Two members can commit at the same instant. Both + * commits claim `currentEpoch + 1`, and only one may win, or members end + * up on divergent trees. `recordCommit` resolves that in the database. + * 2. **Membership windows.** A device's decryption window is an epoch + * interval, not a boolean. `getEpochWindow` returns that interval so the + * message read paths can mark what a device genuinely cannot read. + */ + +import { and, asc, eq, gt, inArray, isNull, desc } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { + conversationMembers, + devices, + mlsCommits, + mlsGroupMembers, + mlsGroups, + mlsWelcomes, +} from '../db/schema.js'; +import type { MlsEpochWindow } from '../lib/mlsVisibility.js'; + +/** Maximum commits returned by a single catch-up page. */ +export const MLS_COMMIT_PAGE_SIZE = 200; + +/** Maximum devices a single commit may add or remove. */ +export const MLS_MAX_COMMIT_MEMBER_CHANGES = 100; + +export interface MlsGroupState { + id: string; + conversationId: string; + groupId: string; + cipherSuite: number; + currentEpoch: number; +} + +export async function getGroupByConversation( + conversationId: string, +): Promise { + const row = await db.query.mlsGroups.findFirst({ + where: eq(mlsGroups.conversationId, conversationId), + columns: { + id: true, + conversationId: true, + groupId: true, + cipherSuite: true, + currentEpoch: true, + }, + }); + + return row ?? null; +} + +/** + * The epoch interval `deviceId` can decrypt in `mlsGroupId`, or `null` when the + * device holds no leaf in the group. + * + * A device that was removed and later re-added has more than one row; the most + * recent one is the live window. The earlier interval is intentionally not + * merged in — the device genuinely cannot read the epochs it was absent for. + */ +export async function getEpochWindow( + mlsGroupId: string, + deviceId: string, +): Promise { + const row = await db.query.mlsGroupMembers.findFirst({ + where: and(eq(mlsGroupMembers.mlsGroupId, mlsGroupId), eq(mlsGroupMembers.deviceId, deviceId)), + orderBy: [desc(mlsGroupMembers.joinedAtEpoch)], + columns: { joinedAtEpoch: true, removedAtEpoch: true }, + }); + + return row ?? null; +} + +/** + * Convenience wrapper for the message read paths: resolves the conversation's + * group and the caller device's window in one step. Returns + * `{ hasGroup: false }` for conversations that are not MLS groups, so callers + * can skip the visibility pass entirely. + */ +export async function getConversationEpochWindow( + conversationId: string, + deviceId: string | undefined, +): Promise<{ hasGroup: boolean; window: MlsEpochWindow | null }> { + const group = await getGroupByConversation(conversationId); + if (!group) return { hasGroup: false, window: null }; + if (!deviceId) return { hasGroup: true, window: null }; + + return { hasGroup: true, window: await getEpochWindow(group.id, deviceId) }; +} + +/** True when the device currently holds an active leaf in the group. */ +export async function isActiveMember(mlsGroupId: string, deviceId: string): Promise { + const row = await db.query.mlsGroupMembers.findFirst({ + where: and( + eq(mlsGroupMembers.mlsGroupId, mlsGroupId), + eq(mlsGroupMembers.deviceId, deviceId), + isNull(mlsGroupMembers.removedAtEpoch), + ), + columns: { id: true }, + }); + + return row !== undefined && row !== null; +} + +/** + * Active devices belonging to conversation members that hold no leaf in the + * group yet — the set a committer has to Add. + * + * This is how a newly-linked device surfaces to the rest of the group: it + * registers, appears here, and the next commit brings it into the tree. + */ +export async function listDevicesAwaitingJoin( + conversationId: string, + mlsGroupId: string, +): Promise> { + const memberRows = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.conversationId, conversationId), + columns: { userId: true }, + }); + + const userIds = memberRows.map((m) => m.userId); + if (userIds.length === 0) return []; + + const activeDevices = await db.query.devices.findMany({ + where: and(inArray(devices.userId, userIds), isNull(devices.revokedAt)), + columns: { id: true, userId: true, identityPublicKey: true }, + }); + + if (activeDevices.length === 0) return []; + + const joined = await db.query.mlsGroupMembers.findMany({ + where: and( + eq(mlsGroupMembers.mlsGroupId, mlsGroupId), + isNull(mlsGroupMembers.removedAtEpoch), + inArray( + mlsGroupMembers.deviceId, + activeDevices.map((d) => d.id), + ), + ), + columns: { deviceId: true }, + }); + + const joinedIds = new Set(joined.map((m) => m.deviceId)); + + return activeDevices + .filter((d) => !joinedIds.has(d.id)) + .map((d) => ({ deviceId: d.id, userId: d.userId, identityPublicKey: d.identityPublicKey })); +} + +export interface CommitInput { + mlsGroupId: string; + /** Epoch this commit produces. Must be exactly `currentEpoch + 1`. */ + epoch: number; + committerDeviceId: string; + commit: string; + addedDevices: Array<{ deviceId: string; userId: string; welcome: string }>; + removedDeviceIds: string[]; +} + +export type CommitResult = + | { ok: true; epoch: number } + | { ok: false; reason: 'epoch_conflict'; currentEpoch: number }; + +/** + * Applies a commit and everything that follows from it in one transaction: + * the commit row, removals closed at this epoch, added devices opened at this + * epoch, their Welcome messages, and the group's new current epoch. + * + * The group row is locked for the duration, so a concurrent commit for the + * same epoch waits and then loses the epoch check rather than both being + * applied. The loser gets `epoch_conflict` with the epoch it should rebase on. + */ +export async function recordCommit(input: CommitInput): Promise { + return db.transaction(async (tx) => { + const [group] = await tx + .select({ id: mlsGroups.id, currentEpoch: mlsGroups.currentEpoch }) + .from(mlsGroups) + .where(eq(mlsGroups.id, input.mlsGroupId)) + .limit(1) + .for('update'); + + if (!group) { + return { ok: false as const, reason: 'epoch_conflict' as const, currentEpoch: 0 }; + } + + if (input.epoch !== group.currentEpoch + 1) { + return { + ok: false as const, + reason: 'epoch_conflict' as const, + currentEpoch: group.currentEpoch, + }; + } + + await tx.insert(mlsCommits).values({ + mlsGroupId: input.mlsGroupId, + epoch: input.epoch, + committerDeviceId: input.committerDeviceId, + commit: input.commit, + }); + + // Removals close the outgoing device's window at this epoch: it can still + // read everything up to `epoch - 1` and nothing from `epoch` on, which is + // exactly what the post-commit rekey gives it. + if (input.removedDeviceIds.length > 0) { + await tx + .update(mlsGroupMembers) + .set({ removedAtEpoch: input.epoch }) + .where( + and( + eq(mlsGroupMembers.mlsGroupId, input.mlsGroupId), + isNull(mlsGroupMembers.removedAtEpoch), + inArray(mlsGroupMembers.deviceId, input.removedDeviceIds), + ), + ); + } + + if (input.addedDevices.length > 0) { + await tx.insert(mlsGroupMembers).values( + input.addedDevices.map((d) => ({ + mlsGroupId: input.mlsGroupId, + deviceId: d.deviceId, + userId: d.userId, + joinedAtEpoch: input.epoch, + })), + ); + + await tx.insert(mlsWelcomes).values( + input.addedDevices.map((d) => ({ + mlsGroupId: input.mlsGroupId, + deviceId: d.deviceId, + epoch: input.epoch, + welcome: d.welcome, + })), + ); + } + + await tx + .update(mlsGroups) + .set({ currentEpoch: input.epoch, updatedAt: new Date() }) + .where(eq(mlsGroups.id, input.mlsGroupId)); + + return { ok: true as const, epoch: input.epoch }; + }); +} + +/** + * Commits after `sinceEpoch`, oldest first — the replay a device uses to catch + * its local group state back up after being offline. + */ +export async function listCommitsSince( + mlsGroupId: string, + sinceEpoch: number, + limit: number, +): Promise> { + return db + .select({ + epoch: mlsCommits.epoch, + commit: mlsCommits.commit, + createdAt: mlsCommits.createdAt, + }) + .from(mlsCommits) + .where(and(eq(mlsCommits.mlsGroupId, mlsGroupId), gt(mlsCommits.epoch, sinceEpoch))) + .orderBy(asc(mlsCommits.epoch)) + .limit(Math.min(limit, MLS_COMMIT_PAGE_SIZE)); +} + +/** + * Claims the device's pending Welcome, marking it consumed. + * + * Claiming is idempotent within a transaction but not repeatable across calls: + * once claimed, the device is expected to have imported the group state. If it + * fails to, it recovers by being re-added rather than by re-reading a Welcome + * whose epoch may already be stale. + */ +export async function claimWelcome( + mlsGroupId: string, + deviceId: string, +): Promise<{ epoch: number; welcome: string } | null> { + return db.transaction(async (tx) => { + const [pending] = await tx + .select({ + id: mlsWelcomes.id, + epoch: mlsWelcomes.epoch, + welcome: mlsWelcomes.welcome, + }) + .from(mlsWelcomes) + .where( + and( + eq(mlsWelcomes.mlsGroupId, mlsGroupId), + eq(mlsWelcomes.deviceId, deviceId), + isNull(mlsWelcomes.claimedAt), + ), + ) + // Newest first: if several Welcomes queued up while the device was + // offline, the latest epoch is the only one still usable. + .orderBy(desc(mlsWelcomes.epoch)) + .limit(1) + .for('update', { skipLocked: true }); + + if (!pending) return null; + + await tx + .update(mlsWelcomes) + .set({ claimedAt: new Date() }) + .where(eq(mlsWelcomes.id, pending.id)); + + return { epoch: pending.epoch, welcome: pending.welcome }; + }); +} + +/** Epoch of the device's unclaimed Welcome, if one is waiting. */ +export async function pendingWelcomeEpoch( + mlsGroupId: string, + deviceId: string, +): Promise { + const row = await db.query.mlsWelcomes.findFirst({ + where: and( + eq(mlsWelcomes.mlsGroupId, mlsGroupId), + eq(mlsWelcomes.deviceId, deviceId), + isNull(mlsWelcomes.claimedAt), + ), + orderBy: [desc(mlsWelcomes.epoch)], + columns: { epoch: true }, + }); + + return row?.epoch ?? null; +} diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index 3b76ffba..d572e818 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -21,6 +21,8 @@ import { deliverMessage } from '../services/deliveryPipeline.js'; import { publishEphemeral, readMissedEvents } from '../services/resumeStream.js'; import { handleDeviceDeliveryReceipt } from '../services/deliveryAggregation.js'; import { conversationRoom } from '../services/roomManager.js'; +import { applyMlsVisibility } from '../lib/mlsVisibility.js'; +import { getConversationEpochWindow } from '../services/mlsGroups.js'; import { EventDispatcher } from './dispatcher.js'; const PAGE_SIZE = 30; @@ -95,6 +97,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void ciphertext, envelopes, fileId: inputFileId, + mlsEpoch, } = payload as { conversationId: string; messageId?: string; @@ -103,6 +106,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void ciphertext?: string; envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; fileId?: string; + mlsEpoch?: number; }; const deviceId = socket.auth!.deviceId; @@ -135,6 +139,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void ciphertext: effectiveCiphertext, envelopes, fileId: inputFileId, + mlsEpoch, }); if (!validation.ok) { socket.emit('error', { @@ -167,8 +172,10 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void return; } - // Enforce full sibling-device coverage (#188). - const siblingIds = await fetchSiblingDeviceIds(userId, deviceId); + // Enforce full sibling-device coverage (#188). MLS group messages are + // exempt: a single group ciphertext already reaches every member device in + // the tree, so there are no per-device envelopes that could be missing. + const siblingIds = mlsEpoch === undefined ? await fetchSiblingDeviceIds(userId, deviceId) : []; if (siblingIds.length > 0) { const providedIds = new Set(envelopes?.map((e) => e.recipientDeviceId) ?? []); const missing = siblingIds.filter((id) => !providedIds.has(id)); @@ -213,6 +220,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void contentType: resolvedContentType, ciphertext: effectiveCiphertext || null, fileId: fileId, + mlsEpoch: mlsEpoch ?? null, }) .returning(); @@ -565,9 +573,21 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void }, }); + // #372 — same MLS epoch visibility rule as GET /conversations/:id/messages: + // messages outside this device's membership window come back as + // placeholders instead of undecryptable ciphertext. + const { hasGroup, window } = await getConversationEpochWindow( + conversationId, + socket.auth!.deviceId, + ); + socket.emit('message_history', { conversationId, - messages: history.reverse().map((message) => serializeMessage(message)), + messages: history + .reverse() + .map((message) => + serializeMessage(hasGroup ? applyMlsVisibility(message, window) : message), + ), }); });