Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
db14841
feat(realtime-collab): land CollaborativeWrapper + codemirror / tipta…
dschmidt May 19, 2026
a30474b
perf(realtime-collab): adapter serialize() gets optional editor context
dschmidt May 19, 2026
3e30da4
feat(realtime-collab): hocuspocus sidecar in web's docker-compose
dschmidt May 19, 2026
f958e63
fix(realtime-collab): dedupe yjs / y-protocols / y-prosemirror / @tiptap
dschmidt May 19, 2026
b086d48
feat(realtime-collab): refactor web-app-text-editor onto Collaborativ…
dschmidt May 19, 2026
8ba1521
docs(realtime-collab): mark Phase 4 done in migration plan
dschmidt May 19, 2026
36fce37
feat(realtime-collab): per-app room namespace + cursor markers in tex…
dschmidt May 19, 2026
3f55a40
fix(realtime-collab): skip auto-focus in collab mode
dschmidt May 19, 2026
e56ff98
refactor(editor): move auto-focus out of useTextEditor into the caller
dschmidt May 19, 2026
a240f2e
feat(realtime-collab): AppWrapper etag-sync inject for peer-saved etags
dschmidt May 19, 2026
6331a3c
docs(realtime-collab): mark Phase 4.5 done
dschmidt May 19, 2026
3d68f24
fix(realtime-collab): don't forward stale etag from initial sync to A…
dschmidt May 19, 2026
6d4badd
Revert "feat(realtime-collab): AppWrapper etag-sync inject for peer-s…
dschmidt May 19, 2026
6c564a4
feat(realtime-collab): refetch + retry on 412/409 save conflicts
dschmidt May 19, 2026
38197b6
chore: drop vite resolve.dedupe for collab deps
dschmidt May 19, 2026
ecf636d
feat(realtime-collab): propagate peer-save state via _oc_meta
dschmidt May 19, 2026
ef98b10
test(realtime-collab): port CollaborativeWrapper unit spec to web-pkg
dschmidt May 19, 2026
f66ab95
test(realtime-collab): cucumber-port collaboration e2e suite
dschmidt May 20, 2026
d0c8610
build(realtime-collab): route /realtime through OC's reverse proxy
dschmidt May 20, 2026
babba45
chore(realtime-collab): satisfy prettier + vue-tsc
dschmidt May 20, 2026
8200ad7
feat(realtime-collab): web-app-excalidraw on the collab framework
dschmidt May 20, 2026
c20c7d8
feat(collab): client-side _oc_meta.isStale detection for relay backends
dschmidt May 20, 2026
cc7af63
build(realtime-collab): bump hocuspocus to 4.1.0, drop the patch + sq…
dschmidt May 21, 2026
b35e3b7
feat(realtime-collab): default editors local, opt into collab via app…
dschmidt Jun 29, 2026
65ac063
refactor: remove apps we do no want in monorepo
JammingBen Jul 1, 2026
5745d23
fix: add loading state for yjs hydration
JammingBen Jul 1, 2026
bb3a9a5
test: (temporarily) remove e2e tests for yjs
JammingBen Jul 1, 2026
5f1c3ae
refactor: use TS types for props in collaborative wrapper
JammingBen Jul 1, 2026
e1fbb9f
refactor: use TS types for props in TextEditorBinding
JammingBen Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions dev/docker/hocuspocus/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
FROM node:22-alpine

WORKDIR /app

COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund --loglevel=error

COPY server.js ./

CMD ["node", "server.js"]
12 changes: 12 additions & 0 deletions dev/docker/hocuspocus/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "opencloud-hocuspocus-dev",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"@hocuspocus/server": "^4.1.0"
}
}
268 changes: 268 additions & 0 deletions dev/docker/hocuspocus/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { Server } from '@hocuspocus/server'

const port = parseInt(process.env.PORT ?? '1234', 10)
const opencloudUrl = (process.env.OPENCLOUD_URL ?? 'https://host.docker.internal:9200').replace(
/\/$/,
''
)
const devFakeToken = process.env.DEV_FAKE_TOKEN ?? ''

// Per-document first-seen app version. Acts as the authoritative gate for
// "everybody in this room must run the same client version". First connect
// for a documentName sets the baseline; subsequent connects with a different
// appVersion are rejected at authenticate-time. In-memory only; on restart
// the next connecter becomes the new baseline (acceptable for a stateless
// sidecar). Empty appVersion is tolerated for legacy/test clients.
const appVersionByDocument = new Map()

function deterministicColor(seed) {
let hash = 0
for (let i = 0; i < seed.length; i++) hash = seed.charCodeAt(i) + ((hash << 5) - hash)
return `hsl(${Math.abs(hash) % 360}, 70%, 50%)`
}

async function validateTokenAgainstOpenCloud(token) {
const res = await fetch(`${opencloudUrl}/graph/v1.0/me`, {
headers: { Authorization: `Bearer ${token}` }
})
if (!res.ok) {
const detail = await res.text().catch(() => '')
throw new Error(`graph /me returned ${res.status}: ${detail.slice(0, 200)}`)
}
return res.json()
}

// Heuristic: a libregraph permission action implies write access when its
// trailing verb is create/update/delete/allTasks on driveItem properties.
const WRITE_ACTION = /\/(update|create|delete|allTasks)$/

// Splits OC's canonical composite id `<storageid>$<spaceid>!<opaqueid>` into
// the (driveId, itemId) pair the Graph endpoint expects: driveID =
// `<storageid>$<spaceid>`, itemID = the FULL composite.
//
// The wrapper now namespaces room names by app id to avoid schema
// collisions between different editors opening the same file
// (e.g. `text-editor::<composite>` vs `codemirror::<composite>`). Strip
// any `<scope>::` prefix before parsing so the Graph probe targets the
// raw file id.
function parseDocumentId(documentName) {
const scopeSep = documentName.indexOf('::')
const fileId = scopeSep >= 0 ? documentName.slice(scopeSep + 2) : documentName
const sep = fileId.indexOf('!')
if (sep <= 0 || sep === fileId.length - 1) {
throw new Error(`malformed documentName="${documentName}"`)
}
return { driveId: fileId.slice(0, sep), itemId: fileId }
}

// Probes OC's Graph API for the user's effective access AND the file's
// current native etag. Returns `{ canWrite, etag }` on success; `null` when
// OC denies access entirely (401/403/404).
//
// Two parallel calls:
// - Graph /permissions for the effective action set (top-level
// @libre.graph.permissions.actions.allowedValues, which is the merged
// PermissionSet that backs WebDAV's oc:permissions).
// - WebDAV HEAD for the native eTag (Graph's /items endpoint is share-jail-
// only and 400s on personal drives; WebDAV works uniformly).
async function probeFileAccess(token, documentName) {
const { driveId, itemId } = parseDocumentId(documentName)
const permsUrl =
`${opencloudUrl}/graph/v1beta1/drives/${encodeURIComponent(driveId)}` +
`/items/${encodeURIComponent(itemId)}/permissions`
const davUrl = `${opencloudUrl}/remote.php/dav/spaces/${encodeURIComponent(itemId)}`
const headers = { Authorization: `Bearer ${token}` }

const [permsRes, headRes] = await Promise.all([
fetch(permsUrl, { headers }),
fetch(davUrl, { method: 'HEAD', headers })
])

if ([permsRes.status, headRes.status].some((s) => s === 401 || s === 403 || s === 404)) {
return null
}
if (!permsRes.ok) {
const detail = await permsRes.text().catch(() => '')
throw new Error(`graph permissions returned ${permsRes.status}: ${detail.slice(0, 200)}`)
}
if (!headRes.ok) {
const detail = await headRes.text().catch(() => '')
throw new Error(`webdav HEAD returned ${headRes.status}: ${detail.slice(0, 200)}`)
}

const permsBody = await permsRes.json()
const allowed = Array.isArray(permsBody?.['@libre.graph.permissions.actions.allowedValues'])
? permsBody['@libre.graph.permissions.actions.allowedValues']
: []
const canWrite = allowed.some((a) => WRITE_ACTION.test(a))

// WebDAV emits the strong validator under `ETag` (and sometimes `OC-ETag`
// for OC-specific extensions). Strip surrounding quotes for consistency
// with the etag the wrapper sees from `props.resource.etag`.
const rawEtag = headRes.headers.get('etag') || headRes.headers.get('oc-etag') || ''
const etag = rawEtag.replace(/^"(.*)"$/, '$1')

return { canWrite, etag }
}

const META_KEY = '_oc_meta'

const server = new Server({
port,
address: '0.0.0.0',
// No server-side persistence: every doc is file-backed via WebDAV.
// Cold-start for a fresh peer = hydrate from `currentContent` in the
// wrapper. The persisted SQLite snapshot would get discarded on stale-
// state recovery anyway (etag drift triggers rehydrate); keeping it
// here is "mostly ceremony" per the migration plan. Stale detection
// moved to the client (see CollaborativeWrapper.onProviderSynced).

async onAuthenticate({ token, documentName, requestParameters }) {
if (!token) {
throw new Error('missing token')
}

// App-version gate. First connect to a documentName sets the baseline,
// subsequent connects with a different appVersion are rejected so old
// clients can't poison the room. Empty client appVersion is permitted
// (back-compat for the integration test harness using a raw provider).
const clientAppVersion = requestParameters.get('appVersion') ?? ''
const baselineAppVersion = appVersionByDocument.get(documentName)
if (clientAppVersion && baselineAppVersion && clientAppVersion !== baselineAppVersion) {
throw new Error(
`app version mismatch for document="${documentName}": ` +
`client=${clientAppVersion} room=${baselineAppVersion}, please reload`
)
}
if (clientAppVersion && !baselineAppVersion) {
appVersionByDocument.set(documentName, clientAppVersion)
}

// Dev shortcut for integration tests: any token matching DEV_FAKE_TOKEN
// returns a synthetic identity. ACL check is skipped (tests use random
// documentNames that don't exist in OC). Disabled when DEV_FAKE_TOKEN is
// unset. Tests can pass `devEtag` to drive the stale-state detection
// path without touching real OC.
if (devFakeToken && token === devFakeToken) {
const id = 'dev-fake-user'
const nativeEtag = requestParameters.get('devEtag') ?? ''
console.log(`[onAuthenticate] dev-fake document="${documentName}" nativeEtag="${nativeEtag}"`)
return {
nativeEtag,
user: {
id,
displayName: 'Dev Fake User',
color: deterministicColor(id)
}
}
}

const me = await validateTokenAgainstOpenCloud(token)
const id = me.id ?? me.userPrincipalName ?? 'unknown'

// ACL + native etag probe via Graph: enforces access AND captures the
// current native etag so onLoadDocument can detect a stale persisted
// Y.Doc snapshot (Hocuspocus persistence vs external file write).
const access = await probeFileAccess(token, documentName)
if (access === null) {
throw new Error(`access denied for document="${documentName}"`)
}
const readOnly = !access.canWrite

console.log(
`[onAuthenticate] document="${documentName}" user="${me.displayName ?? id}" ` +
`id="${id}" readOnly=${readOnly} nativeEtag="${access.etag}"`
)
return {
readOnly,
nativeEtag: access.etag,
clientAppVersion,
user: {
id,
displayName: me.displayName ?? me.userPrincipalName ?? id,
color: deterministicColor(id)
}
}
},

// Stale-state detection — DISABLED.
//
// This hook fires once when a doc is loaded into memory. It used to do
// useful work when we shipped the SQLite extension: at cold load it
// compared the persisted `_oc_meta.etag` against the live native etag
// and flagged drift so the wrapper would rehydrate. Without persistence
// the doc is always freshly created at load time, `_oc_meta` is empty,
// and the wrapper's etag mirror runs strictly AFTER this hook — so the
// comparison can never fire. The equivalent check now lives in
// CollaborativeWrapper.onProviderSynced (runs on the client, sees the
// CRDT-synced `_oc_meta.etag` from whichever peer joined first).
//
// Kept commented out as a reference: if persistence is reintroduced
// (extension-sqlite, redis, etc.), uncomment to get the server-side
// cold-load probe back.
//
// async onLoadDocument({ document, context }) {
// const meta = document.getMap(META_KEY)
// const persistedEtag = meta.get('etag')
// const nativeEtag = context?.nativeEtag
// const persistedAppVersion = meta.get('appVersion')
// const clientAppVersion = context?.clientAppVersion
//
// const etagDrift = !!persistedEtag && !!nativeEtag && persistedEtag !== nativeEtag
// const versionDrift =
// !!persistedAppVersion && !!clientAppVersion && persistedAppVersion !== clientAppVersion
//
// if (!etagDrift && !versionDrift) return
//
// const reasons = []
// if (etagDrift) reasons.push(`etag(${persistedEtag}→${nativeEtag})`)
// if (versionDrift) reasons.push(`appVersion(${persistedAppVersion}→${clientAppVersion})`)
// console.log(
// `[onLoadDocument] stale state document="${document.name}" ` +
// `${reasons.join(' ')} → marked for rehydrate`
// )
// document.transact(() => {
// meta.set('isStale', true)
// if (nativeEtag) meta.set('nativeEtag', nativeEtag)
// })
// },

async onConnect({ documentName, requestHeaders }) {
console.log(`[onConnect] document="${documentName}" origin=${requestHeaders.origin ?? '-'}`)
},

async onDisconnect({ documentName, clientsCount }) {
console.log(`[onDisconnect] document="${documentName}" remaining=${clientsCount}`)
if (clientsCount === 0) {
// Forget the version baseline once the room empties out so a new
// deploy can start fresh without manual restart.
appVersionByDocument.delete(documentName)
}
},

// Anti-spoof identity stamp: before each inbound awareness update is
// applied, overwrite the `user` field on every state in the update with
// the authenticated identity from the connection's context.
//
// Hocuspocus v4 invokes extension hooks with a single payload object — the
// positional `(document, states, origin)` signature applies only to the
// document-level callback the lib wires up internally (see
// hocuspocus-server.cjs ~line 1299). Using positional args here would
// silently no-op (states=undefined -> no user found -> return).
async beforeHandleAwareness({ states, context, connection }) {
const user = context?.user ?? connection?.context?.user
if (!user) return
const canonical = {
id: user.id,
name: user.displayName,
color: user.color
}
for (const state of states.values()) {
state.user = canonical
}
}
})

server.listen().then(() => {
console.log(`hocuspocus v4 listening on :${port}, oc=${opencloudUrl}`)
})
8 changes: 7 additions & 1 deletion dev/docker/opencloud.web.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
},
"apps": [
"files",
"text-editor",
"pdf-viewer",
"search",
"external",
Expand All @@ -21,5 +20,12 @@
"contacts",
"rclone-crypt",
"office-settings"
],
"external_apps": [
{
"id": "text-editor",
"path": "web-app-text-editor",
"config": { "realtimeUrl": "wss://host.docker.internal:9200/realtime" }
}
]
}
12 changes: 12 additions & 0 deletions dev/docker/opencloud/proxy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@
additional_policies:
- name: default
routes:
# Realtime collab sidecar. The hocuspocus container terminates plain
# HTTP on :1234; OC's reverse proxy upgrades the incoming WebSocket
# request and forwards it on. Pattern is borrowed from the
# opencloud-music sidecar setup. `unprotected: true` because
# hocuspocus does its own bearer-token validation against OC's
# Graph API (see dev/docker/hocuspocus/server.js
# `validateTokenAgainstOpenCloud`) and must see the Authorization
# header the client sent verbatim, not whatever OC's proxy would
# substitute.
- endpoint: /realtime
backend: http://hocuspocus:1234
unprotected: true
- endpoint: /caldav/
backend: http://host.docker.internal:5232
remote_user_header: X-Remote-User
Expand Down
25 changes: 25 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -403,11 +403,36 @@ services:
depends_on:
- stalwart

hocuspocus:
build: ./dev/docker/hocuspocus
extra_hosts:
- host.docker.internal:${DOCKER_HOST:-host-gateway}
environment:
PORT: '1234'
OPENCLOUD_URL: https://host.docker.internal:9200
# Dev only: self-signed cert from OC's Traefik
NODE_TLS_REJECT_UNAUTHORIZED: '0'
# Dev only: allows the integration tests to bypass real OIDC tokens.
# Remove for prod.
DEV_FAKE_TOKEN: dev-integration-token
volumes:
# Dev override: edit server.js without rebuild; remove for prod
- ./dev/docker/hocuspocus/server.js:/app/server.js:ro
networks:
# On the traefik network so OC (which is also on it) can reach
# hocuspocus by service name. Browser-facing routing is no longer
# done at the Traefik layer — OC's reverse proxy forwards /realtime
# via `additional_policies` in opencloud/proxy.yaml. This keeps the
# routing model identical to what CI uses (CI has no Traefik).
- traefik
restart: unless-stopped

volumes:
uploads:
opencloud-config:
opencloud-federated-config:
stalwart-data:
hocuspocus-data:

networks:
traefik:
8 changes: 8 additions & 0 deletions packages/web-app-text-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
"private": true,
"description": "OpenCloud Web simple text editor",
"license": "AGPL-3.0",
"dependencies": {
"@hocuspocus/provider": "^4.0.0",
"@tiptap/core": "^3.20.4",
"@tiptap/extension-collaboration": "^3.20.4",
"@tiptap/vue-3": "^3.20.4",
"y-protocols": "^1.0.7",
"yjs": "^13.6.0"
},
"devDependencies": {
"@opencloud-eu/web-test-helpers": "workspace:*"
},
Expand Down
Loading