feat: F5.3b the editor on a project of server F

?project=<uuid> opens the shared document over /sync and the room over
/sim (RemoteSimHost: messages wait for the socket, a lost socket comes
back with what it watched, F's refusal or parting ends it); instead of
the editor, a word why when F refuses or lets go. F tells each
connection its access at once. Who only reads sees everything and
changes nothing: no palette or undo, a locked surface, the sheet's
fields disabled (tabs stay), no starting or stopping; thawed, the
document comes anew. The toolbar names who started the simulation.
Refusals carry no message, so Hocuspocus writes nothing outside F's
JSON log. A browser test runs three people against a real F.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 15:00:18 +00:00
co-authored by Claude Opus 5.5
parent 8cfc518f0c
commit f52852493a
28 changed files with 1142 additions and 58 deletions
+28 -8
View File
@@ -11,12 +11,12 @@
import { randomUUID } from 'node:crypto';
import pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import WebSocket from 'ws';
import * as Y from 'yjs';
import { addDevice, emptyProject, readProject, ROOTS } from '@mdg-filius/doc';
import { SYNC_CLOSINGS, SYNC_REFUSALS } from '@mdg-filius/protocol';
import { cookieValue, PostgresSessions, PostgresStore, safeNext, SERVICE_BODY_BYTES, serviceKeyMatches, SESSION_COOKIE, SESSION_SECONDS, sessionCookie, type SessionStore } from '../src/index.js';
import { cookieValue, PostgresSessions, PostgresStore, refusal, safeNext, SERVICE_BODY_BYTES, serviceKeyMatches, SESSION_COOKIE, SESSION_SECONDS, sessionCookie, type SessionStore } from '../src/index.js';
import { testDatabase, type TestDatabase } from './database.js';
import { EDITOR_ORIGIN, enter, exchange, join, newProject as makeProject, SERVICE_KEY, signIn, startTestServer, TICKET_SECRET, until, type TestServer } from './harness.js';
import { signTicket } from './tickets.js';
@@ -226,6 +226,21 @@ describe('the way into a shared document', () => {
await server.close();
});
it('refuses without a word on the console: the reason goes to the editor, the log has the rest', async () => {
expect([refusal('noAccess').reason, refusal('noAccess').message]).toEqual(['noAccess', '']);
const server = await start();
const project = await newProject();
const written: unknown[] = [];
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => void written.push(args));
try {
await expect(join(server, project)).rejects.toThrow('refused: notSignedIn');
} finally {
spy.mockRestore();
}
expect(written).toEqual([]);
await server.close();
});
it('reads a cookie among others, and nothing that only looks like it', () => {
expect(cookieValue(`a=1; ${SESSION_COOKIE}=key; b=2`, SESSION_COOKIE)).toBe('key');
expect(cookieValue(`${SESSION_COOKIE}=key=with=equals`, SESSION_COOKIE)).toBe('key=with=equals');
@@ -306,25 +321,30 @@ describe("the dashboard's service", () => {
const project = await newProject();
const writer = await enter(server, project, { level: 'write' });
const reader = await enter(server, project, { level: 'read' });
// Each is told its access as it comes in.
await until(() => writer.messages.length === 1 && reader.messages.length === 1);
expect([writer.messages, reader.messages]).toEqual([[{ v: 1, kind: 'access', readOnly: false, frozen: false }], [{ v: 1, kind: 'access', readOnly: true, frozen: false }]]);
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: true }))).toEqual([200, { frozen: true }]);
await until(() => writer.messages.length === 1);
expect(writer.messages).toEqual([{ v: 1, kind: 'access', readOnly: true, frozen: true }]);
await until(() => writer.messages.length === 2);
expect(writer.messages[1]).toEqual({ v: 1, kind: 'access', readOnly: true, frozen: true });
// Whoever comes now reads too, with a write grant; what they change is not taken.
const late = await enter(server, project, { level: 'write' });
expect(late.provider.authorizedScope).toBe('readonly');
await until(() => late.messages.length === 1);
expect(late.messages).toEqual([{ v: 1, kind: 'access', readOnly: true, frozen: true }]);
place(late.doc, 'a');
await sleep(300);
expect(await store.pending(project)).toBe(0);
expect(deviceCount(writer.doc)).toBe(0);
late.close();
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: false }))).toEqual([200, { frozen: false }]);
await until(() => writer.messages.length === 2);
expect(writer.messages[1]).toEqual({ v: 1, kind: 'access', readOnly: false, frozen: false });
await until(() => writer.messages.length === 3);
expect(writer.messages[2]).toEqual({ v: 1, kind: 'access', readOnly: false, frozen: false });
place(writer.doc, 'b');
await until(async () => (await store.pending(project)) === 1);
await until(() => deviceCount(reader.doc) === 1);
// The reader was told nothing: it read before and reads still.
expect(reader.messages).toEqual([]);
// The reader was told nothing more: it read before and reads still.
expect(reader.messages).toHaveLength(1);
expect(server.log.lines.filter((line) => line.event === 'svc.frozen').map((line) => line.frozen)).toEqual([true, false]);
writer.close();
reader.close();