Skip to content

Tell one person, in a kind the client knows how to render (GRYT-896) - #130

Merged
sivert-io merged 1 commit into
mainfrom
claude/GRYT-896-directed-notices
Sep 3, 2026
Merged

Tell one person, in a kind the client knows how to render (GRYT-896)#130
sivert-io merged 1 commit into
mainfrom
claude/GRYT-896-directed-notices

Conversation

@sivert-io

Copy link
Copy Markdown
Member

Server half. Client half is Gryt-chat/client#374merge this one first, or a client that knows the event never gets sent one.

What was wrong

The outdated-client reminder went through postSystemMessage, which does an insertMessage and broadcasts. So a notice addressed to one person by name — "your Windows client (v1.6.24) cannot update itself" — was stored permanently and shown to everybody in the channel. The whole server learned somebody's install was broken, and there was no way to take it back.

The shape

The server sends a kind and a version. It does not send a sentence.

That is the security property, not a style choice. A panel rendered in app furniture, carrying text the server chose, addressed to one person, is a phishing message with a nice border: "Your Gryt session has expired, sign in at …". Attribution and link-stripping make that harder. Sending no text makes it impossible. Every string that reaches a screen now ships in the client.

It is worth nothing if a value can be a sentence, so every field is checked on the way out and a failing notice is dropped rather than trimmed — a half-filled notice renders copy that does not match what happened.

export type ClientNotice = {
  kind: "outdated_client";
  version: string;   // /^\d{1,4}\.\d{1,4}\.\d{1,4}$/ and nothing else
};

Delivery is that person's sockets only. clientsInfo already maps every socket to a serverUserId and chat.ts already filters on it, so this is the same shape rather than new machinery. Nothing is written down — it is state, re-sent on the next join while the condition holds.

The cost: a client release whenever there is something new to say. Accepted — there is one kind today, and anything genuinely bespoke belongs in postSystemMessage, which is public, attributable and deletable. Join and leave stay there.

A bug the tests did not catch until the rig did

The first version emitted with io.to(clientId) — a room lookup — and reached nobody. Every other targeted emit in this codebase uses io.sockets.sockets.get(cid).

The fake in the test had a to() method on it, so it passed against an implementation that sent nothing. It is shaped like the real socket registry now, and a notice only counts as sent if a registered socket received it. Worth knowing about, because a fake shaped to match the code rather than the runtime is a test that agrees with whatever it is given.

Tests

Seven, four mutations, each caught:

mutation caught
version pattern widened to any non-empty string
validity gate removed
per-user filter removed, so it broadcasts
message field added to ClientNotice

That last one is a source check, because a field carrying prose would typecheck perfectly and hand the server back the exact thing this shape exists to take away.

What to look at

  • remindOutdatedWindowsClient lost its nickname argument. The client renders this to the person it is about, so it says "your client".
  • It only fires on server:verify, not on a session restore — same as before this change, but it means an already-joined client reconnecting will not see it until their session lapses. Pre-existing; flagging it rather than widening the diff.
  • formatUpdateReminder and the installer URL are gone from here. They live in the client now.

yarn test 694 pass. tsc --noEmit and eslint clean.

🤖 Generated with Claude Code

The outdated-client reminder was a chat message. `postSystemMessage` writes a
row into the messages table and broadcasts it, so a notice addressed to one
person by name — "your Windows client (v1.6.24) cannot update itself" — was
stored for good and shown to everybody in the channel. The whole server learned
that somebody's install was broken, and there was no way to take it back.

It is a directed notice now: that person's sockets only, nothing written down.
`clientsInfo` already maps every socket to a `serverUserId` and `chat.ts`
already filters on it, so the delivery is the same shape rather than new
machinery.

**The server sends a kind and a version. It does not send a sentence.**

That is the security property and it is the reason for the shape. A panel in
app furniture, carrying text the server chose, addressed to one person, is a
phishing message with a nice border: "Your Gryt session has expired, sign in at
…". Attribution and link-stripping make that harder. Sending no text makes it
impossible. Every string that reaches the screen now ships in the client.

It is worth nothing if a *value* can be a sentence, so each field is checked on
the way out and a notice that fails is dropped rather than trimmed — a
half-filled notice would render copy that does not match what happened. A
version is `x.y.z` and nothing else.

The cost is a client release whenever there is something new to say. Accepted:
there is one kind today, and anything genuinely bespoke belongs in
`postSystemMessage`, which is public, attributable and deletable — the right
shape for something a server tells everybody. Join and leave stay there.

`remindOutdatedWindowsClient` loses its `nickname` argument. The client renders
this to the person it is about, so it says "your client"; there is nobody else
in the room to disambiguate for. `formatUpdateReminder` and the installer URL
go with it, to the client.

One bug the tests did not catch until the rig did: the first version emitted
with `io.to(clientId)`, a room lookup, and reached nobody. Every other targeted
emit here uses `io.sockets.sockets.get(cid)`. The fake in the test had a `to()`
method, so it passed against an implementation that sent nothing — it is shaped
like the real registry now, and a notice only counts as sent if a registered
socket received it.

Seven checks, four mutations, each caught: widening the version pattern to any
non-empty string, dropping the validity gate, dropping the per-user filter so it
broadcasts, and growing a `message` field on the notice type. The last is a
source check, because a field carrying prose would typecheck perfectly and hand
the server back the exact thing this shape exists to take away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sivert-io
sivert-io merged commit 12350b7 into main Sep 3, 2026
2 checks passed
@sivert-io
sivert-io deleted the claude/GRYT-896-directed-notices branch September 3, 2026 22:01
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