(collaboration) build the version history from the activity api

The version history has been dead since the migration. It listed S3 object
versions of the legacy `{pk}/file` key, and nothing writes that key any more,
so every document's list has been frozen at its migration date; restoring one
was a stub that closed the modal and did nothing, while still promising that
the document would be replaced.

It now reads the collaboration server, which is what keeps the history: the
list comes from `activity`, a selected version is previewed from `changeset`
as the document stood at that moment, and restoring one is a `rollback`.

A version is a minute of editing — changes less than a minute apart become
one, and none spans more than a minute. The collaboration server groups only
changes by the same author, so the browser merges what is left across authors:
a version is a moment in the document, not a moment in one person's editing.
Both are needed, and both use the same rule.

This grants `history.rollback` to editors, which is the first time a browser
may change the past rather than read it, and publishes the rollback route.
A reader is refused it twice over — the collaboration server treats it as a
dead grant without document write access, and the endpoint is withheld as well.
Mutations refuse where reads clamp, so a rollback reaching further back than
the history a user was granted is rejected rather than trimmed: nobody can
undo work that predates their own access, and a rollback with no bound at all
is refused outright. `prune`, which erases, stays granted to nobody. Restoring
is not destructive: it appends a change that undoes another, so what it
replaced stays in the history and can be restored again.

The backend's version endpoints are untouched and now have no caller. They are
marked deprecated with the condition for removing them, since until a document
has been replayed by `migrate_documents` they hold the only record of what it
looked like before it moved.

Also fixes the e2e helper that waited for the removed content endpoint, so it
never returned, and the three version tests that hung behind it.

Signed-off-by: Kevin Jahns <kevin.jahns@protonmail.com>
This commit is contained in:
Kevin Jahns
2026-09-22 16:02:07 +02:00
committed by Manuel Raynaud
parent 4a373f69df
commit 9b4c40fbee
24 changed files with 867 additions and 386 deletions
+46 -20
View File
@@ -82,14 +82,14 @@ It is not a fork of yhub — it is a thin wrapper:
Public exposure: the browser needs the websocket `/collaboration/ws/`,
`/collaboration/ydoc/` for the http fallback, `/collaboration/activity/` and
`/collaboration/changeset/` for the editing history, plus
`/collaboration/jwks/v1`, which carries public keys and nothing else. Every
other route this server serves — `rollback`, `prune`, `reset-connections`,
`migrate`, `create-ydoc`, `restore-ydoc`, `reset-ydoc` — is refused to a browser
by the permission tables themselves (see "Access control" below), so publishing
one is no longer the security boundary it was under yhub 0.7. Keep them off the
public ingress all the same: an endpoint that cannot be reached cannot be
probed. The two probes are not worth publishing either — kubelet calls them from
`/collaboration/changeset/` for the editing history, `/collaboration/rollback/`
for restoring a document to a point in it, plus `/collaboration/jwks/v1`, which
carries public keys and nothing else. Every other route this server serves —
`prune`, `reset-connections`, `migrate`, `create-ydoc`, `restore-ydoc`,
`reset-ydoc` — is refused to a browser by the permission tables themselves (see
"Access control" below), so publishing one is no longer the security boundary it
was under yhub 0.7. Keep them off the public ingress all the same: an endpoint
that cannot be reached cannot be probed. The two probes are not worth publishing either — kubelet calls them from
inside — and the helm chart's ingress lists what it routes rather than what it
hides, so they stay in-cluster on their own.
@@ -109,12 +109,13 @@ Masks are positional `crud` strings where `-` denies, so `'-r--'` is read-only.
|---|---|---|---|---|
| `ydoc` | `-r--` | `-ru-` | as reader/editor | `cru-` |
| `awareness` | `-r--` | `-ru-` | as reader/editor | `-ru-` |
| `history` | `from: <access date>` | `from: <access date>` | — | `from: 0` |
| `history` | `from: <access date>` | `from: <access date>`, `rollback` | — | `from: 0` |
| `delete` | — | — | — | `['soft']` |
| `endpoint.ws` | `-r--` | `-ru-` | as reader/editor | `crud` (`'*'`) |
| `endpoint.ydoc` | `-r--` | `-ru-` | as reader/editor | `crud` (`'*'`) |
| `endpoint.activity` | `-r--` | `-r--` | — | `crud` (`'*'`) |
| `endpoint.changeset` | `-r--` | `-r--` | — | `crud` (`'*'`) |
| `endpoint.rollback` | — | `c---` | — | `crud` (`'*'`) |
| every other endpoint | — | — | — | `crud` (`'*'`) |
All three browser columns are the same document permission,
@@ -123,7 +124,7 @@ All three browser columns are the same document permission,
there is a history to read. `abilities.retrieve` decided whether there is any
access at all before either.
Five of those cells are decisions rather than transcriptions:
Six of those cells are decisions rather than transcriptions:
- **`awareness: '-r--'` for a reader.** A reader receives presence and never
publishes it — [suitenumerique/docs#2544](https://github.com/suitenumerique/docs/pull/2544),
@@ -135,7 +136,7 @@ Five of those cells are decisions rather than transcriptions:
a feature. The frontend has to know it too: the http fallback provider has no
receive-only setting, so a reader's `HttpProvider` is built with no awareness
instance at all, or its first `PATCH` would take a 403 and close it for good.
- **No `'*'` endpoint fallback for the browser.** Only the four routes above are
- **No `'*'` endpoint fallback for the browser.** Only the routes above are
named, so everything else is denied — including any endpoint a future yhub
release adds. Under 0.7 this fence was a `purpose != null` check, which
`create-ydoc` slipped through by declaring no purpose.
@@ -159,8 +160,24 @@ Five of those cells are decisions rather than transcriptions:
version history for exactly that reason ("we wouldn't know from which date to
allow them anyway"). `activity` and `changeset` are withheld together with the
ray rather than granted alone, which would open a route that answers 403 by
itself. `rollback` and `prune` are withheld from everyone: they are
destructive and are granted by name.
itself — and `rollback` with them.
- **`history.rollback` for an editor.** `POST /rollback` undoes every change in
a window, and it is what the version history's restore button calls: **any
user who may edit a document may restore it to an earlier state.** Four things
bound that.
- A reader cannot, twice over. yhub normalizes `rollback` to `false` unless
`ydoc` carries `u` — it is a dead grant without the write it rides on — and
the requirement side mirrors it. Docs additionally withholds the endpoint,
so a reader is refused at the door rather than inside the handler.
- Nobody can undo what happened before they arrived. Mutations *refuse* where
reads clamp: a rollback demands a ray reaching back to its own `from`
instead of having it moved forward silently. Every moment a user can name is
one the timeline showed them, and that timeline starts at their access date
— so the bound holds without trusting the client, and a rollback with no
`from` at all, which asks to undo all of history, is refused outright.
- Nothing is destroyed. A rollback appends an update that undoes another; what
it undid stays in the history and can be restored again from the same panel.
- `prune`, which does erase, stays withheld from everyone.
- **`delete: ['soft']` and not `'hard'` for the admin.** yhub 0.8 made
`DELETE /ydoc?hard=true` reachable over REST for the first time. Docs keeps
irreversible erasure programmatic, behind `reset-ydoc` (see "Deletion").
@@ -601,10 +618,16 @@ Operational notes:
## Full migration (`POST /collaboration/migrate/v1/{org}/{docid}`)
The media bucket is versioned, so `{docid}/file` keeps every snapshot Django
ever wrote — that is the version history the backend exposes at
`/documents/{id}/versions/`. The lazy seed above replays only the newest one, so
a soft-migrated document lands in yhub as a single `system` change stamped with
the migration time and its past is gone.
ever wrote — that is the version history the backend used to expose at
`/documents/{id}/versions/`, and which nothing writes to any more. The lazy seed
above replays only the newest one, so a soft-migrated document lands in yhub as a
single `system` change stamped with the migration time and its past is gone.
This is what makes the backfill user-visible rather than housekeeping. The
frontend's version history is built from `activity`, so until a document has been
migrated in full its history begins at the moment it reached yhub: the snapshots
are still in S3, but nothing reads them. Running the backfill is what gives those
documents their past back.
`migrate` replays the whole history instead. It lists the object's versions and
applies them, oldest first, to a single `Y.Doc({ gc: false })`; after each one
@@ -612,9 +635,12 @@ it credits the ids that version introduced (and the ones it deleted) with
**that version's own S3 timestamp**. `GET
/collaboration/activity/v1/{org}/{docid}?group=false` then reports one entry per
S3 version, at the same timestamps the backend's version listing reports as
`last_modified` — which is what lines the two up. (Pass `group=false`: the
default grouping merges changes by the same author less than a second apart,
which would fold versions saved in quick succession into one entry.)
`last_modified` — which is what lines the two up, and what makes the two lists
comparable when checking a backfill. (Pass `group=false`: the default grouping
merges changes by the same author less than a second apart, which would fold
versions saved in quick succession into one entry. The frontend asks for the
opposite — a minute of grouping — because it wants a readable history rather
than a faithful one; use `group=false` to compare, not what the browser sends.)
`gc: false` is what preserves content that later versions deleted — most of
what makes a history worth keeping.
+36 -6
View File
@@ -52,9 +52,30 @@
* connection, which requires `from === 0` exactly — see the guard in server.js.
*
* No `delete` facet: deleting a document is Django's, through the admin token.
* Deliberately absent too: `rollback` and `prune`, which are destructive and are
* granted by name — restoring a version is not something a reader, or an editor,
* does through this grant today.
*
* `history.rollback` is granted to an editor, and it is the one thing here that
* lets a browser change the past rather than read it: `POST /rollback` undoes
* every change in a window, which is what the version history's "restore" button
* is. Four things bound it.
*
* A reader never gets it, twice over. yhub normalizes `rollback` to `false`
* unless `ydoc` carries `u` — it is a dead grant without the write it rides on —
* and the requirement side mirrors that, so a reader would be refused even if
* this table said otherwise. `canEdit` is belt to those braces, and withholds
* the endpoint with it so a reader is refused once, at the door, instead of
* halfway through the handler.
*
* Nobody can undo what happened before they arrived. Mutations refuse where
* reads clamp: `POST /rollback` demands a ray reaching back to its own `from`,
* rather than quietly moving it forward the way `activity` does. Every moment a
* user can name is one they were shown, and everything they were shown is inside
* their ray — so the bound holds without the client being trusted to respect it,
* and a rollback with no `from` at all, which would ask to undo all of history,
* is refused outright.
*
* `prune` stays absent. Rollback is additive — it appends an update that undoes
* another, and what it undid is still in the history, still restorable by the
* same route. Prune erases, and no browser needs that.
*
* No `'*'` endpoint fallback, so everything not named here is denied — including
* any endpoint a future yhub release adds. Under 0.7 this fence was a
@@ -65,15 +86,24 @@ export const browserDocumentPermissions = (canEdit, historyFrom = null) => ({
type: 'permissions:document:v1',
ydoc: canEdit ? '-ru-' : '-r--',
awareness: canEdit ? '-ru-' : '-r--',
...(historyFrom ? { history: { from: historyFrom } } : null),
...(historyFrom
? { history: { from: historyFrom, ...(canEdit && { rollback: true }) } }
: null),
endpoint: {
// `r` opens the socket, `u` admits document updates over it
ws: canEdit ? '-ru-' : '-r--',
// GET is `r` and PATCH is `u`; DELETE (`d`) stays out — see `delete` above
ydoc: canEdit ? '-ru-' : '-r--',
// the editing timeline, and one point in it — both GET-only, both clamped to
// the ray above
...(historyFrom ? { activity: '-r--', changeset: '-r--' } : null),
// the ray above — and, for an editor, the route that undoes a window of it.
// `c---` because rollback is a POST; there is no other verb on it
...(historyFrom
? {
activity: '-r--',
changeset: '-r--',
...(canEdit && { rollback: 'c---' }),
}
: null),
},
});
+68 -12
View File
@@ -144,24 +144,82 @@ describe('the history a user may read', () => {
}
});
it('never grants rollback or prune', () => {
// destructive, granted by name, and restoring a version is not something
// this grant does
it('never grants prune', () => {
// erasure, granted by name, and nothing a browser does needs it
for (const who of [reader, editor]) {
assert.equal(
grants(who, { history: { from: ACCESS_SINCE, rollback: true } }),
false,
);
assert.equal(
grants(who, { history: { from: ACCESS_SINCE, prune: true } }),
false,
);
assert.equal(grants(who, { endpoint: { rollback: 'c---' } }), false);
assert.equal(grants(who, { endpoint: { prune: 'c---' } }), false);
}
});
});
describe('undoing a stretch of history', () => {
/**
* `POST /rollback` appends an update that undoes every change in a window: it
* is what the version history's restore button does. An editor may, a reader
* may not, and neither may reach back past the moment they arrived.
*/
it('lets an editor undo a window of its own history', () => {
assert.equal(
grants(editor, { history: { from: ACCESS_SINCE, rollback: true } }),
true,
);
assert.equal(grants(editor, { endpoint: { rollback: 'c---' } }), true);
});
it('refuses an editor a window wider than its ray', () => {
// the difference between a read and a mutation: `activity` would clamp this
// silently, `rollback` refuses it. `from: 0` is what a rollback with no
// bound at all asks for, so this is also what stops "undo everything"
assert.equal(
grants(editor, { history: { from: 0, rollback: true } }),
false,
);
assert.equal(
grants(editor, { history: { from: ACCESS_SINCE - 1, rollback: true } }),
false,
);
});
it('never lets a reader undo anything', () => {
assert.equal(
grants(reader, { history: { from: ACCESS_SINCE, rollback: true } }),
false,
);
assert.equal(grants(reader, { endpoint: { rollback: 'c---' } }), false);
});
it('is a dead grant without the write it rides on', () => {
// yhub's own rule, asserted here because it is what makes the reader case
// safe even if this policy ever spelled it wrong: `rollback` normalizes to
// false unless `ydoc` carries `u`
const readerWithRollback = normalizePermissions({
...browserDocumentPermissions(false, ACCESS_SINCE),
history: { from: ACCESS_SINCE, rollback: true },
});
assert.equal(readerWithRollback.history.rollback, false);
});
it('is withheld from a browser that holds no access', () => {
for (const who of [linkReader, linkEditor]) {
assert.equal(
grants(who, { history: { from: ACCESS_SINCE, rollback: true } }),
false,
);
assert.equal(grants(who, { endpoint: { rollback: 'c---' } }), false);
}
});
it('is not granted to the admin token', () => {
// 0.8 stopped implying it from write access, and the backend does not
// restore versions — the browser does
assert.equal(grants(admin, { history: { from: 0, rollback: true } }), false);
});
});
describe('a reader who holds no access, only the link', () => {
/**
* There is no access row and so no date. The backend has always refused these
@@ -199,7 +257,6 @@ describe('everything the browser must not reach', () => {
// a future release is denied until it is named — this is the property that
// replaced 0.7's `purpose != null` check
for (const name of [
'rollback',
'prune',
'create-ydoc',
'migrate',
@@ -235,9 +292,8 @@ describe('the admin token', () => {
assert.equal(grants(admin, { delete: ['hard'] }), false);
});
it('is not granted rollback or prune', () => {
// 0.8 stopped implying them from write access; Docs does not use them
assert.equal(grants(admin, { history: { from: 0, rollback: true } }), false);
it('is not granted prune', () => {
// 0.8 stopped implying it from write access; Docs does not use it
assert.equal(grants(admin, { history: { from: 0, prune: true } }), false);
});
});