diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index c0bcc632c..2c34a4270 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -601,8 +601,15 @@ inbound frame names the lineage, then sends them as one update — so a row created and placed inside the bind round trip is never refused. Two more socket-level defenses: per-frame payload caps (64 KB awareness / 4 MB sync, plus the transport `maxPayloadLength`) drop oversized frames before any decode work, and every awareness frame is decoded and -checked against the session — a state claiming another user's identity -(`state.user.id !== session user`) is dropped, so presence can't be spoofed. +checked against the session — a state whose identity (id, name, avatar, +Gravatar hash) differs from the session's is dropped, so presence can't be +spoofed. The session name is `userDisplayLabel(user)` +(`src/core/users/displayName.ts`): the display name, or the email address +when the admin has none ("Your name" is optional at setup). The editor +publishes the same label, so collaborators see the **email address** of an +admin without a display name. Before, such an admin published no presence +at all: no cursor, no selection ring, and no "someone else is editing" +guard. **Client transport** (`src/admin/pages/site/collab/collabProvider.ts`): one socket, every bound doc multiplexed; local transactions send updates the diff --git a/server/collab/socket.ts b/server/collab/socket.ts index 790b99203..56d933b8b 100644 --- a/server/collab/socket.ts +++ b/server/collab/socket.ts @@ -54,6 +54,7 @@ import { originAllowed } from '../auth/security' import type { DbClient } from '../db/client' import { jsonResponse } from '../http' import { BranchGoneError } from './relayBranches' +import { userDisplayLabel } from '@core/users/displayName' import type { CollabRelay, RelayDoc } from './relay' export { SITE_SOCKET_PATH } @@ -215,7 +216,7 @@ export async function handleCollabSocketUpgrade( userId: user.id, identity: { id: user.id, - name: user.displayName, + name: userDisplayLabel(user), avatarUrl: user.avatarUrl, gravatarHash: user.gravatarHash, }, diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index d11c1f848..b0a02c894 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -14,6 +14,7 @@ * - `runPublishFlush` drains the persist debounce, so publish bakes the * edit an admin made seconds earlier instead of losing it. */ +import { userDisplayLabel } from '@core/users/displayName' import { afterEach, describe, expect, it } from 'bun:test' import * as Y from 'yjs' import * as awarenessProtocol from 'y-protocols/awareness' @@ -349,7 +350,7 @@ describe('collab relay integration (real server, real sockets)', () => { const viewerUserId = viewerUser!.id const realIdentity = { id: viewerUserId, - name: viewerUser!.displayName, + name: userDisplayLabel(viewerUser!), color: peerColor(viewerUserId), avatarUrl: viewerUser!.avatarUrl, gravatarHash: viewerUser!.gravatarHash, @@ -369,7 +370,7 @@ describe('collab relay integration (real server, real sockets)', () => { for (const [, state] of owner.awareness.getStates()) { const s = state as { user?: { id?: string; name?: string } } expect(s.user?.id).not.toBe('someone-else') - if (s.user?.id === viewerUserId) expect(s.user?.name).toBe(viewerUser!.displayName) + if (s.user?.id === viewerUserId) expect(s.user?.name).toBe(userDisplayLabel(viewerUser!)) } }) @@ -431,7 +432,7 @@ describe('collab relay integration (real server, real sockets)', () => { const user = (await findUserByEmail(stack.harness.db, email))! return { id: user.id, - name: user.displayName, + name: userDisplayLabel(user), color: peerColor(user.id), avatarUrl: user.avatarUrl, gravatarHash: user.gravatarHash, diff --git a/src/__tests__/server/collabSocket.test.ts b/src/__tests__/server/collabSocket.test.ts index a027ff774..0496ab4a7 100644 --- a/src/__tests__/server/collabSocket.test.ts +++ b/src/__tests__/server/collabSocket.test.ts @@ -115,6 +115,23 @@ describe('collab socket — upgrade gating', () => { expect(readOnlyServer.data()?.fullSiteWriter).toBe(false) }) + // "Your name" is optional: without a display name the session identity (the + // only presence name the relay forwards) must still be non-empty and match + // what the editor publishes (userDisplayLabel) — otherwise that admin's + // presence never reaches anyone. + it('names an admin without a display name by email in the presence identity', async () => { + const { harness } = await setup() + const nameless = await harness.createRoleUser({ + name: 'Nameless', + slug: 'site-nameless', + displayName: '', + capabilities: ['site.read'], + }) + const server = fakeServer(true) + expect(await handleCollabSocketUpgrade(socketRequest({ cookie: nameless.cookie }), harness.db, server)).toBeNull() + expect(server.data()?.identity.name).toBe(nameless.email) + }) + it('returns 426 when the request is not an upgradable WebSocket handshake', async () => { const { harness, cookie } = await setup() const res = await handleCollabSocketUpgrade(socketRequest({ cookie }), harness.db, fakeServer(false)) diff --git a/src/__tests__/users/displayName.test.ts b/src/__tests__/users/displayName.test.ts new file mode 100644 index 000000000..9e860084a --- /dev/null +++ b/src/__tests__/users/displayName.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'bun:test' +import { userDisplayLabel } from '@core/users/displayName' + +describe('userDisplayLabel', () => { + it('uses the trimmed display name, else the email', () => { + expect(userDisplayLabel({ displayName: ' Alex ', email: 'n@x.de' })).toBe('Alex') + expect(userDisplayLabel({ displayName: ' ', email: 'n@x.de' })).toBe('n@x.de') + expect(userDisplayLabel({ displayName: '', email: 'n@x.de' })).toBe('n@x.de') + }) +}) diff --git a/src/admin/pages/account/tabs/ProfileTab.tsx b/src/admin/pages/account/tabs/ProfileTab.tsx index 887138335..6c9861c69 100644 --- a/src/admin/pages/account/tabs/ProfileTab.tsx +++ b/src/admin/pages/account/tabs/ProfileTab.tsx @@ -23,6 +23,7 @@ import { UserAvatar } from '@admin/shared/UserAvatar' import styles from '../AccountPage.module.css' import { getErrorMessage } from '@core/utils/errorMessage' import { isStepUpCancelled } from './securityErrors' +import { userDisplayLabel } from '@core/users/displayName' interface ProfileTabProps { user: CmsCurrentUser @@ -86,7 +87,7 @@ export function ProfileTab({ user }: ProfileTabProps) { const displayNameId = useId() const emailId = useId() - const displayName = user.displayName.trim() || user.email + const displayName = userDisplayLabel(user) const hasUploadedAvatar = user.avatarUrl !== null const profileDirty = profileForm.displayName.trim() !== user.displayName || diff --git a/src/admin/pages/dashboard/widgets/ActivityWidget.tsx b/src/admin/pages/dashboard/widgets/ActivityWidget.tsx index e486b112e..9fdc06e14 100644 --- a/src/admin/pages/dashboard/widgets/ActivityWidget.tsx +++ b/src/admin/pages/dashboard/widgets/ActivityWidget.tsx @@ -20,6 +20,7 @@ import { type DashboardActivityEntry, } from '../hooks/useDashboardStats' import styles from './widgets.module.css' +import { userDisplayLabel } from '@core/users/displayName' /** * Diameter of the actor avatar in the feed row. Picked to fit the @@ -187,7 +188,7 @@ export function ActivityWidget({ span, editing }: DashboardWidgetRendererProps) ) : (