Skip to content

Yes on the guest prompt moved nothing when the account was already a member (GRYT-1225) - #193

Merged
sivert-io merged 1 commit into
mainfrom
claude/GRYT-1225-claim-edge-cases
Sep 15, 2026
Merged

sivert-io merged 1 commit into
mainfrom
claude/GRYT-1225-claim-edge-cases

Conversation

@sivert-io

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

Copy link
Copy Markdown
Member

Review-required. This changes src/db/sqlite, so Sivert reads the whole diff before it merges. Don't merge it on green.

GRYT-1225. The client half is Gryt-chat/client#587, and it needs this released first.

Gryt-chat/docs#116 documents chat:merge_user, and the two API reference checks wait on each other. This one reads docs main, which documents 147 of the 212 socket events. The check wants 70%. The one on docs#116 reads server main, which doesn't have chat:merge_user yet. So it fails on an event the server doesn't have. Merge this first and docs#116 straight after. Server main is under the coverage floor only until docs#116 lands.

What yes did before

Yes on "Were you here as a guest before?" stores the answer, drops the server session and reconnects. On that join the account signs a link with the guest's key, and carryIdentityForward points the guest's users row at the account. It's the same server_user_id, so name, picture, roles, messages and ownership all come along.

If the account was already a member, it returned account_already_member, logged a line and moved nothing. The client wasn't told. That happens when the account joined from a phone first. It also happens when this device added the server again after signing in, since that joins as the account before the prompt shows.

What it does now

carryIdentityForward merges the guest into the account's row instead, in one BEGIN IMMEDIATE transaction (mergeGuestIntoAccount in src/db/sqlite/mergeGuest.ts):

  • Every column that names the guest as sender, uploader, creator, reporter or moderator now names the account. That covers messages, reactions, threads and forum topics, conversations and their members, mentions, files, emojis, emoji jobs, webhooks, invites, message and user reports, bans, join requests, bots and the audit log.
  • Where both had a row, the account's is kept. A reaction both left with the same emoji counts once, and so does a conversation or a mention both were in.
  • Blocks move over in both directions. A block between the guest and the account would be a block on yourself, so it goes.
  • The account keeps its own name, picture and roles. If the guest owned the server, the account owns it now and gets the owner role.
  • The account's row takes the stricter moderation state and the earlier join date, and stays active if either was.
  • The server revokes the guest's refresh tokens and deletes its row, which is where a move ends up too. A second claim finds no guest row and gets no_prior_membership, and the join carries on as normal.

The join then tells the client what happened, with identityClaim on server:joined: carried, merged, no_prior_membership or failed. It's only there when a link came with the join.

After a merge the server clears its message cache. It sends chat:merge_user with both ids to verified sockets, so clients can rewrite the ids on messages and reactions they already have. It also sends each of the guest's conversations to its members again. Any other socket still signed in as the guest gets token:revoked with the reason identity_merged.

A socket that proves a different identity than the one it holds now drops the old one straight away, before the new one is admitted. Until now, a device that said no and was then refused, say because the server needs an invite, kept getting member broadcasts as the guest.

Look closely at

  • AUTHOR_COLUMNS is the list of every column that can name the guest. mergeGuest.test.ts reads the schema and fails on any column matching server_user_id, sender_server_id or created_by that isn't listed or handled. The test can't see audit_log.target, which holds all kinds of ids, so that one is listed by hand.
  • Moderation: a live mute on either row carries over, and so does a deafen. Of two timed mutes the later expiry wins, and an indefinite one beats both. Otherwise yes would be a way to shed a mute.
  • forgetIdentity in join.ts runs for every socket that verifies as someone else, including ones that never saw the prompt. For a move the account ends up on the same server_user_id, so the SFU sync should leave a call alone. For a merge or a no the id changes, and the sync drops a socket in a call a couple of seconds later. I didn't run an SFU, so the voice part is read off the code.
  • DMs keep their conversation ids, because sealed messages are bound to the id. A DM the guest had with someone shows up next to one the account already has with them. Sealed messages sent to the guest are wrapped for the guest's member id, so the account can't open those.
  • The move path still leaves blocks on the old guest id, so a block on a guest stops applying once it moves. That's GRYT-1249.

Tests

src/db/sqlite/mergeGuest.test.ts covers messages, reactions both left on one message, attachment ownership, ownership moving from a guest owner, an account that already owns, and a second claim. It also covers conversations, thread authorship, mentions, blocks, revoked refresh tokens, mutes, a rollback when a step fails, and the schema check. carryIdentity.test.ts now expects merged where it expected account_already_member. I broke the reaction dedupe, blocks, ownership, rollback, conversation dedupe and the files column one at a time, and each one failed a test.

yarn test (1326 passing), yarn test:examples, yarn build, npx eslint . and check-comment-length pass locally.

Checked against the client

I ran this branch as a throwaway server with the client branch and drove both in headless Chromium, reading the DOM, the socket frames and the database. Keycloak needs credentials I can't type, so I faked three things, all uncommitted and since reverted:

  • The client: getValidIdentityToken, useAccount and useUserId answered for a fake account when a gryt_fake_account key was set in localStorage.
  • The identity service: a small local one that signed an account certificate for whatever sub the fake token named. The server trusted it through GRYT_TRUSTED_CERT_ISSUERS.
  • The account's store on the device was seeded with the guest's server list and a nickname, which signing in doesn't do on its own.

65 checks passed. Among them:

  • Merge: the guest owned the server, both had reacted 👍 to the guest's message, and the guest had a DM with a third member. After yes, the guest's row was gone and no row still named it. The message and both reactions were the account's, with 👍 at 1, and the account owned the server with roles member,owner. The DM was between the account and the third member, and the third member was sent it again. The phone never reloaded, and its row went from "Birch 9:49 PM hello from the guest 👍 2" to "Sivert 9:49 PM hello from the guest 👍 1".
  • A second claim joined with identityClaim: no_prior_membership and changed nothing.
  • A second tab on the guest got token:revoked with identity_merged and rejoined as the account without an error, in 3 runs out of 3.
  • A step failing (a trigger refusing the delete) sent failed and left everything as it was.
  • After no, with the account refused because the server needed an invite, the socket got 0 member broadcasts. With the forgetIdentity line taken out it got 3.

The client's yarn e2e passed against this branch too: 13 passed, and the phone settings test skipped because it needs a server of its own.

🤖 Generated with Claude Code

…member (GRYT-1225)

carryIdentityForward returned account_already_member when the account already
had a row. So a yes after joining from a phone, or after adding the server
again once signed in, moved nothing and told nobody.

It now merges the guest into the account's row in one transaction. Every
column naming the guest as sender, uploader, creator, reporter or moderator
names the account instead. A reaction, conversation or mention both had counts
once, and blocks follow in both directions. The account keeps its name,
picture and roles. It gets ownership and the owner role if the guest owned the
server. Either way it takes the stricter moderation state and the earlier join
date. The guest's refresh tokens are revoked and its row deleted, so a second
claim gets no_prior_membership.

server:joined carries identityClaim when a link came with the join. After a
merge the server clears its message cache, sends chat:merge_user, resends the
guest's conversations to their members and revokes other sockets still on the
guest.

A socket that proves a different identity now drops the old one before the new
one is admitted, so a refused switch stops getting member broadcasts as the
guest.

Co-Authored-By: Claude Opus 5 <[email protected]>
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