Skip to content

The MLS delivery service over the socket (GRYT-1500) - #241

Merged
sivert-io merged 2 commits into
mainfrom
claude/GRYT-1500-mls-ds-socket
Sep 25, 2026
Merged

sivert-io merged 2 commits into
mainfrom
claude/GRYT-1500-mls-ds-socket

Conversation

@sivert-io

@sivert-io sivert-io commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Builds on the tables from #240, which is merged. Nothing here touches
src/db/** any more. The events are documented in Gryt-chat/docs#139, also merged.

What to look at

  • src/services/mlsWire.ts decodes bytes anybody signed in can send. It's
    ts-mls's own decoder, and nothing gets decrypted or verified. It's still a
    parser running on untrusted input.
  • ts-mls 1.6.4 is a new dependency, pinned exactly like the design asks.
    The server loads it with require("ts-mls/message.js") and
    require("ts-mls/keyPackage.js"), because the package index pulls in the
    noble provider and its optional peers, which aren't installed here. That
    leans on Node's require(esm), so Node 22.12 or later, which the engines
    field already asks for.
  • The Add check (memberDevicesFor) covers Adds sent by value, in a commit
    or a proposal. A commit can also name a proposal by reference; that
    proposal's Adds were checked when it arrived. Removes aren't checked. In a one-to-one either person can remove the other's devices, and
    the server doesn't track who's at which leaf.
  • A block in either direction refuses a KeyPackage claim, the way dm:open
    refuses opening. An existing group still gets commits both ways.

The API

Requests, each with a socket.io ack that answers { ok: true, ... } or
{ ok: false, error, message }, and each taking accessToken:

  • mls:keypackages:publish { deviceId, keyPackages: bytes[], lastResort? }
    → { stored, unclaimed, lastResort }. The first publish registers the
    device. A sixth device gets too_many_devices.
  • mls:keypackages:claim { conversationId, deviceId, devices? } →
    { keyPackages: [{ serverUserId, deviceId, keyPackage, lastResort }], missing }.
    Every other device in the conversation, or the ones named.
  • mls:devices { conversationId? } → your own devices, or everybody's in
    the conversation.
  • mls:device:remove { deviceId }, your own only.
  • mls:group:create { conversationId, groupId }, hex group id. The loser
    gets group_exists with the winner's group.
  • mls:commit { conversationId, deviceId, commit, welcome? } → { seq, epoch },
    or stale_epoch with epoch and headSeq.
  • mls:send { conversationId, deviceId, message } → { seq }. Application
    messages and proposals.
  • mls:log:fetch { conversationId, after, limit? } →
    { group, entries, nextCursor, hasMore, gap }.
  • mls:sync { deviceId } → { registered, groups, welcomes, keyPackages }.
  • mls:welcome:ack { deviceId, welcomeIds }.

Pushed by the server:

  • mls:message { conversationId, groupId, seq, kind, epoch, senderServerUserId, senderDeviceId, data, createdAt }
  • mls:welcome { welcomeId, conversationId, groupId, deviceId, data, createdAt },
    to every socket of that person. The client keeps the ones for its own device.
  • mls:devices:changed { serverUserId }, to anybody sharing a DM with them.
  • dm:opened on the first message, as for a sealed DM.
  • server:info gains mls: { version: 1, ciphersuites: [1], retentionDays }.

Binary goes both ways as socket.io binary (a Buffer on Node, an ArrayBuffer
in the browser).

Not in this PR

  • Apps from before stage 1 get nothing for an MLS message. Decision
    4 says they should show "update to read", and an app that's already out
    can't be taught to. The server would have to put something in their
    chat:new path. That's GRYT-1508.
  • The design says the server checks device certificates when a KeyPackage
    is uploaded. It can't until the certificate format is released in
    @gryt/crypto. GRYT-1509.
  • MLS messages aren't in the messages table, so the server's unread
    counts and the REST history don't see them. The client counts its own.

Tests

src/socket/handlers/mls.test.ts, 24 tests, run real suite 1 MLS from
ts-mls on both ends, so what comes back out of the server is shown to still
decrypt. They cover auth and membership, KeyPackage consumption and the
last-resort fallback, the device cap, blocks, contact settings and the DM
switch, the claim rate limit, two commits racing for one epoch and the
loser catching up from its cursor, cursor paging, blocked senders, mute,
retention with the gap flag, and the capability flag. Full suite: 1754
pass.

End to end: a throwaway server built from this branch on :5003, two
socket.io-client sockets restoring minted sessions. Alice published,
claimed Bob's package, created the group and committed the add. Bob got
the Welcome pushed, joined, and decrypted Alice's message ("hei bob, dette
er MLS"). A second mls:group:create got group_exists. Two commits
racing for epoch 1 came back as one { ok, seq: 3, epoch: 2 } and one
stale_epoch.

Task: GRYT-1500. Part of GRYT-754 and GRYT-1244.

🤖 Generated with Claude Code

sivert-io and others added 2 commits September 25, 2026 12:31
Stage 1's server half from docs/mls-design.md: KeyPackages, Welcomes, a
log per DM group with cursors, one commit per epoch, and a flag saying the
server has all of this. Every request answers through its socket.io ack.

The server reads a few header fields and nothing else: group id, epoch and
content type, the refs of the KeyPackages a commit adds, and who a Welcome
is for. It gets them from ts-mls 1.6.4, pinned exactly. It loads only the
message and keyPackage subpaths, so the noble provider is never pulled in. It uses
those fields to:

- refuse a commit for any epoch but the current one, with the current one
- refuse a message from an epoch that hasn't happened yet
- refuse an Add or a Welcome for a device that doesn't belong to somebody
  in the conversation
- hand each Welcome to the device it names, without the client saying who

Commits and proposals have to be PublicMessage, or the Adds couldn't be
checked. Application messages have to be PrivateMessage.

Only one-to-one DMs for now. Group DMs are stage 2 and bring their own
checks. Everything a sealed DM goes through still applies to an MLS
message: mute, send_messages, the server's DM switch, send_direct_messages,
contact settings, and the spam filter on size and recipients alone.
Claiming somebody's KeyPackages goes through the same checks. A block
either way reads as "not a member", as it does for dm:open. Somebody who
blocked the sender doesn't get their messages, live or from the log. They
still get the sender's commits, or their group state would fall behind.

server:info carries mls: { version: 1, ciphersuites: [1], retentionDays }.
The log is kept for 30 days, or fewer if MLS_RETENTION_DAYS says so, and
swept hourly. A device whose cursor is older than what's left is told it
has a gap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The socket coverage check reads an emit's event name only when the first
argument has no parentheses in it, so mls:message, mls:welcome and
mls:devices:changed read as documented events the server didn't have.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@sivert-io
sivert-io force-pushed the claude/GRYT-1500-mls-ds-socket branch from 5e289db to 1a07ffb Compare September 25, 2026 10:32
@sivert-io
sivert-io marked this pull request as ready for review September 25, 2026 10:39
@sivert-io
sivert-io merged commit 0affdb5 into main Sep 25, 2026
5 checks passed
@sivert-io
sivert-io deleted the claude/GRYT-1500-mls-ds-socket branch September 25, 2026 11:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant