diff --git a/documentation/architecture.md b/documentation/architecture.md index f858eb01d..250c47d06 100644 --- a/documentation/architecture.md +++ b/documentation/architecture.md @@ -1,20 +1,513 @@ -## Architecture +# Architecture -### Global system architecture +## Overview +Docs is a set of small services around one rule: **Django owns who may do what, +and the collaboration server owns what a document says**. Every other service is +either a client of those two or a converter they call. + +- The **backend** (Django) holds documents, users, accesses, invitations and + attachments metadata. It is the only service that decides whether a caller may + read or write a document, and every other service asks it rather than deciding + on its own. +- **yhub** holds the live Yjs state of the documents. Browsers connect to it over + a websocket and edit together; it persists the merged state in a store of its + own. The backend reads and writes that state through yhub's REST API — never + directly in its database. +- The **frontend** is a Next.js application exported to static files. In + production it is served by Nginx, with no Node.js runtime. +- **y-provider** and **Docspec** are stateless converters. They store nothing and + own nothing. + +Nothing long-lived is shared between the services: they authenticate to each +other with short-lived RS256 JWTs, each verified against the JWKS the other +publishes. + +## The services at a glance + +| Service | Stack | Owns | Talks to | +| --- | --- | --- | --- | +| Frontend | Next.js, BlockNote, static export served by Nginx | nothing | the browser only | +| Backend | Django, DRF | documents, users, accesses, attachments | PostgreSQL, Redis, S3, yhub, y-provider, Docspec, OIDC, LLM, search | +| Celery worker | Celery | nothing | PostgreSQL, Redis, yhub, SMTP, search | +| yhub server | `@y/hub`, Node | the live document content | Valkey, its PostgreSQL, the backend, S3 | +| yhub worker | `@y/hub`, Node | — | Valkey, its PostgreSQL, optional S3 | +| y-provider | Express, BlockNote server-util | nothing | the backend (JWKS only) | +| Docspec | external image | nothing | nothing | + +## Global service map + + ```mermaid -flowchart TD - User -- HTTP --> Front("Frontend (NextJS SPA)") - Front -- REST API --> Back("Backend (Django)") - Front -- WebSocket --> Yserver("Microservice Yjs (Express)") -- WebSocket --> CollaborationServer("Collaboration server (Hocuspocus)") -- REST API <--> Back - Front -- OIDC --> Back -- OIDC ---> OIDC("Keycloak / ProConnect") - Back -- REST API --> Yserver - Back --> DB("Database (PostgreSQL)") - Back <--> Celery --> DB - Back ----> S3("Minio (S3)") - Back -- REST API --> Find +flowchart LR + + subgraph clients["Clients"] + direction TB + BROWSER["Browser
Next.js SPA · BlockNote
+ service worker (offline)"] + EXTAPP["Third-party app
resource-server client"] + end + + subgraph edge["Edge"] + INGRESS["Ingress / Nginx
TLS · routing · media auth"] + end + + subgraph app["Application services"] + direction TB + FRONT["Frontend
Next.js static build"] + BACK["Backend
Django · DRF"] + CELERY["Celery worker"] + subgraph collab["Collaboration"] + direction TB + YHUB["yhub server
WebSocket + REST"] + YWORKER["yhub worker"] + end + subgraph conv["Conversion"] + direction TB + YPROV["y-provider
Yjs to md / HTML / JSON"] + DOCSPEC["Docspec
.docx import"] + end + end + + subgraph stores["Data stores"] + direction TB + PG[("PostgreSQL
docs · users · rights")] + REDIS[("Redis
broker · cache")] + S3[("S3 / MinIO
attachments · versions")] + VALKEY[("Valkey
update stream")] + YPG[("PostgreSQL yhub
Yjs state")] + YS3[("S3 yhub
optional")] + end + + subgraph ext["External services"] + direction TB + OIDC["OIDC provider
Keycloak · ProConnect"] + LLM["LLM provider
OpenAI · Mistral"] + SEARCH["Search / indexing"] + SMTP["SMTP relay"] + AV["Malware detection"] + OBS["Langfuse · Sentry · PostHog"] + end + + %% clients to edge + BROWSER ==>|"HTTPS · WSS"| INGRESS + EXTAPP -->|"bearer token"| INGRESS + BROWSER -.->|"login"| OIDC + + %% edge to services + INGRESS -->|"/"| FRONT + INGRESS ==>|"/api/v1.0/"| BACK + INGRESS ==>|"/collaboration/"| YHUB + INGRESS -->|"/media/ after auth check"| S3 + INGRESS -.->|"auth_request"| BACK + INGRESS -->|"/external_api/v1.0/"| BACK + + %% backend + BACK --> PG + BACK --> S3 + BACK --> REDIS + REDIS --> CELERY + CELERY --> PG + BACK <-.->|"OIDC"| OIDC + BACK -->|"AI features"| LLM + BACK -->|"query"| SEARCH + CELERY -->|"index"| SEARCH + CELERY -->|"mail"| SMTP + BACK <-.->|"scan · callback"| AV + BACK -.-> OBS + + %% backend and collaboration + BACK <==>|"document lifecycle
+ auth callbacks"| YHUB + CELERY -->|"cascade sync"| YHUB + + %% conversion + BACK -->|"convert"| YPROV + BACK -->|"import .docx"| DOCSPEC + YPROV -.->|"JWKS"| BACK + + %% collaboration internals + YHUB --> VALKEY + VALKEY --> YWORKER + YWORKER --> YPG + YHUB --> YPG + YWORKER -.-> YS3 + YHUB -.->|"legacy content"| S3 + + classDef client fill:#e8f0fe,stroke:#3b6fd4,color:#12244a + classDef service fill:#e6f7ed,stroke:#2f9e5f,color:#0f3d24 + classDef store fill:#fff4e0,stroke:#d99a19,color:#4a3410 + classDef external fill:#f3ecfc,stroke:#8256c4,color:#2e1a4d + classDef edgeNode fill:#fdeaea,stroke:#cf4747,color:#4a1414 + + class BROWSER,EXTAPP client + class FRONT,BACK,CELERY,YHUB,YWORKER,YPROV,DOCSPEC service + class PG,REDIS,S3,VALKEY,YPG,YS3 store + class OIDC,LLM,SEARCH,SMTP,AV,OBS external + class INGRESS edgeNode + + style clients fill:#fbfcff,stroke:#c7d4ec + style edge fill:#fffafa,stroke:#eccaca + style app fill:#fafdfb,stroke:#c9e3d4 + style collab fill:#f0faf4,stroke:#a9d6bd + style conv fill:#f0faf4,stroke:#a9d6bd + style stores fill:#fffdf8,stroke:#ecd9b0 + style ext fill:#fdfbff,stroke:#d9c9ee ``` -### Architecture decision records +## Request routing -- [ADR-0001-20250106-use-yjs-for-docs-editing](./adr/ADR-0001-20250106-use-yjs-for-docs-editing.md) \ No newline at end of file +One hostname is enough. The reverse proxy dispatches on the path: + +| Path | Goes to | Notes | +| --- | --- | --- | +| `/` | frontend | static assets, client-side routing | +| `/api/v1.0/` | backend | REST API, session cookie | +| `/external_api/v1.0/` | backend | resource server, OIDC bearer token | +| `/collaboration/ws/v1/…` | yhub | the websocket the editors open | +| `/collaboration/{ydoc,rollback,prune,changeset,activity,jwks}` | yhub | document APIs, guarded by the same document authorization | +| `/media/` | S3, after an `auth_request` to the backend | see [attachments](#attachments-and-protected-media) | + +Four yhub routes are **backend-internal** and must not be published: +`reset-connections`, `migrate`, `restore-ydoc` and `reset-ydoc`. The two probes +(`ping`, `ready`) are called by the kubelet and stay in-cluster too. The helm +chart's ingress lists what it routes rather than what it hides, so they stay +private on their own. + +## Collaboration + + +```mermaid +flowchart TB + + EDITORS["Editors
one browser per participant"] + BACK["Django backend
owns documents, users, access rights"] + + subgraph yhubbox["yhub — two halves sharing only the stores"] + direction LR + SRV["server
YHUB_ROLE=server | all
websockets + /collaboration/ routes"] + WRK["worker
YHUB_ROLE=worker | all
merges updates, persists, trims"] + end + + VALKEY[("Valkey / Redis
update stream · consumer group")] + YPG[("PostgreSQL yhub
persisted Yjs state")] + YS3[("S3 yhub
optional blob store")] + LEGACY[("S3 media bucket
legacy content + versions")] + + EDITORS <==>|"WSS /collaboration/ws/v1/:org/:docid"| SRV + SRV -->|"auth + change callbacks"| BACK + BACK -->|"document lifecycle API"| SRV + + SRV -->|"XADD"| VALKEY + VALKEY -->|"XREADGROUP"| WRK + VALKEY -->|"fan-out to the other replicas"| SRV + SRV -->|"read on first sync"| YPG + WRK -->|"merged state"| YPG + WRK -.->|"when enabled"| YS3 + SRV -.->|"seed a room · replay history"| LEGACY + BACK --> LEGACY + + classDef svc fill:#e6f7ed,stroke:#2f9e5f,color:#0f3d24 + classDef store fill:#fff4e0,stroke:#d99a19,color:#4a3410 + classDef client fill:#e8f0fe,stroke:#3b6fd4,color:#12244a + class SRV,WRK,BACK svc + class VALKEY,YPG,YS3,LEGACY store + class EDITORS client + style yhubbox fill:#f0faf4,stroke:#a9d6bd +``` + +yhub is **two halves that share the two stores and nothing else** — no +in-process state, no ordering between them: + +- the **server** accepts the websockets, serves the routes, and writes every + update to the Valkey stream; +- the **worker** claims tasks from that stream, merges the updates, stores the + result and trims what it persisted. + +`YHUB_ROLE` decides which half a process runs. Unset means both, which is the +default. Splitting them lets each scale on its own — servers with the connected +editors, workers with the write throughput — but they are split together or not +at all: servers alone accept edits and never persist them. + +Consumer groups hand each task to exactly one worker, and replicas exchange +updates through the stream, so **no sticky routing is needed** on the websocket +ingress and any number of replicas can serve the same document. + +yhub decides nothing about access. On every websocket upgrade it asks the +backend twice — `GET /api/v1.0/users/me/` for the identity behind the cookie, +then `GET /api/v1.0/documents/{id}/` for what that identity may do with this +document — and a backend that does not answer produces a retryable 503 rather +than a permission decision. When the persisted content changes, it calls +`POST /api/v1.0/documents/{id}/content-updated/` back, so that lists ordered by +`updated_at` follow the edits. That call is best effort: one the backend refuses +or never receives is logged and dropped. + +Documents created before the migration still have their content in the S3 media +bucket. The first access to a room yhub does not know seeds it from there; +`POST /collaboration/migrate/…` replays a document's full version history out of +the same bucket. + +See [collaboration.md](collaboration.md) for the configuration. + +### Opening and editing a document + + +```mermaid +sequenceDiagram + autonumber + actor U as Browser + participant N as Ingress / Nginx + participant D as Django backend + participant Y as yhub server + participant V as Valkey + participant W as yhub worker + participant P as PostgreSQL yhub + + U->>N: GET /docs/:id + N->>D: GET /api/v1.0/documents/:id/ (session cookie) + D-->>U: metadata, abilities, link reach + + U->>N: WSS /collaboration/ws/v1/docs/:id + N->>Y: upgrade, forwards cookie and origin + Y->>D: GET /api/v1.0/users/me/ + D-->>Y: user id (or 401, then anonymous) + Y->>D: GET /api/v1.0/documents/:id/ + D-->>Y: abilities, so read-only or read-write + Note over Y: unknown room, seed it once
from the legacy S3 content + Y->>P: read the persisted state + Y-->>U: initial sync + + loop while editing + U->>Y: Yjs update + Y->>V: XADD + Y-->>U: broadcast to the other editors + end + + V->>W: XREADGROUP, one task to one worker + W->>P: merge and persist + W->>V: trim what is stored + Y->>D: POST /documents/:id/content-updated/ (yhub JWT) + D->>D: bump updated_at, queue the search indexer +``` + +## Content is read through yhub, never around it + +The backend never reads a document's content from its own database — it does not +have it. Anything needing the text goes through yhub's REST API with an admin +JWT: + +| The backend wants to | It calls | +| --- | --- | +| create a document with initial content | `POST /collaboration/create-ydoc/v1/…` — a strict create, 409 if content already exists | +| read the content (export, search indexing, AI) | `GET /collaboration/ydoc/v1/…` | +| delete a document | `DELETE /collaboration/ydoc/v1/…` | +| restore a deleted one | `POST /collaboration/restore-ydoc/v1/…` | +| erase the content, keeping the room | `POST /collaboration/reset-ydoc/v1/…` | +| re-check the rights of connected clients | `POST /collaboration/reset-connections/v1/…` | +| replay the legacy history | `POST /collaboration/migrate/v1/…` | + +Deletions and permission changes cascade: a document inherits the accesses of its +ancestors, so the Celery tasks walk the subtree and call the document-scoped +endpoint once per descendant. A document that fails is logged and does not stop +the ones after it. + +## Conversion, import and export + + +```mermaid +sequenceDiagram + autonumber + actor U as Browser + participant D as Django backend + participant K as Docspec + participant C as y-provider + participant Y as yhub server + + rect rgb(240,247,255) + Note over U,Y: importing a file + U->>D: POST /documents/create-for-owner/ or import + alt .docx + D->>K: POST /conversion + K-->>D: BlockNote blocks + else markdown + Note over D: kept as is + end + D->>C: POST /api/convert/ (admin JWT) + C-->>D: Yjs update, binary + D->>Y: POST /collaboration/create-ydoc/v1/docs/:id + Y-->>D: 201, or 409 if the document already has content + end + + rect rgb(245,255,248) + Note over U,Y: exporting or reading the content + U->>D: GET /documents/:id/formatted-content/?content_format=markdown + D->>Y: GET /collaboration/ydoc/v1/docs/:id + Y-->>D: current state, the source of truth + D->>C: POST /api/convert/ Yjs to markdown + C-->>D: markdown + D-->>U: converted content + end +``` + +**y-provider** is the only service that understands both a Yjs update and the +BlockNote schema, so every conversion in either direction goes through it. It +used to serve the websockets as well, which is why it could be deployed twice; +the collaboration is served by yhub now and the conversion is all that is left. + +**Docspec** turns `.docx` into modern editable content. It is optional +(`CONVERSION_UPLOAD_ENABLED`), it exposes a public API with no authentication of +its own, and should therefore be deployed on a private network. + +See [format_conversion.md](format_conversion.md). + +## Attachments and protected media + + +```mermaid +sequenceDiagram + autonumber + actor U as Browser + participant N as Ingress / Nginx + participant D as Django backend + participant S as S3 / MinIO + participant C as Celery worker + participant A as Malware scanner + + rect rgb(240,247,255) + Note over U,A: upload + U->>D: POST /documents/:id/attachment-upload/ + D->>D: check write access, size, mime type + D->>S: PUT object, status=processing + D->>C: queue the scan + C->>A: scan the object + A-->>D: callback, safe or infected + D->>S: metadata status=ready, or delete + D-->>U: media url + end + + rect rgb(245,255,248) + Note over U,S: read + U->>N: GET /media/:key + N->>D: auth_request /documents/media-auth/
X-Original-URL + D->>D: resolve the document, check read access + D-->>N: 200 + Authorization, X-Amz-Date, X-Amz-Content-SHA256 + N->>S: GET the object with those signed headers + S-->>U: file, with Content-Security-Policy default-src none + end +``` + +Attachments are never served straight from the bucket. Every `/media/` request +goes through an Nginx `auth_request` sub-request to the backend, which resolves +the object key back to its document, checks the caller's read access, and answers +with the S3 headers signing that single object. Nginx then proxies the object +with those headers. The bucket needs no public access, and a leaked url grants +nothing. + +The sub-request is on the hot path of every image in a document, so a slow +backend shows up here first — the reason the readiness probe and the database +pool sizing matter more than their traffic suggests. + +## Service-to-service trust + + +```mermaid +flowchart LR + + BACK["Django backend
signs with JWT_PRIVATE_KEY
publishes /api/v1.0/jwks"] + YHUB["yhub
signs with YHUB_JWT_PRIVATE_KEY
publishes /collaboration/jwks/v1"] + YPROV["y-provider
verifies only, signs nothing"] + RS["Resource server
same Django process
publishes /external_api/v1.0/jwks"] + OIDC["OIDC provider"] + + BACK ==>|"admin JWT · RS256 · aud yhub · short lived"| YHUB + YHUB -.->|"fetches the backend JWKS to verify it"| BACK + YHUB ==>|"callback JWT · RS256 · aud docs-backend · 1 min"| BACK + BACK -.->|"fetches the yhub JWKS to verify it"| YHUB + BACK ==>|"admin JWT · RS256"| YPROV + YPROV -.->|"fetches the backend JWKS to verify it"| BACK + OIDC -.->|"introspection · declared JWKS"| RS + RS --- BACK + + NOTE["No shared secret in either direction.
Each side holds only its own private key
and can roll it without touching the other:
keys are picked by kid and the set is re-fetched
whenever an unknown one shows up."] + + classDef svc fill:#e6f7ed,stroke:#2f9e5f,color:#0f3d24 + classDef external fill:#f3ecfc,stroke:#8256c4,color:#2e1a4d + classDef note fill:#fffdf5,stroke:#d9c9a0,color:#4a3410 + class BACK,YHUB,YPROV,RS svc + class OIDC external + class NOTE note +``` + +Both directions between the backend and yhub are authenticated with short-lived +RS256 JWTs, and each side verifies the other against the JWKS it publishes. Both +need a signing key of their own, and neither needs a copy of the other's — so +rolling one needs no change on the other side. The helm chart can generate both +keys into a secret every service mounts read-only (`jwtKeys.enabled`). + +Two other credentials exist and are *not* part of that scheme: + +- `Y_PROVIDER_API_KEY`, sent by yhub as `X-Y-Provider-Key` on its authorization + calls. It only exempts them from the API throttling — it authenticates nothing. +- `SERVER_TO_SERVER_API_TOKENS`, a list of bearer tokens accepted on + `create-for-owner`, for trusted services creating a document on a user's behalf. + +## Authentication of the users + +The frontend redirects to the OIDC provider (Keycloak in development, ProConnect +in production, any compliant provider elsewhere); the backend is the relying +party and keeps the session in a cookie. That cookie is what the browser sends to +the API *and* what yhub forwards to the backend when authorizing a websocket. + +Docs is also a **resource server**: with `OIDC_RESOURCE_SERVER_ENABLED`, another +application can call `/external_api/v1.0/` with an OIDC access token, on the +routes and actions the `EXTERNAL_API` setting allows. See +[resource_server.md](resource_server.md). + +## Asynchronous work + +Celery runs on the same Redis instance the backend caches and stores sessions in, +on a database of its own. It carries what must not +block a request: invitation and access-request emails, the search indexing, the +user reconciliation import, the malware scan callbacks, and the cascade calls to +yhub when a document is deleted, restored, or has its accesses changed. There is +no beat scheduler — periodic work is deployed as Kubernetes CronJobs +(`backend.cronjobs` in the chart). + +## Optional and external services + +| Service | Enabled by | Used for | +| --- | --- | --- | +| Search / indexing | `SEARCH_INDEXER_CLASS`, `INDEXING_URL`, `SEARCH_URL` | full-text search across documents, fed by Celery | +| LLM provider | `AI_FEATURE_ENABLED` + OpenAI-compatible or Mistral credentials | rewrite, summarize, translate, fix typos | +| Langfuse | `LANGFUSE_*` | tracing the LLM calls | +| Malware detection | `MALWARE_DETECTION` backend | scanning uploaded attachments | +| Docspec | `CONVERSION_UPLOAD_ENABLED`, `DOCSPEC_API_URL` | `.docx` import | +| yhub S3 persistence | `YHUB_S3_PERSISTENCE` | document blobs in a bucket instead of PostgreSQL | +| Sentry, PostHog | `SENTRY_DSN`, `POSTHOG_KEY` | errors and product analytics | + +## Data stores + +| Store | Holds | Lost if deleted | +| --- | --- | --- | +| PostgreSQL (Django) | documents, users, accesses, invitations | everything but the document text | +| Redis | Celery broker, cache, sessions | in-flight tasks, and every session — users are logged out | +| S3 / MinIO | attachments, legacy content and its version history | attachments, version history | +| Valkey / Redis (yhub) | the update stream not yet persisted | the last seconds of edits — hence `appendonly` | +| PostgreSQL (yhub) | the persisted Yjs state | the documents' text | +| S3 (yhub, optional) | the same state as blobs | the documents' text | + +yhub's schema is never created at startup — the server runs no DDL. Run its init +script once before starting it, and again after every upgrade that adds a table; +the helm chart runs it as a job. + +## Regenerating the diagrams + +The diagrams above are the source. `documentation/assets/diagrams/render.sh` +extracts each one into a standalone `.mmd` file and renders it to PNG and SVG in +the same directory: + +```bash +./documentation/assets/diagrams/render.sh +``` + +## Architecture decision records + +- [ADR-0001-20250106-use-yjs-for-docs-editing](./adr/ADR-0001-20250106-use-yjs-for-docs-editing.md) diff --git a/documentation/assets/diagrams/architecture-collaboration.mmd b/documentation/assets/diagrams/architecture-collaboration.mmd new file mode 100644 index 000000000..6729c2f8c --- /dev/null +++ b/documentation/assets/diagrams/architecture-collaboration.mmd @@ -0,0 +1,36 @@ +flowchart TB + + EDITORS["Editors
one browser per participant"] + BACK["Django backend
owns documents, users, access rights"] + + subgraph yhubbox["yhub — two halves sharing only the stores"] + direction LR + SRV["server
YHUB_ROLE=server | all
websockets + /collaboration/ routes"] + WRK["worker
YHUB_ROLE=worker | all
merges updates, persists, trims"] + end + + VALKEY[("Valkey / Redis
update stream · consumer group")] + YPG[("PostgreSQL yhub
persisted Yjs state")] + YS3[("S3 yhub
optional blob store")] + LEGACY[("S3 media bucket
legacy content + versions")] + + EDITORS <==>|"WSS /collaboration/ws/v1/:org/:docid"| SRV + SRV -->|"auth + change callbacks"| BACK + BACK -->|"document lifecycle API"| SRV + + SRV -->|"XADD"| VALKEY + VALKEY -->|"XREADGROUP"| WRK + VALKEY -->|"fan-out to the other replicas"| SRV + SRV -->|"read on first sync"| YPG + WRK -->|"merged state"| YPG + WRK -.->|"when enabled"| YS3 + SRV -.->|"seed a room · replay history"| LEGACY + BACK --> LEGACY + + classDef svc fill:#e6f7ed,stroke:#2f9e5f,color:#0f3d24 + classDef store fill:#fff4e0,stroke:#d99a19,color:#4a3410 + classDef client fill:#e8f0fe,stroke:#3b6fd4,color:#12244a + class SRV,WRK,BACK svc + class VALKEY,YPG,YS3,LEGACY store + class EDITORS client + style yhubbox fill:#f0faf4,stroke:#a9d6bd diff --git a/documentation/assets/diagrams/architecture-collaboration.png b/documentation/assets/diagrams/architecture-collaboration.png new file mode 100644 index 000000000..56749204f Binary files /dev/null and b/documentation/assets/diagrams/architecture-collaboration.png differ diff --git a/documentation/assets/diagrams/architecture-collaboration.svg b/documentation/assets/diagrams/architecture-collaboration.svg new file mode 100644 index 000000000..02bb710d4 --- /dev/null +++ b/documentation/assets/diagrams/architecture-collaboration.svg @@ -0,0 +1 @@ +

yhub — two halves sharing only the stores

WSS /collaboration/ws/v1/:org/:docid

auth + change callbacks

document lifecycle API

XADD

XREADGROUP

fan-out to the other replicas

read on first sync

merged state

when enabled

seed a room · replay history

Editors
one browser per participant

Django backend
owns documents, users, access rights

server
YHUB_ROLE=server | all
websockets + /collaboration/ routes

worker
YHUB_ROLE=worker | all
merges updates, persists, trims

Valkey / Redis
update stream · consumer group

PostgreSQL yhub
persisted Yjs state

S3 yhub
optional blob store

S3 media bucket
legacy content + versions

\ No newline at end of file diff --git a/documentation/assets/diagrams/architecture-services.mmd b/documentation/assets/diagrams/architecture-services.mmd new file mode 100644 index 000000000..fca05d20a --- /dev/null +++ b/documentation/assets/diagrams/architecture-services.mmd @@ -0,0 +1,112 @@ +flowchart LR + + subgraph clients["Clients"] + direction TB + BROWSER["Browser
Next.js SPA · BlockNote
+ service worker (offline)"] + EXTAPP["Third-party app
resource-server client"] + end + + subgraph edge["Edge"] + INGRESS["Ingress / Nginx
TLS · routing · media auth"] + end + + subgraph app["Application services"] + direction TB + FRONT["Frontend
Next.js static build"] + BACK["Backend
Django · DRF"] + CELERY["Celery worker"] + subgraph collab["Collaboration"] + direction TB + YHUB["yhub server
WebSocket + REST"] + YWORKER["yhub worker"] + end + subgraph conv["Conversion"] + direction TB + YPROV["y-provider
Yjs to md / HTML / JSON"] + DOCSPEC["Docspec
.docx import"] + end + end + + subgraph stores["Data stores"] + direction TB + PG[("PostgreSQL
docs · users · rights")] + REDIS[("Redis
broker · cache")] + S3[("S3 / MinIO
attachments · versions")] + VALKEY[("Valkey
update stream")] + YPG[("PostgreSQL yhub
Yjs state")] + YS3[("S3 yhub
optional")] + end + + subgraph ext["External services"] + direction TB + OIDC["OIDC provider
Keycloak · ProConnect"] + LLM["LLM provider
OpenAI · Mistral"] + SEARCH["Search / indexing"] + SMTP["SMTP relay"] + AV["Malware detection"] + OBS["Langfuse · Sentry · PostHog"] + end + + %% clients to edge + BROWSER ==>|"HTTPS · WSS"| INGRESS + EXTAPP -->|"bearer token"| INGRESS + BROWSER -.->|"login"| OIDC + + %% edge to services + INGRESS -->|"/"| FRONT + INGRESS ==>|"/api/v1.0/"| BACK + INGRESS ==>|"/collaboration/"| YHUB + INGRESS -->|"/media/ after auth check"| S3 + INGRESS -.->|"auth_request"| BACK + INGRESS -->|"/external_api/v1.0/"| BACK + + %% backend + BACK --> PG + BACK --> S3 + BACK --> REDIS + REDIS --> CELERY + CELERY --> PG + BACK <-.->|"OIDC"| OIDC + BACK -->|"AI features"| LLM + BACK -->|"query"| SEARCH + CELERY -->|"index"| SEARCH + CELERY -->|"mail"| SMTP + BACK <-.->|"scan · callback"| AV + BACK -.-> OBS + + %% backend and collaboration + BACK <==>|"document lifecycle
+ auth callbacks"| YHUB + CELERY -->|"cascade sync"| YHUB + + %% conversion + BACK -->|"convert"| YPROV + BACK -->|"import .docx"| DOCSPEC + YPROV -.->|"JWKS"| BACK + + %% collaboration internals + YHUB --> VALKEY + VALKEY --> YWORKER + YWORKER --> YPG + YHUB --> YPG + YWORKER -.-> YS3 + YHUB -.->|"legacy content"| S3 + + classDef client fill:#e8f0fe,stroke:#3b6fd4,color:#12244a + classDef service fill:#e6f7ed,stroke:#2f9e5f,color:#0f3d24 + classDef store fill:#fff4e0,stroke:#d99a19,color:#4a3410 + classDef external fill:#f3ecfc,stroke:#8256c4,color:#2e1a4d + classDef edgeNode fill:#fdeaea,stroke:#cf4747,color:#4a1414 + + class BROWSER,EXTAPP client + class FRONT,BACK,CELERY,YHUB,YWORKER,YPROV,DOCSPEC service + class PG,REDIS,S3,VALKEY,YPG,YS3 store + class OIDC,LLM,SEARCH,SMTP,AV,OBS external + class INGRESS edgeNode + + style clients fill:#fbfcff,stroke:#c7d4ec + style edge fill:#fffafa,stroke:#eccaca + style app fill:#fafdfb,stroke:#c9e3d4 + style collab fill:#f0faf4,stroke:#a9d6bd + style conv fill:#f0faf4,stroke:#a9d6bd + style stores fill:#fffdf8,stroke:#ecd9b0 + style ext fill:#fdfbff,stroke:#d9c9ee diff --git a/documentation/assets/diagrams/architecture-services.png b/documentation/assets/diagrams/architecture-services.png new file mode 100644 index 000000000..aa30b9858 Binary files /dev/null and b/documentation/assets/diagrams/architecture-services.png differ diff --git a/documentation/assets/diagrams/architecture-services.svg b/documentation/assets/diagrams/architecture-services.svg new file mode 100644 index 000000000..38c5c5b40 --- /dev/null +++ b/documentation/assets/diagrams/architecture-services.svg @@ -0,0 +1 @@ +

External services

Data stores

Application services

Edge

Clients

Conversion

Collaboration

HTTPS · WSS

bearer token

login

/

/api/v1.0/

/collaboration/

/media/ after auth check

auth_request

/external_api/v1.0/

OIDC

AI features

query

index

mail

scan · callback

document lifecycle
+ auth callbacks

cascade sync

convert

import .docx

JWKS

legacy content

Browser
Next.js SPA · BlockNote
+ service worker (offline)

Third-party app
resource-server client

Ingress / Nginx
TLS · routing · media auth

Frontend
Next.js static build

Backend
Django · DRF

Celery worker

yhub server
WebSocket + REST

yhub worker

y-provider
Yjs to md / HTML / JSON

Docspec
.docx import

PostgreSQL
docs · users · rights

Redis
broker · cache

S3 / MinIO
attachments · versions

Valkey
update stream

PostgreSQL yhub
Yjs state

S3 yhub
optional

OIDC provider
Keycloak · ProConnect

LLM provider
OpenAI · Mistral

Search / indexing

SMTP relay

Malware detection

Langfuse · Sentry · PostHog

\ No newline at end of file diff --git a/documentation/assets/diagrams/architecture-trust.mmd b/documentation/assets/diagrams/architecture-trust.mmd new file mode 100644 index 000000000..5425ea046 --- /dev/null +++ b/documentation/assets/diagrams/architecture-trust.mmd @@ -0,0 +1,25 @@ +flowchart LR + + BACK["Django backend
signs with JWT_PRIVATE_KEY
publishes /api/v1.0/jwks"] + YHUB["yhub
signs with YHUB_JWT_PRIVATE_KEY
publishes /collaboration/jwks/v1"] + YPROV["y-provider
verifies only, signs nothing"] + RS["Resource server
same Django process
publishes /external_api/v1.0/jwks"] + OIDC["OIDC provider"] + + BACK ==>|"admin JWT · RS256 · aud yhub · short lived"| YHUB + YHUB -.->|"fetches the backend JWKS to verify it"| BACK + YHUB ==>|"callback JWT · RS256 · aud docs-backend · 1 min"| BACK + BACK -.->|"fetches the yhub JWKS to verify it"| YHUB + BACK ==>|"admin JWT · RS256"| YPROV + YPROV -.->|"fetches the backend JWKS to verify it"| BACK + OIDC -.->|"introspection · declared JWKS"| RS + RS --- BACK + + NOTE["No shared secret in either direction.
Each side holds only its own private key
and can roll it without touching the other:
keys are picked by kid and the set is re-fetched
whenever an unknown one shows up."] + + classDef svc fill:#e6f7ed,stroke:#2f9e5f,color:#0f3d24 + classDef external fill:#f3ecfc,stroke:#8256c4,color:#2e1a4d + classDef note fill:#fffdf5,stroke:#d9c9a0,color:#4a3410 + class BACK,YHUB,YPROV,RS svc + class OIDC external + class NOTE note diff --git a/documentation/assets/diagrams/architecture-trust.png b/documentation/assets/diagrams/architecture-trust.png new file mode 100644 index 000000000..1f7e8ea83 Binary files /dev/null and b/documentation/assets/diagrams/architecture-trust.png differ diff --git a/documentation/assets/diagrams/architecture-trust.svg b/documentation/assets/diagrams/architecture-trust.svg new file mode 100644 index 000000000..17d193dc9 --- /dev/null +++ b/documentation/assets/diagrams/architecture-trust.svg @@ -0,0 +1 @@ +

admin JWT · RS256 · aud yhub · short lived

fetches the backend JWKS to verify it

callback JWT · RS256 · aud docs-backend · 1 min

fetches the yhub JWKS to verify it

admin JWT · RS256

fetches the backend JWKS to verify it

introspection · declared JWKS

Django backend
signs with JWT_PRIVATE_KEY
publishes /api/v1.0/jwks

yhub
signs with YHUB_JWT_PRIVATE_KEY
publishes /collaboration/jwks/v1

y-provider
verifies only, signs nothing

Resource server
same Django process
publishes /external_api/v1.0/jwks

OIDC provider

No shared secret in either direction.
Each side holds only its own private key
and can roll it without touching the other:
keys are picked by kid and the set is re-fetched
whenever an unknown one shows up.

\ No newline at end of file diff --git a/documentation/assets/diagrams/render.sh b/documentation/assets/diagrams/render.sh new file mode 100755 index 000000000..611d1dd5f --- /dev/null +++ b/documentation/assets/diagrams/render.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# Extract every mermaid diagram of documentation/architecture.md into a +# standalone .mmd file, and render it to PNG and SVG next to it. +# +# The document is the source: a diagram is picked up when its fenced block is +# preceded by a `` comment, which gives it its file name. +# +# Usage: ./documentation/assets/diagrams/render.sh [name ...] +# (no argument renders them all) + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +doc="${here}/../../architecture.md" +width="${MERMAID_WIDTH:-2000}" + +[ -f "$doc" ] || { echo "not found: $doc" >&2; exit 1; } + +# 1. extract +names=$(awk -v dir="$here" ' + /^>D: callback, safe or infected + D->>S: metadata status=ready, or delete + D-->>U: media url + end + + rect rgb(245,255,248) + Note over U,S: read + U->>N: GET /media/:key + N->>D: auth_request /documents/media-auth/
X-Original-URL + D->>D: resolve the document, check read access + D-->>N: 200 + Authorization, X-Amz-Date, X-Amz-Content-SHA256 + N->>S: GET the object with those signed headers + S-->>U: file, with Content-Security-Policy default-src none + end diff --git a/documentation/assets/diagrams/sequence-attachment.png b/documentation/assets/diagrams/sequence-attachment.png new file mode 100644 index 000000000..cf3b10851 Binary files /dev/null and b/documentation/assets/diagrams/sequence-attachment.png differ diff --git a/documentation/assets/diagrams/sequence-attachment.svg b/documentation/assets/diagrams/sequence-attachment.svg new file mode 100644 index 000000000..835410bb6 --- /dev/null +++ b/documentation/assets/diagrams/sequence-attachment.svg @@ -0,0 +1 @@ +Malware scannerCelery workerS3 / MinIODjango backendIngress / NginxMalware scannerCelery workerS3 / MinIODjango backendIngress / NginxuploadreadBrowserPOST /documents/:id/attachment-upload/1check write access, size, mime type2PUT object, status=processing3queue the scan4scan the object5callback, safe or infected6metadata status=ready, or delete7media url8GET /media/:key9auth_request /documents/media-auth/X-Original-URL10resolve the document, check read access11200 + Authorization, X-Amz-Date, X-Amz-Content-SHA25612GET the object with those signed headers13file, with Content-Security-Policy default-src none14Browser \ No newline at end of file diff --git a/documentation/assets/diagrams/sequence-conversion.mmd b/documentation/assets/diagrams/sequence-conversion.mmd new file mode 100644 index 000000000..2322ca064 --- /dev/null +++ b/documentation/assets/diagrams/sequence-conversion.mmd @@ -0,0 +1,32 @@ +sequenceDiagram + autonumber + actor U as Browser + participant D as Django backend + participant K as Docspec + participant C as y-provider + participant Y as yhub server + + rect rgb(240,247,255) + Note over U,Y: importing a file + U->>D: POST /documents/create-for-owner/ or import + alt .docx + D->>K: POST /conversion + K-->>D: BlockNote blocks + else markdown + Note over D: kept as is + end + D->>C: POST /api/convert/ (admin JWT) + C-->>D: Yjs update, binary + D->>Y: POST /collaboration/create-ydoc/v1/docs/:id + Y-->>D: 201, or 409 if the document already has content + end + + rect rgb(245,255,248) + Note over U,Y: exporting or reading the content + U->>D: GET /documents/:id/formatted-content/?content_format=markdown + D->>Y: GET /collaboration/ydoc/v1/docs/:id + Y-->>D: current state, the source of truth + D->>C: POST /api/convert/ Yjs to markdown + C-->>D: markdown + D-->>U: converted content + end diff --git a/documentation/assets/diagrams/sequence-conversion.png b/documentation/assets/diagrams/sequence-conversion.png new file mode 100644 index 000000000..d7fade238 Binary files /dev/null and b/documentation/assets/diagrams/sequence-conversion.png differ diff --git a/documentation/assets/diagrams/sequence-conversion.svg b/documentation/assets/diagrams/sequence-conversion.svg new file mode 100644 index 000000000..432d0436a --- /dev/null +++ b/documentation/assets/diagrams/sequence-conversion.svg @@ -0,0 +1 @@ +yhub servery-providerDocspecDjango backendyhub servery-providerDocspecDjango backendimporting a filekept as isalt[.docx][markdown]exporting or reading the contentBrowserPOST /documents/create-for-owner/ or import1POST /conversion2BlockNote blocks3POST /api/convert/ (admin JWT)4Yjs update, binary5POST /collaboration/create-ydoc/v1/docs/:id6201, or 409 if the document already has content7GET /documents/:id/formatted-content/?content_format=markdown8GET /collaboration/ydoc/v1/docs/:id9current state, the source of truth10POST /api/convert/ Yjs to markdown11markdown12converted content13Browser \ No newline at end of file diff --git a/documentation/assets/diagrams/sequence-open-document.mmd b/documentation/assets/diagrams/sequence-open-document.mmd new file mode 100644 index 000000000..d66e92243 --- /dev/null +++ b/documentation/assets/diagrams/sequence-open-document.mmd @@ -0,0 +1,35 @@ +sequenceDiagram + autonumber + actor U as Browser + participant N as Ingress / Nginx + participant D as Django backend + participant Y as yhub server + participant V as Valkey + participant W as yhub worker + participant P as PostgreSQL yhub + + U->>N: GET /docs/:id + N->>D: GET /api/v1.0/documents/:id/ (session cookie) + D-->>U: metadata, abilities, link reach + + U->>N: WSS /collaboration/ws/v1/docs/:id + N->>Y: upgrade, forwards cookie and origin + Y->>D: GET /api/v1.0/users/me/ + D-->>Y: user id (or 401, then anonymous) + Y->>D: GET /api/v1.0/documents/:id/ + D-->>Y: abilities, so read-only or read-write + Note over Y: unknown room, seed it once
from the legacy S3 content + Y->>P: read the persisted state + Y-->>U: initial sync + + loop while editing + U->>Y: Yjs update + Y->>V: XADD + Y-->>U: broadcast to the other editors + end + + V->>W: XREADGROUP, one task to one worker + W->>P: merge and persist + W->>V: trim what is stored + Y->>D: POST /documents/:id/content-updated/ (yhub JWT) + D->>D: bump updated_at, queue the search indexer diff --git a/documentation/assets/diagrams/sequence-open-document.png b/documentation/assets/diagrams/sequence-open-document.png new file mode 100644 index 000000000..8d90f59f8 Binary files /dev/null and b/documentation/assets/diagrams/sequence-open-document.png differ diff --git a/documentation/assets/diagrams/sequence-open-document.svg b/documentation/assets/diagrams/sequence-open-document.svg new file mode 100644 index 000000000..9d6eab7cc --- /dev/null +++ b/documentation/assets/diagrams/sequence-open-document.svg @@ -0,0 +1 @@ +PostgreSQL yhubyhub workerValkeyyhub serverDjango backendIngress / NginxPostgreSQL yhubyhub workerValkeyyhub serverDjango backendIngress / Nginxunknown room, seed it oncefrom the legacy S3 contentloop[while editing]BrowserGET /docs/:id1GET /api/v1.0/documents/:id/ (session cookie)2metadata, abilities, link reach3WSS /collaboration/ws/v1/docs/:id4upgrade, forwards cookie and origin5GET /api/v1.0/users/me/6user id (or 401, then anonymous)7GET /api/v1.0/documents/:id/8abilities, so read-only or read-write9read the persisted state10initial sync11Yjs update12XADD13broadcast to the other editors14XREADGROUP, one task to one worker15merge and persist16trim what is stored17POST /documents/:id/content-updated/ (yhub JWT)18bump updated_at, queue the search indexer19Browser \ No newline at end of file