Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3bc372c
♻️(collaboration) switch collaboration server from hocuspocus to yhub
dmonad Aug 3, 2026
c6a0695
📄(collaboration) add license notice for yhub-server directory
dmonad Aug 4, 2026
ffc6627
✨(backend) add a service generating cached RS256 JWT tokens
lunika Aug 4, 2026
bcad65a
✨(backend) publish the JWT public key on a JWKS endpoint
lunika Aug 4, 2026
80cf998
✨(backend) add a method to create a dedicated admin token
lunika Aug 4, 2026
d2b4c00
🔧(dev) generate the JWT signing key when bootstrapping the dev stack
lunika Aug 4, 2026
1b49a4f
🔥(ci) remove checking print statement in lint-git
lunika Aug 4, 2026
9030c3f
🔥(backend) remove `CollaborationService` and `can-edit` endpoint
lunika Aug 4, 2026
0414067
✨(collaboration) add admin reset-connections endpoint on yhub 0.4.0
dmonad Aug 5, 2026
a24ca5a
🛂(django) use jwt token for converter services
AntoLC Aug 4, 2026
4632ba8
🛂(y-provider) verify jwt token instead of the shared api key
AntoLC Aug 4, 2026
0fb5d69
🔥(helm) remove occurences of Y_PROVIDER_API_KEY
AntoLC Aug 4, 2026
7ca5ffa
🛂(backend) add audience to jwt
AntoLC Aug 5, 2026
64b46de
✨(collaboration) add create-ydoc endpoint on yhub
dmonad Aug 5, 2026
123e8de
🔒️(collaboration) harden the create-ydoc endpoint
dmonad Aug 5, 2026
10eb434
🔥(frontend) remove "can-edit" mechanism
AntoLC Aug 5, 2026
ebacc97
🔥(project) remove occurences of COLLABORATION_SERVER_SECRET
AntoLC Aug 5, 2026
f575b42
🔥(frontend) remove content GET PATCH
AntoLC Aug 5, 2026
c57d5a1
🙈(dev) ignore playwright-mcp browser artifacts
dmonad Aug 5, 2026
2426375
✨(collaboration) soft-migrate legacy S3 documents into yhub
dmonad Aug 5, 2026
c0151a3
📝(changelog) note that get-connections is dropped, not deferred
dmonad Aug 6, 2026
a21b8fa
🔒️(collaboration) reject admin jwts not issued for the yhub audience
dmonad Aug 6, 2026
a9c4f6c
✨(collaboration) replay legacy s3 version history into yhub
dmonad Aug 7, 2026
38013cb
✅(collaboration) test the legacy migrations against a real yhub
dmonad Aug 10, 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
70 changes: 66 additions & 4 deletions .github/workflows/impress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ jobs:
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- src/backend ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
Expand Down Expand Up @@ -132,6 +128,13 @@ jobs:
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

# message stream for the collaboration server (see the yhub steps below)
valkey:
image: valkey/valkey:alpine
ports:
- 6379:6379
options: --health-cmd "valkey-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5

env:
DJANGO_CONFIGURATION: Test
DJANGO_SETTINGS_MODULE: impress.settings
Expand All @@ -146,6 +149,12 @@ jobs:
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: impress
AWS_S3_SECRET_ACCESS_KEY: password
# Collaboration server. The integration tests reach it over this url and
# skip themselves when nothing answers; yhub reads the JWKS back from the
# django server started alongside it, so both sides must share
# JWT_PRIVATE_KEY_FILE.
COLLABORATION_API_URL: http://localhost:3002/collaboration
JWT_PRIVATE_KEY_FILE: ${{ github.workspace }}/data/jwt/private.pem

steps:
- name: Checkout repository
Expand Down Expand Up @@ -210,5 +219,58 @@ jobs:
- name: Generate a MO file from strings extracted from the project
run: uv run python manage.py compilemessages

# --- collaboration server -------------------------------------------
# The yhub integration tests drive a real collaboration server: it reads
# legacy documents out of MinIO and reads this backend's JWKS back to
# verify the admin token the tests mint, so the two must share the
# signing key. Tests skip themselves when nothing answers on
# COLLABORATION_API_URL.
- name: Generate the JWT signing key
working-directory: .
run: bin/generate-jwt-private-key.sh

- name: Create the collaboration server database
run: |
PGPASSWORD=pass psql -h localhost -U dinum -d impress \
-c 'CREATE DATABASE yhub'
PGPASSWORD=pass psql -h localhost -U dinum -d yhub \
-f ../../docker/files/yhub/initdb/01-yhub.sql

- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "22.x"

- name: Install the collaboration server
working-directory: src/yhub-server
run: npm ci --omit=dev

- name: Start the backend for the collaboration server to authenticate against
env:
DJANGO_ALLOWED_HOSTS: "*"
run: |
nohup uv run python manage.py runserver 0.0.0.0:8000 --noreload \
> /tmp/backend.log 2>&1 &
dockerize -wait http://localhost:8000/api/v1.0/jwks -timeout 60s

- name: Start the collaboration server
working-directory: src/yhub-server
env:
PORT: 3002
REDIS: redis://localhost:6379
POSTGRES: postgres://dinum:pass@localhost:5432/yhub
REDIS_PREFIX: yhub
COLLABORATION_BACKEND_BASE_URL: http://localhost:8000
COLLABORATION_SERVER_ORIGIN: http://localhost:3000
AWS_STORAGE_BUCKET_NAME: impress-media-storage
SOFT_MIGRATION: "true"
run: |
nohup node server.js > /tmp/yhub.log 2>&1 &
dockerize -wait tcp://localhost:3002 -timeout 30s

- name: Run tests
run: uv run pytest -n 2

- name: Collaboration server logs
if: failure()
run: cat /tmp/yhub.log /tmp/backend.log
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ env.d/terraform
compose.override.yml
docker/auth/*.local

# yhub server local install
src/yhub-server/node_modules/

# npm
node_modules

Expand Down Expand Up @@ -90,3 +93,4 @@ AGENTS.md
.aider*
.copilot/
.github/copilot-instructions.md
.playwright-mcp
78 changes: 78 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,70 @@ and this project adheres to

### Added

- ✅(collaboration) add integration tests covering both legacy migrations,
running against a real yhub: CI now starts the collaboration server and a
valkey alongside the backend test job. They skip themselves when nothing
answers on `COLLABORATION_API_URL`, so a `make test` without the dev stack
still passes
- ✨(collaboration) soft-migrate legacy S3 documents into yhub on first access
(`SOFT_MIGRATION=true`): when yhub does not know a document yet, its legacy
snapshot (`{id}/file`, base64 Yjs update) is fetched from the Django S3
media bucket and seeded server-side (attributed to `system`, with no
timestamp — a lazy seed is not an editing event, and stamping one would
collide with the real per-version times the migrate endpoint writes) before
the connection is admitted. A missing S3 object means a brand-new document and
yields an empty room. Seeding never decides access: a legacy object that
cannot be migrated (undecodable or oversized) opens as a new document, logged
per access, since no retry could fix it and refusing would make the document
permanently unopenable. Every other failure — an unreachable store, but also
any refusal from S3 such as `AccessDenied` on a rotated key or a wrong bucket
name — answers a retryable `503`, so an empty document is never started on
top of content that exists.
Backend reads carrying the admin JWT are seeded too, so a server-side read of
an unmigrated document never answers with an empty one. Enabled in the dev
stack via compose.yml
- ✨(collaboration) add a migrate endpoint on yhub:
`POST /collaboration/migrate/v1/docs/{id}` replays a document's **full**
legacy version history from the versioned S3 media bucket into a
`gc: false` Yjs document, crediting each S3 version with its own S3
timestamp — so `activity?group=false` reports the same timeline as the
backend's `/documents/{id}/versions/`, instead of the single
migration-time change the
lazy soft migration leaves behind. Purely additive: the result is stored as
one new row at clock `0`, so nothing existing is deleted and the next
compaction merges it like any other row. Idempotent by construction (the
clock-`0` insert is `ON CONFLICT DO NOTHING`, and migrated ids are recorded
in a valkey set), lock-free, admin JWT only, and the intended way to backfill
the corpus before `SOFT_MIGRATION` is turned off
- ✨(collaboration) add a create-ydoc endpoint on yhub:
`POST /collaboration/create-ydoc/v1/docs/{id}` seeds a document's initial
Yjs state from a raw binary update posted as `application/octet-stream`,
so the Django backend can create documents without speaking yhub's lib0
wire encoding. Strict create (409 when the document already has content),
initial content attributed to the optional `X-User-Id` header; guarded by
standard document write access (the `aud: "yhub"` admin JWT, or a user
session with update ability)
- ✨(collaboration) add an admin reset-connections endpoint on yhub:
`POST /collaboration/reset-connections/v1/docs/{id}` re-checks the
authorization of the document's connected clients and disconnects (close
code 4401) only those whose access changed. Authenticated with an admin JWT
verified against the backend JWKS and required to carry `aud: "yhub"`, so
an admin token Django issued for another service (e.g. the `y-converter`
one) cannot be replayed here; not yet triggered by the backend on
permission changes (follow-up)
- ⬆️(collaboration) upgrade yhub to 0.5.0 and serve all its routes under the
`/collaboration/` prefix (`server.apiPrefix`): the websocket moves to
`/collaboration/ws/v1/docs`. All `/collaboration/` routes are meant to be
publicly exposed except `reset-connections` and `migrate`, which stay
backend-internal (admin JWT only). Following 0.5.0's error semantics
(`4xx` permanent, `5xx`/`429` retryable), the auth plugin now reports a
temporarily unreachable Django backend, JWKS endpoint or legacy S3 store as
`503` instead of denying access like a permission failure, so clients retry
instead of giving up. The built-in endpoints can also answer JSON on
`Accept: application/json`
- ✨(backend) add a service generating cached RS256 JWT tokens
- ✨(backend) publish the JWT public key on a JWKS endpoint
- 🔧(dev) generate the JWT signing key when bootstrapping the dev stack
- ♿️(frontend) restore skip to content link after header redesign #2510
- 🌐(i18n) rename cn_CN to zh_CN, add eo_PL and zh_TW locales #2486

Expand All @@ -17,7 +81,21 @@ and this project adheres to

### Changed

- 💥(backend) move the resource server JWKS from `/api/{version}/jwks` to
`/external_api/{version}/jwks`
- ♿️(frontend) use semantic `<dl>` structure in document info card #2379
- ♻️(collaboration) migrate the collaboration server from hocuspocus to yhub:
the dev stack gains dedicated valkey and postgres services for yhub. The
kick flow is deferred with TODO(yhub) stubs (the reset-connections
endpoint added above is its server-side replacement, backend wiring
pending). The get-connections API is dropped for good: its only consumer
was the removed can-edit mechanism, so it is not needed anymore
- 🔥(backend) remove the unused `CollaborationService`
- 💥(backend) remove the `documents/{id}/can-edit/` endpoint
- 💥(y-provider) the published `lasuite/impress-y-provider` image becomes
converter-only and no longer serves `/collaboration/ws/`; deployments using
the existing helm values lose collaboration until the helm chart routes
collaboration to yhub (follow-up)

## [v5.4.1] - 2026-07-09

Expand Down
24 changes: 18 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ data/media:
data/static:
@mkdir -p data/static

# RSA key signing the JWT tokens the backend issues. Generated locally, never
# committed: "data/" is gitignored. Regenerate it by deleting the file.
data/jwt/private.pem:
@bin/generate-jwt-private-key.sh

# -- Project

create-env-local-files: ## create env.local files in env.d/development
Expand All @@ -81,7 +86,8 @@ create-env-local-files:
.PHONY: create-env-local-files

generate-secret-keys:
generate-secret-keys: ## generate secret keys to be stored in common.local
generate-secret-keys: ## generate the secret keys needed by the dev stack
generate-secret-keys: data/jwt/private.pem
@bin/generate-oidc-store-refresh-token-key.sh
.PHONY: generate-secret-keys

Expand Down Expand Up @@ -190,6 +196,7 @@ bootstrap-e2e: \
build: cache ?=
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yhub cache=$(cache)
@$(MAKE) build-yjs-provider cache=$(cache)
@$(MAKE) build-frontend cache=$(cache)
.PHONY: build
Expand All @@ -199,9 +206,14 @@ build-backend: ## build the app-dev container
@$(COMPOSE) build app-dev $(cache)
.PHONY: build-backend

build-yhub: cache ?=
build-yhub: ## build the yhub collaboration server container
@$(COMPOSE) build yhub $(cache)
.PHONY: build-yhub

build-yjs-provider: cache ?=
build-yjs-provider: ## build the y-provider container
@$(COMPOSE) build y-provider-development $(cache)
@$(COMPOSE) build y-provider-development-converter $(cache)
.PHONY: build-yjs-provider

build-frontend: cache ?=
Expand All @@ -212,8 +224,9 @@ build-frontend: ## build the frontend container
build-e2e: cache ?=
build-e2e: ## build the e2e container
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yhub cache=$(cache)
@$(COMPOSE_E2E) build frontend $(cache)
@$(COMPOSE_E2E) build y-provider $(cache)
@$(COMPOSE_E2E) build y-provider-converter $(cache)
.PHONY: build-e2e

nginx-frontend: ## build the nginx-frontend container
Expand All @@ -230,10 +243,11 @@ logs: ## display app-dev logs (follow mode)

run-backend: ## Start only the backend application and all needed services
@$(MAKE) create-docker-network
@$(MAKE) data/jwt/private.pem
@$(COMPOSE) up --force-recreate -d docspec
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider-development
@$(COMPOSE) up --force-recreate -d y-provider-development-converter
@$(COMPOSE) up --force-recreate -d yhub
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run-backend

Expand All @@ -246,9 +260,7 @@ run:
run-e2e: ## start the e2e server
run-e2e:
@$(MAKE) run-backend
@$(COMPOSE_E2E) stop y-provider-development
@$(COMPOSE_E2E) up --force-recreate -d frontend
@$(COMPOSE_E2E) up --force-recreate -d y-provider
@$(COMPOSE_E2E) up --force-recreate -d y-provider-converter
.PHONY: run-e2e

Expand Down
7 changes: 7 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ the following command inside your docker container:

## [Unreleased]

- The JWKS of the resource server moved from `/api/{version}/jwks` to
`/external_api/{version}/jwks`, alongside the rest of the resource server
endpoints. `/api/{version}/jwks` now publishes the public key validating the
tokens Docs issues to call external services. If you enabled the resource
server (`OIDC_RESOURCE_SERVER_ENABLED`), update the JWKS URI declared to your
OIDC provider accordingly.

### [5.0.0] - 2026-04-30

We made several changes around document content management leading to several breaking changes in the API.
Expand Down
4 changes: 4 additions & 0 deletions bin/_config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ function _set_user() {
# options: docker compose command options
# ARGS : docker compose command arguments
function _docker_compose() {
# The backend settings point at this key and the containers mount it, so it
# has to exist before any of them starts.
"${REPO_DIR}/bin/generate-jwt-private-key.sh"

# Set DOCKER_USER for Windows compatibility with MinIO
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || -n "${WSL_DISTRO_NAME:-}" ]]; then
export DOCKER_USER="0:0"
Expand Down
23 changes: 23 additions & 0 deletions bin/generate-jwt-private-key.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash

# Generate the RSA private key signing the JWT tokens issued by the backend.
#
# Development only. The key is generated locally and never committed: it lands
# in "data/", which is gitignored. The dev stack mounts it in the backend
# containers, where JWT_PRIVATE_KEY_FILE points at it.
#
# Idempotent: an existing key is kept. Delete the file to roll the key.

set -eo pipefail

REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
KEY_PATH="${REPO_DIR}/data/jwt/private.pem"

if [ -f "${KEY_PATH}" ]; then
exit 0
fi

mkdir -p "$(dirname "${KEY_PATH}")"
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${KEY_PATH}" 2>/dev/null
chmod 600 "${KEY_PATH}"
Comment on lines +11 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Sensitive Data Exposure (CWE-732): Incorrect Permission Assignment for Critical Resource

Reachability: Internal

Create the private key with restrictive permissions.

If a shared development host uses a permissive umask and another user can traverse data/jwt, line 21 creates the private key before line 22 restricts it. A local attacker can read the key during that interval and mint JWTs accepted by yhub. Set umask 077 before creating the directory and key.

Proposed fix
 set -eo pipefail
+umask 077
 
 REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
#!/usr/bin/env bash
set -euo pipefail

tmp_dir="$(mktemp -d)"
trap 'rm -rf "${tmp_dir}"' EXIT

(
  umask 0022
  openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
    -out "${tmp_dir}/private.pem" 2>/dev/null
  stat -c 'created mode: %a' "${tmp_dir}/private.pem"
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/generate-jwt-private-key.sh` around lines 11 - 22, Set a restrictive
umask before the mkdir and openssl commands in the key-generation flow, ensuring
the directory and private key are created inaccessible to other users; retain
the existing chmod 600 safeguard and idempotent KEY_PATH check.

echo "✓ JWT private key generated in ${KEY_PATH}"
15 changes: 1 addition & 14 deletions compose-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ services:
ports:
- "3000:3000"

y-provider:
y-provider-converter:
user: ${DOCKER_USER:-1000}
build:
context: .
Expand All @@ -24,16 +24,3 @@ services:
env_file:
- env.d/development/common
- env.d/development/common.local
ports:
- "4444:4444"

y-provider-converter:
user: ${DOCKER_USER:-1000}
image: impress:y-provider-production
restart: unless-stopped
env_file:
- env.d/development/common
- env.d/development/common.local
depends_on:
y-provider:
condition: service_started
Loading
Loading