Skip to content

Commit 1e7f86c

Browse files
committed
⬆️(yhub) upgrade @y/hub to 0.10.1
Last version of @y/hub contains a fix we need, it fixes the websocket reconnection loop for anonymous user on a read only document sending a keepalive every 20 seconds. In both version 0.10.0 and 0.10.1 breaking changes are introduced, one is impacting us by removing the computePool.patchYdoc function used by the create-ydoc endpoint declated in our yhub server. To fix it, we are doing what the version 0.9.0 was doing and we keep the same behavior. A new environment variable YHUB_AUTH_TIMEOUT_MS is added to configure the server.authTimeout parameters in the createYhub options
1 parent a18320f commit 1e7f86c

15 files changed

Lines changed: 468 additions & 49 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ and this project adheres to
66

77
## [Unreleased]
88

9+
### Changed
10+
11+
- ⬆️(yhub) upgrade @y/hub to 0.10.1
12+
913
## [v6.0.1] - 2026-10-15
1014

1115
### Added

‎UPGRADE.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ the following command inside your docker container:
1616

1717
## [Unreleased]
1818

19+
- ⚠️ **The collaboration server moves to `@y/hub` 0.10.1: run its schema
20+
script again before starting it.** It adds the `yhub_ydoc_versions_v1`
21+
table, without which every hard deletion of a document fails, and two
22+
indexes. The helm chart's `init-db` job does it on sync (`yhub.initDb`); with
23+
docker compose, `make migrate-yhub`. Each index build holds a SHARE lock on
24+
its table, which pauses compaction for a few seconds per million rows: run it
25+
in a quiet moment.
26+
- Redis 6.2 or later (or Valkey) is now required by the collaboration server.
27+
- Roll its workers before its servers when they are deployed apart
28+
(`yhub.worker.enabled`). The other order is harmless: a stale entry in a
29+
Redis index until the document's next write.
30+
- yhub now gives up on an authentication that takes longer than
31+
`YHUB_AUTH_TIMEOUT_MS` (default 30000) and asks the caller to retry. Raise it
32+
along with `YHUB_BACKEND_REQUEST_TIMEOUT_MS`.
33+
1934
## [6.0.0] - 2026-10-05
2035

2136
⚠️ This release replaces the collaboration server. The content of a document

‎documentation/env.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ documents what each of them changes.
203203
| YHUB_TASK_DEBOUNCE_MS | How long an update waits on the redis stream before a worker persists it | 10000 |
204204
| YHUB_MIN_MESSAGE_LIFETIME_MS | How long persisted updates stay replayable from redis rather than read back from postgres | 60000 |
205205
| YHUB_BACKEND_REQUEST_TIMEOUT_MS | How long a call to the backend (`users/me`, `documents/{id}`, `accesses/me`) may take before it is given up on and the caller told to retry, in ms | 5000 |
206+
| YHUB_AUTH_TIMEOUT_MS | How long yhub waits for the whole authentication of a connection (backend calls, legacy seed) before the caller is told to retry, in ms | 30000 |
206207
| YHUB_IDENTITY_CACHE_TTL_MS | How long a resolved identity is reused for the same session cookie, in ms. 0 disables this layer (default) | 0 |
207208
| YHUB_ABILITIES_CACHE_TTL_MS | How long a document abilities verdict is served from cache, in ms, before it is re-read; deleted cluster-wide by the reset-connections kick. 0 disables it | 0 |
208209
| YHUB_ACCESS_CACHE_TTL_MS | How long an access date (the history bound) is served from cache, in ms. 0 disables it (default) | 0 |

‎src/helm/impress/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ Allow: /
442442
| `yhub.envVars.YHUB_TASK_DEBOUNCE_MS` | How long an update waits on the redis stream before a worker persists it, in ms (default: 10000) | |
443443
| `yhub.envVars.YHUB_MIN_MESSAGE_LIFETIME_MS` | How long persisted updates stay replayable from redis, in ms (default: 60000) | |
444444
| `yhub.envVars.YHUB_BACKEND_REQUEST_TIMEOUT_MS` | How long a call to the backend may take before yhub gives up on it and tells the caller to retry, in ms (default: 5000) | |
445+
| `yhub.envVars.YHUB_AUTH_TIMEOUT_MS` | How long yhub waits for the whole authentication of a connection or request (backend calls and legacy seed) before it tells the caller to retry, in ms (default: 30000) | |
445446
| `yhub.envVars.SOFT_MIGRATION` | Set to "true" to seed rooms from the legacy Django/S3 document store on first access | |
446447
| `yhub.envVars.LEGACY_S3_ENDPOINT_URL` | Required by SOFT_MIGRATION, endpoint of the legacy Django media bucket, without a path (e.g. https://s3.example.com) | |
447448
| `yhub.envVars.LEGACY_S3_ACCESS_KEY_ID` | Required by SOFT_MIGRATION, read access to the legacy bucket (or LEGACY_S3_ACCESS_KEY_ID_FILE) | |

‎src/helm/impress/values.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,6 +1189,7 @@ yhub:
11891189
## @extra yhub.envVars.YHUB_TASK_DEBOUNCE_MS How long an update waits on the redis stream before a worker persists it, in ms (default: 10000)
11901190
## @extra yhub.envVars.YHUB_MIN_MESSAGE_LIFETIME_MS How long persisted updates stay replayable from redis, in ms (default: 60000)
11911191
## @extra yhub.envVars.YHUB_BACKEND_REQUEST_TIMEOUT_MS How long a call to the backend may take before yhub gives up on it and tells the caller to retry, in ms (default: 5000)
1192+
## @extra yhub.envVars.YHUB_AUTH_TIMEOUT_MS How long yhub waits for the whole authentication of a connection or request (backend calls and legacy seed) before it tells the caller to retry, in ms (default: 30000)
11921193
## @extra yhub.envVars.YHUB_IDENTITY_CACHE_TTL_MS How long a resolved identity is reused for the same session cookie, in ms; 0 disables this layer (default)
11931194
## @extra yhub.envVars.YHUB_ABILITIES_CACHE_TTL_MS How long a document abilities verdict is served from cache, in ms, before it is re-read; deleted cluster-wide by the reset-connections kick; 0 disables it
11941195
## @extra yhub.envVars.YHUB_ACCESS_CACHE_TTL_MS How long an access date (the history bound) is served from cache, in ms; 0 disables it (default)

‎src/yhub-server/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ were configurable:
274274
| `YHUB_TASK_DEBOUNCE_MS` | `10000` | How long an update waits on the stream before a worker persists it |
275275
| `YHUB_MIN_MESSAGE_LIFETIME_MS` | `60000` | How long persisted updates stay replayable from redis |
276276
| `YHUB_BACKEND_REQUEST_TIMEOUT_MS` | `5000` | How long a call to the Docs backend may take before it is given up on |
277+
| `YHUB_AUTH_TIMEOUT_MS` | `30000` | How long yhub waits for `authenticate` or `authorize` as a whole (backend calls, legacy seed) before it answers 503; raise it with `YHUB_BACKEND_REQUEST_TIMEOUT_MS` |
277278
| `YHUB_IDENTITY_CACHE_TTL_MS` | `0` | How long a resolved `users/me` identity is reused for the same Cookie header (authz cache, off by default) |
278279
| `YHUB_ABILITIES_CACHE_TTL_MS` | `0` | How long a `documents/{id}` verdict is served from cache before it is re-read (deleted by `reset-connections`, see the authz-cache section) |
279280
| `YHUB_ACCESS_CACHE_TTL_MS` | `0` | How long an `accesses/me` date is served from cache before it is re-read |
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
// src/api.ts — the custom REST endpoints. What is covered here is
2+
// `create-ydoc`, the one handler that built its stream message out of a yhub
3+
// internal: it diffed the posted update in the compute pool's `patchYdoc`,
4+
// which yhub 0.9.1 removed. The handler wrapped that call in a catch-all that
5+
// answers "Invalid Yjs update", so the removal turned every document creation
6+
// into a 400 without a type error at runtime, and with no test failing.
7+
//
8+
// `@y/hub` is faked down to what the module imports; `@y/y` is real, so the
9+
// updates are genuine and the contentmap is decoded the way yhub decodes it.
10+
// The fake instance has the compute pool of yhub 0.10 — no `patchYdoc` — and
11+
// these tests would answer 400 again if the handler went back to it.
12+
13+
import { beforeEach, describe, expect, it, vi } from 'vitest';
14+
15+
import * as Y from '@y/y';
16+
17+
import { makeUpdate } from './_helpers.js';
18+
19+
vi.mock('@y/hub', () => {
20+
const logger = {
21+
info: vi.fn(),
22+
warn: vi.fn(),
23+
error: vi.fn(),
24+
child: () => logger,
25+
};
26+
return {
27+
logger,
28+
checkPermissions: vi.fn(),
29+
createApiEndpoint: (name: string, opts: unknown) => ({ name, opts }),
30+
createDocumentPermissions: (x: unknown) => x,
31+
};
32+
});
33+
34+
vi.mock('../src/backend.js', () => ({ backendPublicJwk: null }));
35+
vi.mock('../src/authz-cache.js', () => ({ invalidateDocumentAuthz: vi.fn() }));
36+
vi.mock('../src/migration.js', () => ({
37+
SOFT_MIGRATION: false,
38+
fullMigrate: vi.fn(),
39+
}));
40+
41+
const DOCID = '00000000-0000-4000-8000-000000000000';
42+
43+
type Handler = (req: unknown) => Promise<Response>;
44+
45+
const createYdoc = async (): Promise<Handler> => {
46+
const { api } = await import('../src/api.js');
47+
const endpoint = (
48+
api as unknown as Array<{
49+
name: string;
50+
opts: { post: { handler: Handler } };
51+
}>
52+
).find((e) => e.name === 'create-ydoc');
53+
if (endpoint == null) throw new Error('create-ydoc is not mounted');
54+
return endpoint.opts.post.handler;
55+
};
56+
57+
// The surface of yhub 0.10 the handler reaches: the room read, the stream
58+
// write, and a compute pool without the `patchYdoc` 0.9.1 removed.
59+
const makeYhub = (gcDoc: Uint8Array | null = null) => ({
60+
getDoc: vi.fn(async () => ({ gcDoc })),
61+
stream: { addMessage: vi.fn(async () => {}) },
62+
computePool: { mergeUpdates: vi.fn() },
63+
});
64+
65+
const makeReq = (
66+
body: Uint8Array,
67+
{
68+
yhub = makeYhub(),
69+
authInfo = { userid: 'system', admin: true } as Record<
70+
string,
71+
unknown
72+
> | null,
73+
headers = { 'x-user-id': 'user-1' } as Record<string, string>,
74+
} = {},
75+
) => ({
76+
org: 'docs',
77+
docid: DOCID,
78+
branch: 'main',
79+
docRef: { org: 'docs', docid: DOCID, branch: 'main' },
80+
// uws hands the body over as a Node Buffer, which the handler re-views
81+
bytes: async () => Buffer.from(body),
82+
headers,
83+
authInfo,
84+
permissions: {},
85+
query: {},
86+
yhub,
87+
});
88+
89+
// Every attribute the contentmap puts on the inserted ids, by name.
90+
const insertAttributes = (contentmap: Uint8Array) => {
91+
const attrs = new Map<string, unknown>();
92+
Y.decodeContentMap(contentmap).inserts.clients.forEach((ranges) =>
93+
ranges
94+
.getIds()
95+
.forEach((range) =>
96+
range.attrs.forEach((attr) => attrs.set(attr.name, attr.val)),
97+
),
98+
);
99+
return attrs;
100+
};
101+
102+
beforeEach(() => {
103+
vi.resetModules();
104+
});
105+
106+
describe('create-ydoc', () => {
107+
it('creates the document without the compute pool’s removed patchYdoc', async () => {
108+
const handler = await createYdoc();
109+
const yhub = makeYhub();
110+
const update = makeUpdate('hello world');
111+
112+
const res = await handler(makeReq(update, { yhub }));
113+
114+
expect(res.status).toBe(201);
115+
expect(await res.json()).toEqual({ message: 'Document created' });
116+
expect(yhub.computePool.mergeUpdates).not.toHaveBeenCalled();
117+
expect(yhub.stream.addMessage).toHaveBeenCalledTimes(1);
118+
});
119+
120+
it('appends the update as posted, as yhub’s own PATCH does since 0.9.1', async () => {
121+
const handler = await createYdoc();
122+
const yhub = makeYhub();
123+
const update = makeUpdate('hello world');
124+
125+
await handler(makeReq(update, { yhub }));
126+
127+
const [docRef, message] = yhub.stream.addMessage.mock
128+
.calls[0] as unknown as [
129+
unknown,
130+
{ type: string; update: Uint8Array; contentmap: Uint8Array },
131+
];
132+
expect(docRef).toEqual({ org: 'docs', docid: DOCID, branch: 'main' });
133+
expect(message.type).toBe('ydoc:update:v1');
134+
// an exact Uint8Array, not the Buffer `req.bytes()` resolved to: the
135+
// stream-message schema compares constructors
136+
expect(message.update.constructor).toBe(Uint8Array);
137+
expect(Array.from(message.update)).toEqual(Array.from(update));
138+
// and it is a document yhub can read back
139+
const doc = new Y.Doc();
140+
Y.applyUpdate(doc, message.update);
141+
expect(doc.get('t', 'text').toString()).toBe('hello world');
142+
});
143+
144+
it('attributes every inserted id to the user the admin token names, now', async () => {
145+
const handler = await createYdoc();
146+
const yhub = makeYhub();
147+
const update = makeUpdate('hello world');
148+
const before = Date.now();
149+
150+
await handler(makeReq(update, { yhub }));
151+
152+
const [, { contentmap }] = yhub.stream.addMessage.mock
153+
.calls[0] as unknown as [unknown, { contentmap: Uint8Array }];
154+
// the contentmap covers exactly the ids the update inserts
155+
const covered = Y.createContentIdsFromContentMap(
156+
Y.decodeContentMap(contentmap),
157+
);
158+
const posted = Y.createContentIdsFromUpdate(update);
159+
expect(Y.excludeContentIds(posted, covered).inserts.clients.size).toBe(0);
160+
expect(Y.excludeContentIds(covered, posted).inserts.clients.size).toBe(0);
161+
// with the author and the timestamp the activity timeline reads
162+
const attrs = insertAttributes(contentmap);
163+
expect(attrs.get('insert')).toBe('user-1');
164+
expect(attrs.get('insertAt')).toBeGreaterThanOrEqual(before);
165+
expect(attrs.get('insertAt')).toBeLessThanOrEqual(Date.now());
166+
});
167+
168+
it('attributes a user’s own create to them, whatever X-User-Id says', async () => {
169+
const handler = await createYdoc();
170+
const yhub = makeYhub();
171+
172+
await handler(
173+
makeReq(makeUpdate(), {
174+
yhub,
175+
authInfo: { userid: 'user-2' },
176+
headers: { 'x-user-id': 'someone-else' },
177+
}),
178+
);
179+
180+
const [, { contentmap }] = yhub.stream.addMessage.mock
181+
.calls[0] as unknown as [unknown, { contentmap: Uint8Array }];
182+
expect(insertAttributes(contentmap).get('insert')).toBe('user-2');
183+
});
184+
185+
it('falls back to the admin identity when no X-User-Id is sent', async () => {
186+
const handler = await createYdoc();
187+
const yhub = makeYhub();
188+
189+
await handler(makeReq(makeUpdate(), { yhub, headers: {} }));
190+
191+
const [, { contentmap }] = yhub.stream.addMessage.mock
192+
.calls[0] as unknown as [unknown, { contentmap: Uint8Array }];
193+
expect(insertAttributes(contentmap).get('insert')).toBe('system');
194+
});
195+
196+
it.each([
197+
['truncated', makeUpdate('hello world').slice(0, -5)],
198+
['garbage', new Uint8Array([7, 7, 7, 7, 7, 7, 7, 7, 7, 7])],
199+
[
200+
'corrupted',
201+
(() => {
202+
const update = makeUpdate('hello world');
203+
update[5] ^= 0xff;
204+
update[10] ^= 0xff;
205+
return update;
206+
})(),
207+
],
208+
])('refuses a %s update with 400 and writes nothing', async (_, update) => {
209+
const handler = await createYdoc();
210+
const yhub = makeYhub();
211+
212+
const res = await handler(makeReq(update, { yhub }));
213+
214+
expect(res.status).toBe(400);
215+
expect(await res.json()).toEqual({ error: 'Invalid Yjs update' });
216+
expect(yhub.stream.addMessage).not.toHaveBeenCalled();
217+
});
218+
219+
it('refuses an update that inserts nothing, which would create no room', async () => {
220+
// a delete set alone is a valid update of more than three bytes
221+
const doc = new Y.Doc();
222+
doc.get('t', 'text').insert(0, 'abc');
223+
const stateVector = Y.encodeStateVector(doc);
224+
doc.get('t', 'text').delete(0, 3);
225+
const deleteOnly = Y.encodeStateAsUpdate(doc, stateVector);
226+
expect(deleteOnly.byteLength).toBeGreaterThan(3);
227+
const handler = await createYdoc();
228+
const yhub = makeYhub();
229+
230+
const res = await handler(makeReq(deleteOnly, { yhub }));
231+
232+
expect(res.status).toBe(400);
233+
expect(await res.json()).toEqual({ error: 'Empty update' });
234+
expect(yhub.stream.addMessage).not.toHaveBeenCalled();
235+
});
236+
237+
it('refuses an empty body before reading the room', async () => {
238+
const handler = await createYdoc();
239+
const yhub = makeYhub();
240+
241+
const res = await handler(makeReq(new Uint8Array([0, 0]), { yhub }));
242+
243+
expect(res.status).toBe(400);
244+
expect(yhub.getDoc).not.toHaveBeenCalled();
245+
});
246+
247+
it('answers 409 on a room that already has content, and writes nothing', async () => {
248+
const handler = await createYdoc();
249+
const yhub = makeYhub(makeUpdate('already here'));
250+
251+
const res = await handler(makeReq(makeUpdate(), { yhub }));
252+
253+
expect(res.status).toBe(409);
254+
expect(yhub.stream.addMessage).not.toHaveBeenCalled();
255+
});
256+
257+
it('refuses an anonymous caller', async () => {
258+
const handler = await createYdoc();
259+
const yhub = makeYhub();
260+
261+
const res = await handler(makeReq(makeUpdate(), { yhub, authInfo: null }));
262+
263+
expect(res.status).toBe(401);
264+
expect(yhub.stream.addMessage).not.toHaveBeenCalled();
265+
});
266+
});

‎src/yhub-server/__tests__/config.spec.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ beforeEach(() => {
2525
'YHUB_TASK_DEBOUNCE_MS',
2626
'YHUB_MIN_MESSAGE_LIFETIME_MS',
2727
'YHUB_BACKEND_REQUEST_TIMEOUT_MS',
28+
'YHUB_AUTH_TIMEOUT_MS',
2829
'YHUB_IDENTITY_CACHE_TTL_MS',
2930
'YHUB_ABILITIES_CACHE_TTL_MS',
3031
'YHUB_ACCESS_CACHE_TTL_MS',
@@ -117,6 +118,28 @@ describe('the numeric tuning knobs', () => {
117118
vi.stubEnv('YHUB_BACKEND_REQUEST_TIMEOUT_MS', '5s');
118119
await expect(load()).rejects.toThrow(/YHUB_BACKEND_REQUEST_TIMEOUT_MS/);
119120
});
121+
122+
it('gives the auth hooks 30s, above the 10s yhub 0.10 defaults to', async () => {
123+
// `authorize` chains two backend calls and the 10s S3 fetch of a legacy
124+
// seed: yhub's own default would cut a slow seed short
125+
const { AUTH_TIMEOUT_MS } = await load();
126+
expect(AUTH_TIMEOUT_MS).toBe(30000);
127+
128+
vi.resetModules();
129+
vi.stubEnv('YHUB_AUTH_TIMEOUT_MS', '45000');
130+
expect((await load()).AUTH_TIMEOUT_MS).toBe(45000);
131+
});
132+
133+
it('refuses an auth timeout that is not a positive whole number of ms', async () => {
134+
vi.stubEnv('YHUB_AUTH_TIMEOUT_MS', '0');
135+
await expect(load()).rejects.toThrow(
136+
/YHUB_AUTH_TIMEOUT_MS must be an integer >= 1/,
137+
);
138+
139+
vi.resetModules();
140+
vi.stubEnv('YHUB_AUTH_TIMEOUT_MS', '30s');
141+
await expect(load()).rejects.toThrow(/YHUB_AUTH_TIMEOUT_MS/);
142+
});
120143
});
121144

122145
describe('the origin allowlist', () => {

0 commit comments

Comments
 (0)