Repository navigation
The MLS delivery service over the socket (GRYT-1500) - #241
Merged
Merged
Conversation
This was referenced Sep 25, 2026
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
force-pushed
the
claude/GRYT-1500-mls-ds-socket
branch
from
September 25, 2026 10:32
5e289db to
1a07ffb
Compare
sivert-io
marked this pull request as ready for review
September 25, 2026 10:39
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsdecodes bytes anybody signed in can send. It'sts-mls's own decoder, and nothing gets decrypted or verified. It's still a
parser running on untrusted input.
The server loads it with
require("ts-mls/message.js")andrequire("ts-mls/keyPackage.js"), because the package index pulls in thenoble 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 enginesfield already asks for.
memberDevicesFor) covers Adds sent by value, in a commitor 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.
dm:openrefuses 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 takingaccessToken:mls:keypackages:publish { deviceId, keyPackages: bytes[], lastResort? }→
{ stored, unclaimed, lastResort }. The first publish registers thedevice. 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 inthe conversation.
mls:device:remove { deviceId }, your own only.mls:group:create { conversationId, groupId }, hex group id. The losergets
group_existswith the winner'sgroup.mls:commit { conversationId, deviceId, commit, welcome? }→{ seq, epoch },or
stale_epochwithepochandheadSeq.mls:send { conversationId, deviceId, message }→{ seq }. Applicationmessages 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:openedon the first message, as for a sealed DM.server:infogainsmls: { 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
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:newpath. That's GRYT-1508.is uploaded. It can't until the certificate format is released in
@gryt/crypto. GRYT-1509.messagestable, so the server's unreadcounts 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 fromts-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:creategotgroup_exists. Two commitsracing for epoch 1 came back as one
{ ok, seq: 3, epoch: 2 }and onestale_epoch.Task: GRYT-1500. Part of GRYT-754 and GRYT-1244.
🤖 Generated with Claude Code