Add architecture documentation, ADRs, and operations concept
Initial deliverable of the architecture phase: 16 ADRs (stack, CRDT collaboration, plugin sandbox, import/export, backups, CI/CD), data model, permission model, real-time collaboration and plugin concepts, deployment/operations/security documentation, and the milestone roadmap that the implementation issues are derived from. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
0629411966
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Stefan Waidele and Dorfteich contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
53
README.md
Normal file
53
README.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Dorfteich
|
||||
|
||||
Dorfteich is an open-source wiki system built around **ponds** (German:
|
||||
*Teiche*) — self-contained wiki spaces that people and teams organize freely
|
||||
with hierarchical labels, directories, and Obsidian-style page relations.
|
||||
Pages are edited in a collaborative WYSIWYG editor with live cursors and
|
||||
offline support.
|
||||
|
||||
- Project site: <https://dorfteich.cloud>
|
||||
- Public flagship instance: <https://dorfteich.online>
|
||||
- License: [MIT](LICENSE)
|
||||
|
||||
## Key features
|
||||
|
||||
- **Real-time collaboration** — multiple people edit the same page
|
||||
simultaneously; everyone sees the other participants' cursors and input
|
||||
live. Offline edits merge conflict-free on reconnect (CRDT-based).
|
||||
- **Ponds** — isolated wiki spaces with their own members, permissions,
|
||||
fonts, and page organization. Every registered person gets a personal pond.
|
||||
- **Flexible organization** — hierarchical labels, free page ordering,
|
||||
`[[wikilinks]]` with backlinks. A classic page tree is possible but never
|
||||
enforced.
|
||||
- **Fine-grained permissions** — roles (Site Admin, Pond Admin, Editor,
|
||||
Reader, Public) can be granted per pond, per label, or per page; the most
|
||||
specific setting wins.
|
||||
- **Import & export** — Markdown as the primary exchange format, plus
|
||||
best-effort structural import from Word/OpenOffice and export to
|
||||
Word/OpenOffice/PDF.
|
||||
- **Plugins** — sandboxed extensions (custom blocks, styles, page tools)
|
||||
installable at runtime without redeploying the instance.
|
||||
- **Self-hosting first** — a single `docker compose up` plus a guided
|
||||
first-run setup wizard yields a working instance.
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Contents |
|
||||
| --- | --- |
|
||||
| `docs/architecture/` | Architecture documentation: ADRs, data model, permission model, collaboration and plugin concepts, deployment and operations |
|
||||
| `apps/` | Application packages (web frontend, API server, collaboration server) — created as implementation proceeds |
|
||||
| `packages/` | Shared packages (types, permission logic, plugin SDK) |
|
||||
| `deploy/` | Docker Compose stacks and deployment tooling |
|
||||
|
||||
## Status
|
||||
|
||||
The project is in the architecture and backlog phase. Implementation stories
|
||||
are tracked as issues in this repository. Start reading at
|
||||
[`docs/architecture/README.md`](docs/architecture/README.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Code, comments, and documentation are written in English. Write clear code
|
||||
that humans can follow easily; when in doubt, prefer readability over
|
||||
cleverness. All contributions are accepted under the MIT license.
|
||||
105
docs/architecture/README.md
Normal file
105
docs/architecture/README.md
Normal file
@ -0,0 +1,105 @@
|
||||
# Dorfteich — Architecture Overview
|
||||
|
||||
This directory is the authoritative architecture documentation. Every
|
||||
implementation story references the documents here; when a story and this
|
||||
documentation disagree, clarify before coding.
|
||||
|
||||
## System context
|
||||
|
||||
Dorfteich is shipped as a set of Docker containers behind a reverse proxy.
|
||||
One deployment = one **instance** (e.g. `dorfteich.online`, or a self-hosted
|
||||
installation).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph clients [Clients]
|
||||
B[Browser SPA<br/>React + TipTap + Yjs]
|
||||
end
|
||||
subgraph instance [Dorfteich instance - Docker Compose]
|
||||
RP[Reverse proxy]
|
||||
WEB[web<br/>static SPA assets]
|
||||
API[api<br/>NestJS REST]
|
||||
COLLAB[collab<br/>Hocuspocus WebSocket]
|
||||
PG[(PostgreSQL)]
|
||||
PAN[pandoc-server<br/>doc conversion]
|
||||
GOT[Gotenberg<br/>HTML to PDF]
|
||||
VOL[/uploads + plugins volume/]
|
||||
end
|
||||
B -- HTTPS --> RP
|
||||
RP --> WEB
|
||||
RP -- /api --> API
|
||||
RP -- /collab WebSocket --> COLLAB
|
||||
API --> PG
|
||||
COLLAB --> PG
|
||||
API --> PAN
|
||||
API --> GOT
|
||||
API --> VOL
|
||||
COLLAB -. permission checks .-> API
|
||||
```
|
||||
|
||||
- **web** serves the single-page application (static assets).
|
||||
- **api** owns all business logic: auth, ponds, pages, labels, permissions,
|
||||
quotas, import/export, plugin management, admin functions.
|
||||
- **collab** synchronizes CRDT documents (page content) and awareness
|
||||
(cursors) over WebSocket and persists document state to PostgreSQL. It
|
||||
authenticates clients with short-lived tokens issued by **api**.
|
||||
- **pandoc-server** and **Gotenberg** are internal-only conversion sidecars
|
||||
(Word/OpenOffice import/export, PDF export).
|
||||
- All page content lives in PostgreSQL; binary uploads (images, attachments)
|
||||
and installed plugins live on a Docker volume.
|
||||
|
||||
## Documents
|
||||
|
||||
### Architecture Decision Records (`adr/`)
|
||||
|
||||
| ADR | Decision |
|
||||
| --- | --- |
|
||||
| [0001](adr/0001-typescript-monorepo.md) | TypeScript everywhere, pnpm monorepo |
|
||||
| [0002](adr/0002-postgresql.md) | PostgreSQL as the only database |
|
||||
| [0003](adr/0003-yjs-crdt-collaboration.md) | Yjs CRDT + Hocuspocus for real-time and offline collaboration |
|
||||
| [0004](adr/0004-tiptap-editor.md) | TipTap (ProseMirror) as the WYSIWYG editor |
|
||||
| [0005](adr/0005-react-vite-frontend.md) | React + Vite single-page application |
|
||||
| [0006](adr/0006-nestjs-prisma-backend.md) | NestJS + Prisma for the API server |
|
||||
| [0007](adr/0007-auth-sessions-oidc-ready.md) | Cookie sessions, Argon2id, OIDC-ready identity model |
|
||||
| [0008](adr/0008-plugin-sandbox.md) | Sandboxed iframe plugins with a message-based API |
|
||||
| [0009](adr/0009-import-export-converters.md) | Pandoc + Gotenberg sidecars for import/export |
|
||||
| [0010](adr/0010-postgres-fulltext-search.md) | PostgreSQL full-text search behind a search interface |
|
||||
| [0011](adr/0011-file-storage-quotas.md) | Filesystem volume for uploads, DB-tracked quotas |
|
||||
| [0012](adr/0012-i18n.md) | i18next with German and English from the start |
|
||||
| [0013](adr/0013-versioning-and-trash.md) | Page version history via Yjs snapshots, soft-delete trash |
|
||||
| [0014](adr/0014-gitea-actions-cicd.md) | CI/CD with Gitea Actions, staged promotion |
|
||||
| [0015](adr/0015-backup-strategy.md) | Nightly pg_dump + uploads sync, 30-day retention, off-host mirror |
|
||||
| [0016](adr/0016-self-hosted-fonts.md) | Self-hosted Google Fonts, per-pond font configuration |
|
||||
|
||||
### Concept documents
|
||||
|
||||
| Document | Contents |
|
||||
| --- | --- |
|
||||
| [data-model.md](data-model.md) | Entities and relations: users, ponds, pages, labels, grants, quotas, plugins |
|
||||
| [permissions.md](permissions.md) | Role model and the "most specific setting wins" resolution algorithm |
|
||||
| [realtime-collaboration.md](realtime-collaboration.md) | CRDT document lifecycle, cursor sync, offline behavior, versioning hooks |
|
||||
| [plugin-architecture.md](plugin-architecture.md) | Plugin manifest, packaging, sandbox runtime, extension points, admin flows |
|
||||
| [deployment.md](deployment.md) | Compose stacks for Dev/Test/Int/Prod, environments, promotion pipeline |
|
||||
| [operations.md](operations.md) | Monitoring, logging, backup/restore, update strategy for self-hosters |
|
||||
| [security.md](security.md) | Threat-driven security concept: authn/authz, sandboxing, uploads, secrets |
|
||||
| [roadmap.md](roadmap.md) | Epics and milestones; the order stories are implemented in |
|
||||
|
||||
## Terminology
|
||||
|
||||
| German (product vision) | English (code, docs, issues) |
|
||||
| --- | --- |
|
||||
| Teich | pond |
|
||||
| Seite | page |
|
||||
| Teich-Admin | Pond Admin |
|
||||
| Bearbeitende | Editor |
|
||||
| Lesende | Reader |
|
||||
| Öffentlichkeit | Public |
|
||||
|
||||
## Conventions for implementers
|
||||
|
||||
- Language: English for code, comments, commit messages, and issues.
|
||||
- Write clear, human-readable code; document the "why", not the "what".
|
||||
- UI strings never appear hard-coded — always through i18n resources
|
||||
(see ADR 0012), with German and English translations added in the same
|
||||
change.
|
||||
- Every story lists the ADRs it depends on; read them before starting.
|
||||
55
docs/architecture/adr/0001-typescript-monorepo.md
Normal file
55
docs/architecture/adr/0001-typescript-monorepo.md
Normal file
@ -0,0 +1,55 @@
|
||||
# ADR 0001: TypeScript everywhere, pnpm monorepo
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
Dorfteich needs a browser-based collaborative editor (necessarily
|
||||
JavaScript/TypeScript) and a server side that shares non-trivial logic with
|
||||
the client: CRDT document handling, permission resolution, plugin manifest
|
||||
validation, and shared type definitions. Stories will be implemented by many
|
||||
independent contributors (including AI coding sessions) working in parallel;
|
||||
consistency and low context-switching cost matter more than raw runtime
|
||||
performance.
|
||||
|
||||
## Decision
|
||||
|
||||
- **One language: TypeScript** (strict mode) for frontend, API server,
|
||||
collaboration server, and shared packages. Runtime: Node.js (current LTS).
|
||||
- **One repository: a pnpm workspace monorepo** with this layout:
|
||||
|
||||
```
|
||||
apps/web React SPA (ADR 0005)
|
||||
apps/api NestJS REST API (ADR 0006)
|
||||
apps/collab Hocuspocus collaboration server (ADR 0003)
|
||||
apps/setup (part of api) first-run setup wizard endpoints
|
||||
packages/shared shared types, permission resolution, validation schemas
|
||||
packages/plugin-sdk plugin API typings + host/client messaging helpers
|
||||
deploy/ Docker Compose stacks, Dockerfiles, backup scripts
|
||||
docs/ this documentation
|
||||
```
|
||||
|
||||
- Shared logic (e.g. the permission resolution algorithm, ADR/permissions.md)
|
||||
lives in `packages/shared` and is imported by both `apps/api` and
|
||||
`apps/web` — never duplicated.
|
||||
- Tooling baseline: ESLint + Prettier (repo-wide config), Vitest for unit
|
||||
tests, Playwright for end-to-end tests.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Client and server cannot drift apart on types: API request/response schemas
|
||||
are defined once (Zod schemas in `packages/shared`) and validated at
|
||||
runtime on the server.
|
||||
- The Yjs ecosystem (ADR 0003) is native to this stack; no bridging layer.
|
||||
- Contributors need to know exactly one language and one package manager.
|
||||
- CPU-heavy document conversion is deliberately **not** done in Node —
|
||||
it is delegated to sidecar containers (ADR 0009).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Go or Rust backend + TS frontend**: better raw performance, but splits
|
||||
the CRDT logic across two languages (Yjs vs. yrs bindings), doubles the
|
||||
skill surface, and buys nothing at the target scale (ADR 0010 context).
|
||||
- **Multiple repositories**: rejected; cross-cutting stories (API + client)
|
||||
would constantly need coordinated PRs across repos.
|
||||
48
docs/architecture/adr/0002-postgresql.md
Normal file
48
docs/architecture/adr/0002-postgresql.md
Normal file
@ -0,0 +1,48 @@
|
||||
# ADR 0002: PostgreSQL as the only database
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
Dorfteich stores relational data (users, ponds, pages, labels, grants,
|
||||
quotas), binary CRDT document state, and needs full-text search. Self-hosting
|
||||
must stay simple ("one `docker compose up`"), so every additional stateful
|
||||
service raises the barrier. The capacity target is small-to-medium instances
|
||||
(order of 100 ponds / 10,000 pages), decided in the project kickoff.
|
||||
|
||||
## Decision
|
||||
|
||||
- **PostgreSQL (current stable major) is the single database** for:
|
||||
- all relational entities (see `data-model.md`),
|
||||
- Yjs document state and incremental updates as `bytea` (ADR 0003),
|
||||
- page version snapshots (ADR 0013),
|
||||
- full-text search via `tsvector` (ADR 0010),
|
||||
- job/outbox tables for e-mail sending and notifications (no separate
|
||||
message broker).
|
||||
- Schema migrations are managed with Prisma Migrate (ADR 0006) and run
|
||||
automatically on API startup (`prisma migrate deploy`), so self-hosters
|
||||
update by pulling new images (see `operations.md`).
|
||||
- Binary user uploads (images, attachments) do **not** go into PostgreSQL —
|
||||
they live on a filesystem volume (ADR 0011). Only metadata is stored in the
|
||||
database.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Exactly one stateful service to run, back up, and restore
|
||||
(plus the uploads volume) — backup strategy stays a plain `pg_dump`
|
||||
(ADR 0015).
|
||||
- No Redis: rate limiting, session storage, and pub/sub needs are served by
|
||||
PostgreSQL (sessions table, `LISTEN/NOTIFY` where needed). If horizontal
|
||||
scaling of the collab server ever becomes necessary, introducing Redis
|
||||
pub/sub is a contained change inside `apps/collab`.
|
||||
- Full-text search quality is bounded by PostgreSQL FTS; ADR 0010 keeps the
|
||||
door open for an external engine.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **SQLite**: attractive for tiny self-hosts, but concurrent-write behavior
|
||||
under the collab server's persistence load and the FTS requirements make it
|
||||
risky; supporting two databases doubles the test matrix. Rejected.
|
||||
- **PostgreSQL + Redis + object storage from day one**: standard SaaS stack,
|
||||
but oversized for the target scale and hostile to casual self-hosting.
|
||||
64
docs/architecture/adr/0003-yjs-crdt-collaboration.md
Normal file
64
docs/architecture/adr/0003-yjs-crdt-collaboration.md
Normal file
@ -0,0 +1,64 @@
|
||||
# ADR 0003: Yjs CRDT + Hocuspocus for real-time and offline collaboration
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
The product vision requires: simultaneous editing with live cursor positions
|
||||
of all participants, live-synced input, **and** (decided in kickoff) offline
|
||||
editing with conflict-free merge on reconnect. Expected concurrency is small
|
||||
groups per page. This rules out naive locking and makes Operational
|
||||
Transformation (OT) unattractive: OT needs a central authority and handles
|
||||
offline divergence poorly. CRDTs are the established answer for
|
||||
offline-capable collaborative editing.
|
||||
|
||||
## Decision
|
||||
|
||||
- **Yjs** is the CRDT implementation. Page content is a Yjs document
|
||||
(`Y.Doc`) containing a `Y.XmlFragment` bound to the editor (ADR 0004).
|
||||
- **Hocuspocus** (the Yjs WebSocket server by the TipTap team, MIT) is the
|
||||
collaboration server, running as its own container `apps/collab`:
|
||||
- `onAuthenticate`: validates a short-lived collaboration token (JWT)
|
||||
issued by the API; the token encodes user id, page id, and access level
|
||||
(read-only vs. read-write). No token, no connection.
|
||||
- `onLoadDocument` / `onStoreDocument`: loads and persists document state
|
||||
to PostgreSQL (debounced writes; merged state plus an update log with
|
||||
periodic compaction).
|
||||
- Awareness protocol carries cursor positions, selections, and user
|
||||
display info for the live-cursor UI.
|
||||
- **Offline support** on the client:
|
||||
- `y-indexeddb` persists every opened document locally; edits while
|
||||
disconnected accumulate in IndexedDB and merge automatically on
|
||||
reconnect (CRDT property — no conflict dialogs).
|
||||
- The SPA is installable/cachable as a PWA (service worker caches the app
|
||||
shell) so the editor loads without a network connection.
|
||||
- Read-only permission is enforced server-side: the collab server rejects
|
||||
updates on read-only connections; offline edits by users whose write
|
||||
permission was revoked are rejected at sync time and the client informs
|
||||
the user.
|
||||
- **Cursor display**: TipTap's collaboration-cursor extension renders remote
|
||||
cursors/selections from awareness states.
|
||||
- Version history hooks into this layer via Yjs snapshots (ADR 0013).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Conflict-free merging is guaranteed by construction; no merge UI needed.
|
||||
- Document state in PostgreSQL is binary (Yjs update format). For search,
|
||||
export, and web rendering, the API maintains a derived, plain
|
||||
representation per page (see `data-model.md`, `page_content_cache`),
|
||||
refreshed by the collab server's store hook.
|
||||
- The collab server is stateless apart from in-memory open documents; it can
|
||||
be restarted at any time (clients resync). Horizontal scaling would need
|
||||
sticky routing or Redis pub/sub — out of scope at target size (ADR 0002).
|
||||
- We accept the Yjs storage overhead (tombstones) — mitigated by snapshot
|
||||
compaction (ADR 0013).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Operational Transformation (e.g. ShareDB)**: mature for online-only
|
||||
editing, weak offline story. Rejected because offline is a requirement.
|
||||
- **Automerge**: viable CRDT, but the editor-binding ecosystem
|
||||
(ProseMirror/TipTap) and server tooling around Yjs are significantly more
|
||||
mature.
|
||||
- **Self-written sync protocol**: never a good idea for this problem class.
|
||||
57
docs/architecture/adr/0004-tiptap-editor.md
Normal file
57
docs/architecture/adr/0004-tiptap-editor.md
Normal file
@ -0,0 +1,57 @@
|
||||
# ADR 0004: TipTap (ProseMirror) as the WYSIWYG editor
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
Dorfteich needs a WYSIWYG editor with: collaborative cursors, CRDT binding
|
||||
(ADR 0003), Markdown-friendly copy/paste, image paste from the clipboard,
|
||||
link editing UX (edit URL / open in new tab), and a schema that plugins can
|
||||
extend with custom block types (ADR 0008).
|
||||
|
||||
## Decision
|
||||
|
||||
- **TipTap** (MIT-licensed core, built on ProseMirror) is the editor
|
||||
framework, used with:
|
||||
- `@tiptap/extension-collaboration` — binds the document to Yjs,
|
||||
- `@tiptap/extension-collaboration-cursor` — remote cursors/selections,
|
||||
- standard extensions for headings, lists, tables, code blocks, images,
|
||||
links, task lists.
|
||||
- **Document schema is the source of truth** and is defined centrally in
|
||||
`packages/shared` (node/mark specs), so that editor, server-side
|
||||
rendering/export, and plugin validation agree on what a valid document is.
|
||||
- **Markdown interop**: paste and import/export convert between Markdown and
|
||||
the ProseMirror document model (`prosemirror-markdown`, extended for our
|
||||
custom nodes such as wikilinks). Markdown remains the primary exchange
|
||||
format; the internal format is the ProseMirror/Yjs document, as the vision
|
||||
allows ("internal format follows technical requirements").
|
||||
- **Links**: a bubble menu on links offers "edit URL" and "open in new tab"
|
||||
(the two actions required by the vision).
|
||||
- **Images**: clipboard paste and menu insert both upload through the API
|
||||
(quota-checked, ADR 0011) and insert an image node referencing the stored
|
||||
file — no base64 blobs inside documents.
|
||||
- **Wikilinks**: a custom inline node `wikilink` with `[[` autocomplete,
|
||||
resolved against pages of the current pond; backlinks are indexed
|
||||
server-side (see `data-model.md`).
|
||||
|
||||
## Consequences
|
||||
|
||||
- ProseMirror's schema-based model gives us structural guarantees (valid
|
||||
documents by construction) that plain contenteditable or Markdown-string
|
||||
editors cannot.
|
||||
- Plugin-defined block types register as ProseMirror nodes rendered inside a
|
||||
sandbox (details in `plugin-architecture.md`).
|
||||
- Mobile editing is explicitly out of scope (kickoff decision); we target
|
||||
desktop browsers and accept degraded editing UX on touch devices, while
|
||||
reading stays fully responsive.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Slate, Lexical**: capable editors, but Yjs integration and the extension
|
||||
ecosystem are notably less mature than ProseMirror/TipTap's.
|
||||
- **CodeMirror + Markdown source editing**: excellent for developer wikis but
|
||||
contradicts the WYSIWYG requirement.
|
||||
- **BlockNote** (block editor on TipTap): attractive UX shortcut, but its
|
||||
opinionated block model would constrain our plugin block types and styling
|
||||
requirements; we build on TipTap directly.
|
||||
51
docs/architecture/adr/0005-react-vite-frontend.md
Normal file
51
docs/architecture/adr/0005-react-vite-frontend.md
Normal file
@ -0,0 +1,51 @@
|
||||
# ADR 0005: React + Vite single-page application
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
The frontend hosts a heavily stateful collaborative editor (ADR 0003/0004),
|
||||
a collapsible pond sidebar, admin UIs, and a plugin sandbox host. It must
|
||||
work offline (PWA) and be maintainable by many independent contributors.
|
||||
SEO for wiki content matters only for publicly readable pages; the separate
|
||||
static project site (dorfteich.cloud) covers marketing needs.
|
||||
|
||||
## Decision
|
||||
|
||||
- **React (current stable) + Vite + TypeScript**, shipped as a static
|
||||
single-page application served by the `web` container (nginx serving
|
||||
`dist/`, with SPA fallback to `index.html`).
|
||||
- Routing: React Router. Server state: TanStack Query. Local/UI state:
|
||||
Zustand where component state is not enough. Forms: react-hook-form + Zod
|
||||
schemas from `packages/shared`.
|
||||
- Styling: CSS custom properties + a small utility layer; the visual design
|
||||
is plain and professional per the vision. Pond-level font configuration
|
||||
(ADR 0016) is applied via CSS variables.
|
||||
- PWA: `vite-plugin-pwa` service worker caches the app shell for offline
|
||||
editor startup (works with `y-indexeddb`, ADR 0003).
|
||||
- **Public read-only pages are server-rendered for crawlers only where
|
||||
needed**: the API exposes a plain HTML rendering endpoint per public page
|
||||
(also used for PDF export, ADR 0009). We deliberately avoid SSR frameworks
|
||||
for the app itself.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Simple deployment (static files + API), no Node server for the frontend.
|
||||
- TipTap's React bindings are first-class; the editor integration follows
|
||||
the officially documented path.
|
||||
- The SPA is the only consumer of the REST API, which keeps the API honest
|
||||
as the boundary for self-hosted automation and future integrations.
|
||||
- SEO for public wiki pages relies on the HTML rendering endpoint being
|
||||
served to crawlers via reverse-proxy rules — documented in `deployment.md`
|
||||
and acceptable for a wiki (dorfteich.cloud handles discoverability of the
|
||||
product itself).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **SvelteKit**: excellent framework and used elsewhere in the operator's
|
||||
projects, but the TipTap/Yjs collaborative-editing ecosystem, examples,
|
||||
and collective experience are strongest in React; for a contributor-diverse
|
||||
open-source project the larger ecosystem wins.
|
||||
- **Next.js**: SSR/ISR complexity buys little for an app that is 95% behind a
|
||||
login or served to a small community; PWA/offline is simpler in a pure SPA.
|
||||
54
docs/architecture/adr/0006-nestjs-prisma-backend.md
Normal file
54
docs/architecture/adr/0006-nestjs-prisma-backend.md
Normal file
@ -0,0 +1,54 @@
|
||||
# ADR 0006: NestJS + Prisma for the API server
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
The API server carries most business logic: auth, permission resolution,
|
||||
pond/page/label CRUD, quotas, import/export orchestration, plugin
|
||||
management, e-mail, admin functions. Stories are implemented by many
|
||||
independent contributors in 0.5–2 day slices; the framework must make module
|
||||
boundaries, dependency injection, validation, and testing conventions
|
||||
explicit so parallel work does not collide.
|
||||
|
||||
## Decision
|
||||
|
||||
- **NestJS** (Express adapter) structures `apps/api` into feature modules
|
||||
that mirror the domain: `auth`, `users`, `ponds`, `pages`, `labels`,
|
||||
`permissions`, `search`, `uploads`, `import-export`, `plugins`, `quotas`,
|
||||
`comments`, `notifications`, `admin`, `setup`, `mail`, `health`.
|
||||
- **Prisma** is the ORM: schema-first data model (`schema.prisma` is the
|
||||
single source of truth, mirrored in `data-model.md`), generated type-safe
|
||||
client, `prisma migrate` for migrations (applied automatically at startup,
|
||||
ADR 0002). Raw SQL is allowed where Prisma falls short (FTS queries,
|
||||
ADR 0010; Yjs state upserts).
|
||||
- API style: **REST + JSON** under `/api/v1`, request/response validated
|
||||
with Zod schemas from `packages/shared` (single definition for client and
|
||||
server). OpenAPI document generated from these schemas for documentation.
|
||||
- Cross-cutting rules:
|
||||
- Every route passes an authentication guard and a **permission guard**
|
||||
that calls the shared resolution algorithm (`permissions.md`) — no
|
||||
ad-hoc permission checks inside handlers.
|
||||
- All external side effects (mail, conversion sidecars) go through
|
||||
dedicated injectable services so tests can fake them.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Stories can say "add endpoint X in module Y, guard with permission Z" and
|
||||
be implemented without architectural decisions; NestJS's DI and testing
|
||||
utilities give a uniform unit/e2e test pattern (Vitest + supertest).
|
||||
- Prisma migrations serialize schema changes; stories touching the schema
|
||||
must be sequenced (flagged in issue dependencies).
|
||||
- NestJS adds some boilerplate per module; we accept this for the
|
||||
consistency it buys in a many-contributors setting.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Fastify/Express hand-rolled**: less boilerplate, but every contributor
|
||||
invents structure; consistency would depend on discipline instead of
|
||||
framework rails. Rejected for this team model.
|
||||
- **tRPC**: excellent DX for a closed SPA+API pair, but a REST API is a
|
||||
deliberate product feature for self-hosters and integrations.
|
||||
- **Drizzle**: fine ORM, but Prisma's schema file + migration story is the
|
||||
most widely known and the easiest to review.
|
||||
61
docs/architecture/adr/0007-auth-sessions-oidc-ready.md
Normal file
61
docs/architecture/adr/0007-auth-sessions-oidc-ready.md
Normal file
@ -0,0 +1,61 @@
|
||||
# ADR 0007: Cookie sessions, Argon2id, OIDC-ready identity model
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
Kickoff decisions: self-contained user management (username, e-mail,
|
||||
password) with mandatory e-mail verification (double opt-in), password
|
||||
reset, rate limiting, and the option to disable self-registration per
|
||||
instance. SSO is not in the MVP but the design must allow adding OIDC login
|
||||
later without schema surgery.
|
||||
|
||||
## Decision
|
||||
|
||||
### Authentication
|
||||
|
||||
- **Server-side sessions** stored in PostgreSQL, referenced by an opaque
|
||||
`HttpOnly; Secure; SameSite=Lax` cookie. No JWTs for browser sessions
|
||||
(revocability and simplicity win). Sliding expiration, default 30 days.
|
||||
- Passwords hashed with **Argon2id** (tuned parameters documented in code).
|
||||
- **E-mail flows** (verification, password reset) use single-use, expiring,
|
||||
hashed tokens; mail is sent via SMTP (instance-configured, see setup
|
||||
wizard) through a mail outbox table with retry.
|
||||
- **Rate limiting** on signup, login, password reset, and token endpoints:
|
||||
fixed-window counters in PostgreSQL keyed by IP and by account —
|
||||
no Redis (ADR 0002).
|
||||
- Self-registration can be disabled instance-wide
|
||||
(`instance_settings.registration_mode`: `open` | `closed`). The kickoff
|
||||
also asked for the option of admin approval as a later hardening step; the
|
||||
setting is an enum so `approval_required` can be added without migration
|
||||
pain.
|
||||
- **Collaboration tokens**: the API issues short-lived (≤ 60 s validity for
|
||||
connect) signed JWTs solely for the WebSocket handshake with the collab
|
||||
server (ADR 0003). These are the only JWTs in the system.
|
||||
|
||||
### OIDC readiness (not in MVP)
|
||||
|
||||
- The identity model separates **account** from **login method**:
|
||||
`users` (profile, status) and `user_identities`
|
||||
(`provider` = `password` | future `oidc:<issuer>`, `subject`,
|
||||
`credential`). Password login is just one identity row.
|
||||
- E-mail is unique per user and verified; future OIDC linking matches on
|
||||
verified e-mail or explicit account linking.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Logout and account deactivation are immediate (session rows deleted).
|
||||
- No shared secret sprawl: one signing key for collab tokens, rotated via
|
||||
environment configuration.
|
||||
- Adding OIDC later means: new identity provider rows, an
|
||||
authorization-code flow module, and a login button — no changes to
|
||||
sessions, permissions, or user references.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **JWT access/refresh tokens in the browser**: harder revocation, XSS
|
||||
exposure of tokens, no benefit for a same-origin SPA. Rejected.
|
||||
- **Auth libraries/services (Keycloak, Authentik as mandatory)**: heavy
|
||||
extra container contradicting easy self-hosting; external IdPs remain
|
||||
possible later through the OIDC path.
|
||||
81
docs/architecture/adr/0008-plugin-sandbox.md
Normal file
81
docs/architecture/adr/0008-plugin-sandbox.md
Normal file
@ -0,0 +1,81 @@
|
||||
# ADR 0008: Sandboxed iframe plugins with a message-based API
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
The vision requires plugins that range from simple styling (colored section
|
||||
backgrounds) to complex features (table of contents, page index, diagrams,
|
||||
embedding blocks from other pages), installable at runtime (directory upload
|
||||
or GUI) without redeploying. Kickoff decision: only Site Admins install
|
||||
plugins, and plugins run **sandboxed** — an uploaded plugin must not be able
|
||||
to compromise the server or exfiltrate data beyond what the viewing user may
|
||||
see.
|
||||
|
||||
## Decision
|
||||
|
||||
- **Plugins are client-side packages only** (v1). No plugin code executes on
|
||||
the server. A plugin is a ZIP containing:
|
||||
- `manifest.json` — id, name, version, `apiVersion`, declared extension
|
||||
points, declared permissions, i18n strings;
|
||||
- `plugin.js` — a single ES module bundle;
|
||||
- optional assets (CSS, images).
|
||||
- **Two plugin classes, by trust needs:**
|
||||
1. **Declarative style plugins** — manifest + CSS only, no JavaScript.
|
||||
They define named "section styles" (e.g. colored background boxes)
|
||||
applied as attributes on standard container nodes. No sandbox needed;
|
||||
CSS is served sanitized and scoped.
|
||||
2. **Code plugins** — run inside a **sandboxed `<iframe>`**
|
||||
(`sandbox="allow-scripts"`, **without** `allow-same-origin`, so the
|
||||
frame has an opaque origin: no cookies, no host DOM, no storage).
|
||||
Communication with the host app happens exclusively via `postMessage`
|
||||
RPC defined in `packages/plugin-sdk`.
|
||||
- **Extension points (v1):**
|
||||
- `block`: a custom block node type (registered in the editor schema by
|
||||
the host); the plugin renders/edits the block content inside its iframe
|
||||
(diagram editors, embeds, …). Block data is stored as attributes/content
|
||||
of the node in the page document.
|
||||
- `pageTool`: read-only widgets over page data — table of contents, page
|
||||
index, "embed block from another page". They query data through the
|
||||
plugin API only.
|
||||
- `sectionStyle`: the declarative class above.
|
||||
- **Plugin API & security:** the host mediates every request. Plugins get a
|
||||
capability object scoped to the **viewing user's permissions** — e.g.
|
||||
`listPages(pondId)`, `getPageOutline(pageId)`, `getBlock(pageId, blockId)`
|
||||
return only what the current viewer could read anyway (enforced by the
|
||||
API server, not the client). Network access from the iframe is blocked by
|
||||
CSP; plugins requesting external resources must declare them in the
|
||||
manifest and route them through a host-controlled allowlist (post-v1).
|
||||
- **Lifecycle:** Site Admin uploads via GUI (or drops the ZIP into the
|
||||
`plugins/` volume; a watcher registers it). The API validates the
|
||||
manifest, stores metadata (ADR 0002), and serves the bundle. Site Admin
|
||||
sets each plugin `disabled` / `optional` / `required` per instance;
|
||||
Pond Admins toggle optional plugins per pond. Activation is immediate —
|
||||
no redeploy, clients pick up the plugin list on next page load.
|
||||
- **Versioning:** `apiVersion` in the manifest is checked against the host's
|
||||
supported range; incompatible plugins are refused at install time.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A malicious plugin can, at worst, render nonsense inside its own iframe
|
||||
and read data the current viewer could read anyway — it cannot touch
|
||||
cookies, other pages' DOM, or the server.
|
||||
- Complex "server-ish" features (e.g. scheduled jobs, new storage) are not
|
||||
possible for plugins in v1; they become core features or a future,
|
||||
separately-decided trusted-plugin tier.
|
||||
- Rendering plugin blocks costs one iframe each; acceptable at wiki page
|
||||
scale, and `pageTool` widgets are lazy-loaded.
|
||||
- Exports (PDF/Word) render plugin blocks as their declared static fallback
|
||||
(manifest field `fallback`: image/text) — documented in
|
||||
`plugin-architecture.md`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Trusted server-side plugins (WordPress model)**: maximum power, but one
|
||||
bad upload owns the instance; contradicts the sandbox decision.
|
||||
- **Web Workers as sandbox**: no DOM rendering, which block plugins need;
|
||||
iframes give both isolation and rendering.
|
||||
- **WASM sandbox on the server**: strong isolation for server-side logic,
|
||||
but big complexity budget; revisit only if plugin demand outgrows the
|
||||
client-side model.
|
||||
57
docs/architecture/adr/0009-import-export-converters.md
Normal file
57
docs/architecture/adr/0009-import-export-converters.md
Normal file
@ -0,0 +1,57 @@
|
||||
# ADR 0009: Pandoc + Gotenberg sidecars for import/export
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
Requirements: Markdown as the primary import/export format (including
|
||||
copy/paste), import of Word (`.docx`) and OpenOffice/LibreOffice (`.odt`)
|
||||
documents, export to Word, OpenOffice, and PDF. Kickoff decision: import is
|
||||
**best-effort structural** — headings, paragraphs, lists, tables, images,
|
||||
links, bold/italic are preserved reliably; layout fidelity (columns, text
|
||||
boxes, exact spacing) is explicitly out of scope. Document conversion is
|
||||
CPU-heavy and full of parser edge cases; it must not run inside the Node API
|
||||
process.
|
||||
|
||||
## Decision
|
||||
|
||||
- **Markdown** conversion (both directions, including clipboard paste) is
|
||||
implemented **in-process** in TypeScript via `prosemirror-markdown` with
|
||||
extensions for our custom nodes (wikilinks, section styles). This is the
|
||||
primary, lossless-as-possible path.
|
||||
- **`pandoc-server`** (official pandoc image, HTTP server mode, internal
|
||||
network only) handles:
|
||||
- import: `.docx` / `.odt` → Markdown (+ extracted media, which the API
|
||||
stores as uploads and rewrites to image nodes),
|
||||
- export: Markdown → `.docx` / `.odt` (structure-true best effort).
|
||||
- **Gotenberg** (internal only) handles **PDF export**: the API renders the
|
||||
page to standalone HTML (same renderer as the public read-only HTML
|
||||
endpoint, with the pond's fonts inlined) and sends it to Gotenberg's
|
||||
Chromium route. PDF output is for reading/sharing, not print production.
|
||||
- The API's `import-export` module orchestrates conversions asynchronously
|
||||
(job table + polling endpoint) with size limits and timeouts; sidecar
|
||||
failures degrade gracefully into a user-visible error, never a crash.
|
||||
- Plugin blocks render their manifest-declared static `fallback` in all
|
||||
exports (ADR 0008).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Two extra containers in the Compose stack; both are stateless, official
|
||||
images, and internal-only (no ingress). Self-hosting stays
|
||||
`docker compose up`.
|
||||
- Import fidelity is testable: a fixture corpus of `.docx`/`.odt` files with
|
||||
expected Markdown output lives in the repo (stories reference it).
|
||||
- Copy/paste from Word into the editor goes through the editor's HTML paste
|
||||
handling (structural, same fidelity philosophy), not through pandoc.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **mammoth.js in-process** for docx: good HTML output, but no `.odt`
|
||||
support and no export direction; pandoc covers all four directions with
|
||||
one tool.
|
||||
- **LibreOffice headless for everything**: heavyweight, slower startup,
|
||||
layout-oriented rather than structure-oriented output.
|
||||
- **Browser-print PDF (client-side)**: inconsistent results across clients;
|
||||
server-side Chromium (Gotenberg) gives reproducible PDFs and enables
|
||||
"export without opening the page".
|
||||
49
docs/architecture/adr/0010-postgres-fulltext-search.md
Normal file
49
docs/architecture/adr/0010-postgres-fulltext-search.md
Normal file
@ -0,0 +1,49 @@
|
||||
# ADR 0010: PostgreSQL full-text search behind a search interface
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
Kickoff decisions: capacity target is small-to-medium instances (order of
|
||||
100 ponds / 10,000 pages); search starts with database full-text search but
|
||||
the code must be structured so an external engine can be added later as an
|
||||
optional component, without touching call sites.
|
||||
|
||||
## Decision
|
||||
|
||||
- **PostgreSQL FTS** implements search v1:
|
||||
- Every page has a derived plain-text representation
|
||||
(`page_content_cache.plain_text`, refreshed on document persistence,
|
||||
ADR 0003) with a generated `tsvector` column and GIN index.
|
||||
- Indexed fields with weights: title (A), labels (B), body (C).
|
||||
- Language configuration: `simple` + unaccent by default (mixed
|
||||
German/English content; no stemming surprises), revisitable per
|
||||
instance setting.
|
||||
- **Search results are permission-filtered**: the query joins against the
|
||||
page id set the requesting user may read (computed by the shared
|
||||
permission logic) — no result leakage through snippets. Snippets/highlights
|
||||
via `ts_headline`.
|
||||
- **`SearchProvider` interface** in `apps/api/src/search`:
|
||||
`indexPage(page)`, `removePage(pageId)`, `search(query, scope, userId)`.
|
||||
The PostgreSQL implementation is the default binding; a future
|
||||
Meilisearch/OpenSearch provider is a new binding plus an optional Compose
|
||||
profile — call sites never change. Reindexing is a CLI/admin action
|
||||
(`search:reindex`) defined on the interface from day one.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No extra search container for self-hosters; search works out of the box.
|
||||
- Typo tolerance and fancy ranking are limited — accepted at target scale;
|
||||
the provider interface is the escape hatch.
|
||||
- The plain-text cache also serves export and the public HTML endpoint, so
|
||||
the derivation pipeline is shared and tested once.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Meilisearch from day one**: better UX (typo tolerance), but one more
|
||||
stateful container for every self-host and a second index to back up —
|
||||
disproportionate at target scale.
|
||||
- **Client-side search (lunr/minisearch)**: breaks at pond sizes beyond toy
|
||||
scale and leaks content the user may not read unless carefully scoped;
|
||||
rejected.
|
||||
53
docs/architecture/adr/0011-file-storage-quotas.md
Normal file
53
docs/architecture/adr/0011-file-storage-quotas.md
Normal file
@ -0,0 +1,53 @@
|
||||
# ADR 0011: Filesystem volume for uploads, DB-tracked quotas
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
Pages contain pasted/uploaded images; kickoff added non-image attachments
|
||||
(PDF, office files, …) with a type allowlist and size limits. Self-hosting
|
||||
must not require object storage. Quotas exist on three levels (instance
|
||||
default → per user → per pond, most specific wins) per kickoff decision;
|
||||
storage volume is one of the quota dimensions (kickoff assumption #1,
|
||||
confirmed).
|
||||
|
||||
## Decision
|
||||
|
||||
- **Uploads live on a dedicated Docker volume**, laid out as
|
||||
`uploads/<pondId>/<fileId>` (opaque ids; original filename and metadata in
|
||||
the database). No S3 dependency; the storage access goes through a thin
|
||||
`FileStorage` service so an S3 binding stays possible later.
|
||||
- **Serving**: files are streamed by the API with permission checks (an
|
||||
attachment inherits the permissions of its pond/page); no direct static
|
||||
serving of user uploads. `Content-Disposition` and strict
|
||||
`Content-Type` handling prevent inline execution (see `security.md`).
|
||||
- **Validation on upload**: configurable MIME/extension allowlist (instance
|
||||
setting; images always allowed), size limit per file, magic-byte sniffing
|
||||
for images. SVG uploads are sanitized (script stripping) or rejected per
|
||||
instance setting.
|
||||
- **Quota dimensions** (each on the three-level override ladder):
|
||||
- storage bytes per pond (default: 1 GiB),
|
||||
- max file size (default: 25 MiB),
|
||||
- editors per pond (default 5) and readers per pond (default 50) for
|
||||
self-signup personal ponds,
|
||||
- additional ponds a user may create (default 0).
|
||||
Current usage is tracked in the database (`pond_usage`), updated
|
||||
transactionally with upload/delete; quota exceedance yields a clear,
|
||||
i18n-ed error.
|
||||
- **Orphan cleanup**: deleting a page moves it to trash (ADR 0013); its
|
||||
files are removed when the trash entry is purged. A nightly job reconciles
|
||||
volume contents against the database and reports drift.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Backup must cover the uploads volume in addition to `pg_dump` (ADR 0015).
|
||||
- Multi-node scaling would need shared storage or the S3 binding — out of
|
||||
scope at target size, but not blocked.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Files as bytea in PostgreSQL**: single backup artifact, but bloats the
|
||||
database, slows dumps/restores, and complicates streaming; rejected.
|
||||
- **MinIO/S3 required**: another stateful service for every self-host;
|
||||
rejected for v1, kept possible via the `FileStorage` interface.
|
||||
46
docs/architecture/adr/0012-i18n.md
Normal file
46
docs/architecture/adr/0012-i18n.md
Normal file
@ -0,0 +1,46 @@
|
||||
# ADR 0012: i18next with German and English from the start
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
Kickoff decision: the UI ships with an i18n framework from the beginning,
|
||||
delivered in German and English. Retro-fitting i18n is expensive; two live
|
||||
languages keep the framework honest. E-mails and API error messages face
|
||||
users too.
|
||||
|
||||
## Decision
|
||||
|
||||
- **i18next** (+ `react-i18next`) in the frontend; a lightweight i18next
|
||||
instance in the API for e-mail templates and user-facing error messages.
|
||||
- Translation resources are JSON files per namespace per language under
|
||||
`packages/shared/i18n/<lang>/<namespace>.json`, shared where texts overlap
|
||||
(e.g. validation messages).
|
||||
- Rules for contributors (enforced in review + a CI lint that flags missing
|
||||
keys):
|
||||
- No hard-coded user-facing strings — every string goes through a key.
|
||||
- Every change adds **both** `de` and `en` texts (English is the key
|
||||
fallback language).
|
||||
- Use ICU-style interpolation/plurals via i18next's built-ins; never
|
||||
concatenate translated fragments.
|
||||
- Gender-fair wording in both languages (German: neutral forms or pair
|
||||
forms — e.g. "Bearbeitende", not generic masculine).
|
||||
- Language selection: per-user setting; default from `Accept-Language`;
|
||||
instance default configurable. Public pages render UI chrome in the
|
||||
instance default.
|
||||
- **Content is not translated** — pages have exactly one body; multilingual
|
||||
content management is explicitly out of scope.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Slightly slower story implementation (every UI story touches two language
|
||||
files) — accepted cost for a community-oriented product.
|
||||
- Additional languages later are pure resource additions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **German-only, i18n later**: cheapest now, expensive retrofit, adoption
|
||||
barrier for an international open-source audience. Rejected in kickoff.
|
||||
- **FormatJS/Lingui**: comparable capability; i18next chosen for ubiquity
|
||||
and the simplest mental model for contributors.
|
||||
68
docs/architecture/adr/0013-versioning-and-trash.md
Normal file
68
docs/architecture/adr/0013-versioning-and-trash.md
Normal file
@ -0,0 +1,68 @@
|
||||
# ADR 0013: Page version history via Yjs snapshots, soft-delete trash
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
Kickoff decision: version history (who changed what, view and restore old
|
||||
versions) and a trash (soft delete) are core product features, designed in
|
||||
from the start because retrofitting them into a CRDT data model is costly.
|
||||
Yjs (ADR 0003) stores documents as update logs with tombstones, which gives
|
||||
natural hooks for both.
|
||||
|
||||
## Decision
|
||||
|
||||
### Version history
|
||||
|
||||
- **Automatic versions**: the collab server creates a version snapshot when
|
||||
a page's editing session ends (last participant disconnects) or after a
|
||||
configurable active-editing interval (default 30 min), skipping no-op
|
||||
periods.
|
||||
- **Named versions**: editors can create a version explicitly with a label
|
||||
("before restructuring").
|
||||
- Storage: `page_versions` rows hold a full encoded Yjs state
|
||||
(`bytea`), created-at, trigger (auto/manual/pre-restore), label, and the
|
||||
set of contributing user ids since the previous version (derived from Yjs
|
||||
update metadata).
|
||||
- **Viewing**: read-only render of any version + a text-level diff against
|
||||
the current version (diff computed on the derived plain/Markdown
|
||||
representation — good enough for "what changed", no structural diff UI in
|
||||
v1).
|
||||
- **Restore**: restoring creates a *new* state on top of history (the
|
||||
restored content is applied as a regular update, preceded by an automatic
|
||||
"pre-restore" snapshot). History is append-only; nothing is rewritten.
|
||||
- **Compaction**: to bound Yjs update-log growth, the persistence layer
|
||||
periodically merges the update log into the current state vector; version
|
||||
snapshots are self-contained, so compaction never loses restorable
|
||||
history. Retention of auto-versions is configurable (default: keep all for
|
||||
90 days, then thin to daily).
|
||||
|
||||
### Trash (soft delete)
|
||||
|
||||
- Deleting a page sets `deleted_at` (+ who deleted it); the page disappears
|
||||
from sidebar, search, links (backlinks show a "deleted" hint), and the
|
||||
collab server refuses new sessions on it.
|
||||
- Pond Admins and the deleting editor see the pond's trash, can restore or
|
||||
purge. Auto-purge after a configurable retention (default 30 days) —
|
||||
purging deletes document state, versions, and files (ADR 0011).
|
||||
- Deleting a whole pond follows the same pattern at pond level
|
||||
(Site-Admin-visible trash, same retention).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Version storage costs extra database volume; snapshot thinning and
|
||||
compaction keep it bounded and are covered by explicit stories.
|
||||
- "Who changed what" is per-version granularity (contributor set), not
|
||||
per-keystroke attribution — deliberate simplification for v1.
|
||||
- The permission model needs one extra rule: viewing history/trash requires
|
||||
the same permission as editing the page (see `permissions.md`).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Event-sourcing every update forever, no compaction**: unbounded growth;
|
||||
rejected.
|
||||
- **Storing Markdown snapshots instead of Yjs states**: smaller, but restore
|
||||
would lose structure/plugin blocks and break the CRDT continuity;
|
||||
rejected (Markdown diffs are still used for the *display* layer).
|
||||
- **Hard delete only**: data-loss risk contradicts kickoff decision.
|
||||
56
docs/architecture/adr/0014-gitea-actions-cicd.md
Normal file
56
docs/architecture/adr/0014-gitea-actions-cicd.md
Normal file
@ -0,0 +1,56 @@
|
||||
# ADR 0014: CI/CD with Gitea Actions, staged promotion
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
The project is hosted on a self-managed Gitea (`gitea.101010.cloud`).
|
||||
Environments (kickoff): Dev runs locally on contributors' machines;
|
||||
Test, Int, and Prod run as separate Compose stacks on the LEISINGER host
|
||||
initially, with Prod moving to a dedicated host at go-live (architecture
|
||||
must keep that move cheap). Contributors include AI coding sessions —
|
||||
gates must be automated and objective wherever possible.
|
||||
|
||||
## Decision
|
||||
|
||||
- **Gitea Actions** is the CI/CD system (GitHub-Actions-compatible syntax);
|
||||
an act_runner runs on LEISINGER with Docker access.
|
||||
- **Images** are built once per change and promoted, never rebuilt per
|
||||
stage: pushed to the **Gitea container registry**
|
||||
(`gitea.101010.cloud/stwaidele/dorfteich-{web,api,collab}`), tagged with
|
||||
the git SHA plus moving tags `test`, `int`, and semver tags for releases.
|
||||
- **Pipeline** (details in `deployment.md`):
|
||||
1. **PR / push**: lint, typecheck, unit tests, build; PRs must be green to
|
||||
merge into `main`.
|
||||
2. **Merge to `main`**: build + push images (SHA tag) → deploy to
|
||||
**Test** automatically → run the Playwright e2e suite against Test.
|
||||
3. **Promotion to Int**: automatic when e2e on Test is green — Int is the
|
||||
stable preview environment (same images, `int` tag).
|
||||
4. **Promotion to Prod**: **manual gate** — creating a release tag
|
||||
(`vX.Y.Z`) triggers the Prod deploy after a required manual approval
|
||||
in the workflow. Database migrations run automatically on container
|
||||
start (ADR 0006); releases with breaking migrations must say so in the
|
||||
release notes.
|
||||
- **Deploy mechanism**: the runner executes `docker compose pull && docker
|
||||
compose up -d` in the stage's directory (`/home/DOCKER/dorfteich-<stage>/`)
|
||||
over SSH (deploy key per stage). Moving Prod to a dedicated host later
|
||||
changes only that SSH target.
|
||||
- **Self-hosters** consume the same release images via a published
|
||||
`docker-compose.yml` pinned to semver tags (update strategy in
|
||||
`operations.md`).
|
||||
|
||||
## Consequences
|
||||
|
||||
- One objective quality bar (green e2e on Test) decides Int promotion; the
|
||||
only human gate is the Prod release — matching the risk profile.
|
||||
- The registry, runner, and repo live on the same Gitea — no third-party CI
|
||||
dependency.
|
||||
- e2e stability becomes load-bearing; flaky tests block the pipeline and
|
||||
must be treated as defects (stated in the contributor conventions).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **GitHub Actions with repo mirror**: splits source of truth; rejected.
|
||||
- **Manual deploys per stage**: does not scale to many small stories and
|
||||
invites drift between stages.
|
||||
57
docs/architecture/adr/0015-backup-strategy.md
Normal file
57
docs/architecture/adr/0015-backup-strategy.md
Normal file
@ -0,0 +1,57 @@
|
||||
# ADR 0015: Nightly pg_dump + uploads sync, 30-day retention, off-host mirror
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
The operator's standard for database-driven apps applies (global
|
||||
convention): nightly dump via sidecar, ~30 days local retention, manual
|
||||
download/restore via admin UI, mirroring of nightly dumps to the BASEL host
|
||||
over WireGuard (`/home/RAID/BACKUPS/<app>/`). Dorfteich adds a second
|
||||
persistence root: the uploads/plugins volume (ADR 0011).
|
||||
|
||||
## Decision
|
||||
|
||||
- **What is backed up** (together forming a consistent restore set):
|
||||
1. PostgreSQL: nightly `pg_dump -Fc` from a backup sidecar container.
|
||||
2. Uploads + plugins volume: nightly `tar` archive (or rsync snapshot)
|
||||
taken **after** the dump, referencing the same backup id.
|
||||
- **Retention**: 30 days locally (both artifact types), pruned by the
|
||||
sidecar.
|
||||
- **Mirroring**: nightly rsync of the local backup directory to BASEL,
|
||||
target `/home/RAID/BACKUPS/dorfteich-<stage>/`, same retention. A
|
||||
dedicated `dorfteich-backup` user with home under `/home/` is created on
|
||||
BASEL (per the operator's "cleaner alternative" note — not reusing the
|
||||
Debian `backup` system user, avoiding its UID-34/home-path pitfalls).
|
||||
- **Scope per stage**: Prod is fully backed up + mirrored. Test/Int get the
|
||||
nightly dump with 7-day local retention and **no** off-host mirror
|
||||
(reproducible from Prod data or fixtures). This is the deliberate,
|
||||
ADR-documented deviation from the standard pattern for non-Prod stages.
|
||||
- **Restore paths**:
|
||||
- Documented CLI runbook (`operations.md`): stop stack → restore dump via
|
||||
`pg_restore` → restore volume archive → start stack. Practiced against
|
||||
Test in a recurring story ("restore drill").
|
||||
- **Admin UI**: Site Admin can download the latest dumps and trigger an
|
||||
on-demand backup. Restore stays a CLI operation (a web-triggered restore
|
||||
of the database that serves the web UI is a foot-gun).
|
||||
- **Integrity/alerting**: the sidecar writes a status file consumed by the
|
||||
health endpoint; a failed or missing nightly backup raises an alert
|
||||
(see `operations.md`). Dump restorability is verified monthly by an
|
||||
automated restore into a scratch database on Test.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Consistency between database and file volume is "nightly point-in-time,
|
||||
files may be minutes newer" — acceptable: a restored page referencing a
|
||||
file uploaded after the dump shows a missing image, never corruption.
|
||||
- Moving Prod to a dedicated host keeps the identical sidecar; only the
|
||||
WireGuard/rsync route changes (ADR 0014 consequence applies).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **WAL archiving / PITR (e.g. pgBackRest)**: better RPO, but operationally
|
||||
heavier than the standard pattern warrants at this scale; revisit if
|
||||
Dorfteich.online grows.
|
||||
- **Volume backup via S3-compatible offsite**: no existing infrastructure;
|
||||
BASEL mirror is the established pattern.
|
||||
47
docs/architecture/adr/0016-self-hosted-fonts.md
Normal file
47
docs/architecture/adr/0016-self-hosted-fonts.md
Normal file
@ -0,0 +1,47 @@
|
||||
# ADR 0016: Self-hosted Google Fonts, per-pond font configuration
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
The vision: Pond Admins choose fonts for headings, body text, and monospace
|
||||
from a set of free Google Fonts; fonts must be served from the instance
|
||||
itself (never from Google's CDN) to avoid GDPR issues. Defaults: Roboto 400
|
||||
(headings), Roboto 200 (body), Fira Code (monospace).
|
||||
|
||||
## Decision
|
||||
|
||||
- **Curated font catalog**: the repo contains a maintained list (~15–25
|
||||
families) of OFL/Apache-licensed families with the needed weights. A build
|
||||
step (`deploy/fonts/`) downloads the WOFF2 files **at image build time**
|
||||
from google-webfonts-helper/upstream sources and bakes them into the `web`
|
||||
image under `/fonts/<family>/`. No runtime download, no third-party
|
||||
requests from visitors' browsers — CSP allows `font-src 'self'` only.
|
||||
- **Per-pond configuration**: pond settings store three font slots
|
||||
(`heading`, `body`, `mono`), each referencing a catalog entry + weight.
|
||||
The app applies them as CSS custom properties
|
||||
(`--font-heading`, `--font-body`, `--font-mono`) on the pond's root
|
||||
element; `@font-face` rules for the catalog are generated once.
|
||||
- Defaults per vision: Roboto 400 / Roboto 200 / Fira Code. (Roboto 200 is
|
||||
provided via the variable font or the 200 static weight; fallback stack
|
||||
`system-ui` chain.)
|
||||
- Licensing: each catalog entry records its license (OFL/Apache); the
|
||||
catalog page in the app shows attribution.
|
||||
- Exports: the PDF renderer (ADR 0009) inlines the pond's fonts so PDFs
|
||||
match the on-screen look.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Adding a font is a catalog PR + image rebuild — no runtime font
|
||||
management surface (deliberately small attack/complexity surface).
|
||||
- Image size grows by a few MiB per family (WOFF2, subset to latin/latin-ext
|
||||
by default) — negligible.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Runtime font download by the server on admin selection**: flexible but
|
||||
adds an outbound dependency, cache invalidation, and licensing bookkeeping
|
||||
at runtime; rejected for v1.
|
||||
- **Arbitrary font upload by Pond Admins**: licensing risk and file-format
|
||||
attack surface; may become a Site-Admin-level feature later.
|
||||
180
docs/architecture/data-model.md
Normal file
180
docs/architecture/data-model.md
Normal file
@ -0,0 +1,180 @@
|
||||
# Data model
|
||||
|
||||
Authoritative once implemented in `apps/api/prisma/schema.prisma`; this
|
||||
document explains the entities and their intent. Naming below uses the
|
||||
English terms (pond = Teich).
|
||||
|
||||
## Overview
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
users ||--o{ user_identities : "logs in via"
|
||||
users ||--o{ sessions : has
|
||||
users ||--o{ ponds : "created"
|
||||
ponds ||--o{ pages : contains
|
||||
ponds ||--o{ labels : defines
|
||||
labels o|--o{ labels : "parent of"
|
||||
pages }o--o{ labels : "tagged with"
|
||||
pages ||--o{ page_versions : "has history"
|
||||
pages ||--|| page_content_cache : "derived text"
|
||||
pages ||--o{ page_links : "links to"
|
||||
pages ||--o{ attachments : has
|
||||
ponds ||--o{ attachments : owns
|
||||
users ||--o{ role_grants : "subject of"
|
||||
ponds ||--o{ role_grants : "scoped to"
|
||||
plugins ||--o{ pond_plugins : "activated in"
|
||||
ponds ||--o{ pond_plugins : activates
|
||||
pages ||--o{ comments : has
|
||||
users ||--o{ notifications : receives
|
||||
users ||--o{ watches : sets
|
||||
```
|
||||
|
||||
## Identity and access
|
||||
|
||||
### `users`
|
||||
| Column | Notes |
|
||||
| --- | --- |
|
||||
| `id` (uuid) | |
|
||||
| `username` | unique, URL-safe |
|
||||
| `email` | unique, stored verified/unverified with `email_verified_at` |
|
||||
| `display_name` | shown at cursors, comments |
|
||||
| `locale` | UI language (ADR 0012) |
|
||||
| `is_site_admin` | boolean; Site Admin is a user flag, not a grant |
|
||||
| `status` | `active` / `disabled` / `pending_verification` |
|
||||
| `created_at`, `last_login_at` | |
|
||||
|
||||
### `user_identities` (ADR 0007)
|
||||
`user_id`, `provider` (`password` now; `oidc:<issuer>` later), `subject`,
|
||||
`credential` (Argon2id hash for `password`), unique on
|
||||
(`provider`, `subject`).
|
||||
|
||||
### `sessions`
|
||||
Opaque id (hashed), `user_id`, `created_at`, `expires_at`, `last_seen_at`,
|
||||
user-agent summary (for "active sessions" UI).
|
||||
|
||||
### `auth_tokens`
|
||||
Single-use tokens for e-mail verification and password reset: hashed token,
|
||||
`purpose`, `expires_at`, `consumed_at`.
|
||||
|
||||
### `role_grants` — the permission table (see `permissions.md`)
|
||||
| Column | Notes |
|
||||
| --- | --- |
|
||||
| `id` | |
|
||||
| `pond_id` | every grant belongs to exactly one pond |
|
||||
| `subject_type` | `user` / `authenticated` / `public` |
|
||||
| `subject_id` | user id when `subject_type = user`, else null |
|
||||
| `role` | `pond_admin` / `editor` / `reader` |
|
||||
| `scope_type` | `pond` / `label` / `page` |
|
||||
| `scope_id` | label id or page id when scoped, else null |
|
||||
| `effect` | `allow` / `deny` |
|
||||
| `created_by`, `created_at` | audit |
|
||||
|
||||
Unique on (`pond_id`, `subject_type`, `subject_id`, `role`, `scope_type`,
|
||||
`scope_id`). `pond_admin` grants are only valid with `scope_type = pond`
|
||||
and `subject_type = user`.
|
||||
|
||||
## Content
|
||||
|
||||
### `ponds`
|
||||
| Column | Notes |
|
||||
| --- | --- |
|
||||
| `id`, `slug` (unique), `name`, `description` | |
|
||||
| `type` | `personal` (one per user, from self-signup) / `shared` |
|
||||
| `owner_id` | creator; personal ponds: the user it belongs to |
|
||||
| `settings` (jsonb) | fonts (ADR 0016), sidebar sort mode (`alpha` / `created` / `manual`), default page permissions |
|
||||
| `deleted_at`, `deleted_by` | pond-level trash (ADR 0013) |
|
||||
|
||||
### `pages`
|
||||
| Column | Notes |
|
||||
| --- | --- |
|
||||
| `id`, `pond_id` | |
|
||||
| `title` | also indexed for search weight A |
|
||||
| `slug` | unique per pond, for stable URLs and wikilink resolution |
|
||||
| `ydoc_state` (bytea) | current merged Yjs state (ADR 0003) |
|
||||
| `ydoc_updates` | append log table `page_updates(page_id, seq, update bytea)`, compacted periodically |
|
||||
| `sort_key` | manual sidebar ordering (fractional indexing) |
|
||||
| `created_by`, `created_at`, `updated_at` | |
|
||||
| `deleted_at`, `deleted_by` | trash |
|
||||
|
||||
### `page_content_cache`
|
||||
One row per page, refreshed on persistence: `plain_text`, `markdown`,
|
||||
`html`, `outline` (headings JSON, for TOC plugins), generated `tsvector`
|
||||
column with GIN index (ADR 0010).
|
||||
|
||||
### `page_versions` (ADR 0013)
|
||||
`page_id`, `ydoc_snapshot` (bytea, self-contained), `trigger`
|
||||
(`auto` / `manual` / `pre_restore`), `label`, `contributor_ids`,
|
||||
`created_at`.
|
||||
|
||||
### `labels`
|
||||
`pond_id`, `name`, `parent_id` (nullable — hierarchy), `color`, unique on
|
||||
(`pond_id`, `parent_id`, `name`). Cycles are rejected at write time.
|
||||
`page_labels(page_id, label_id)` is the assignment table.
|
||||
|
||||
### `page_links`
|
||||
Wikilink index maintained on persistence: `from_page_id`, `to_page_id`
|
||||
(nullable when target does not exist yet — "phantom" links), `target_slug`.
|
||||
Backlinks = query by `to_page_id`. Creating a page with a phantom-linked
|
||||
slug resolves those rows.
|
||||
|
||||
### `attachments` (ADR 0011)
|
||||
`id`, `pond_id`, `page_id` (nullable — pond-level files), `file_name`,
|
||||
`mime_type`, `size_bytes`, `storage_path`, `uploaded_by`, `created_at`,
|
||||
`deleted_at`.
|
||||
|
||||
## Plugins (ADR 0008)
|
||||
|
||||
### `plugins`
|
||||
`id` (manifest id), `version`, `manifest` (jsonb), `storage_path`,
|
||||
`kind` (`code` / `section_style`), `instance_mode`
|
||||
(`disabled` / `optional` / `required`), `installed_by`, `installed_at`.
|
||||
|
||||
### `pond_plugins`
|
||||
(`pond_id`, `plugin_id`, `enabled`) — only meaningful for `optional`
|
||||
plugins; `required` plugins are active everywhere.
|
||||
|
||||
## Quotas and settings
|
||||
|
||||
### `instance_settings`
|
||||
Key-value (typed JSON) singleton set: registration mode, SMTP config
|
||||
(secrets referenced from env, not stored plaintext — see `security.md`),
|
||||
default quotas, upload allowlist, legal pages content (imprint, privacy),
|
||||
instance default locale.
|
||||
|
||||
### `quota_overrides`
|
||||
`subject_type` (`user` / `pond`), `subject_id`, `quota_key`
|
||||
(`editors_per_pond`, `readers_per_pond`, `additional_ponds`,
|
||||
`storage_bytes`, `max_file_bytes`), `value`. Resolution: pond override →
|
||||
user override → instance default (most specific wins, mirroring the
|
||||
permission philosophy).
|
||||
|
||||
### `pond_usage`
|
||||
Cached counters per pond: `storage_bytes_used`, `editor_count`,
|
||||
`reader_count` — updated transactionally, reconciled nightly.
|
||||
|
||||
## Collaboration support
|
||||
|
||||
- `collab_tokens` are **not** stored — they are short-lived signed JWTs
|
||||
(ADR 0007).
|
||||
- `mail_outbox`: pending/sent e-mails with retry state (ADR 0002 — no
|
||||
broker).
|
||||
- `jobs`: conversion jobs for import/export (ADR 0009) and maintenance jobs
|
||||
(compaction, trash purge, quota reconciliation) with status + timestamps.
|
||||
|
||||
## Comments & notifications (later milestone)
|
||||
|
||||
- `comments`: `page_id`, `author_id`, `body` (Markdown), `anchor`
|
||||
(optional serialized position), `resolved_at`, `created_at`, thread via
|
||||
`parent_id`.
|
||||
- `watches`: (`user_id`, `target_type` `page`/`pond`, `target_id`).
|
||||
- `notifications`: `user_id`, `type`, `payload` (jsonb), `created_at`,
|
||||
`read_at`; delivered in-app, optionally by e-mail digest.
|
||||
|
||||
## Deliberate non-entities
|
||||
|
||||
- **No `organizations`/`teams`** — ponds + grants cover the vision; groups
|
||||
can be added as a new `subject_type` in `role_grants` without migration
|
||||
pain.
|
||||
- **No content translations** (ADR 0012).
|
||||
- **No per-keystroke authorship** — contributor granularity is the version
|
||||
snapshot (ADR 0013).
|
||||
101
docs/architecture/deployment.md
Normal file
101
docs/architecture/deployment.md
Normal file
@ -0,0 +1,101 @@
|
||||
# Deployment architecture
|
||||
|
||||
Four stages, one Compose definition. Foundational decisions: ADR 0014
|
||||
(CI/CD), ADR 0015 (backup), kickoff topology decision (Dev local; Test, Int,
|
||||
Prod on the LEISINGER host initially; Prod moves to a dedicated host at
|
||||
go-live).
|
||||
|
||||
## The Compose stack
|
||||
|
||||
Every stage (and every self-hosted instance) runs the same services:
|
||||
|
||||
| Service | Image | Notes |
|
||||
| --- | --- | --- |
|
||||
| `web` | `dorfteich-web` | nginx: SPA assets, fonts; SPA fallback routing |
|
||||
| `api` | `dorfteich-api` | NestJS; runs `prisma migrate deploy` on start |
|
||||
| `collab` | `dorfteich-collab` | Hocuspocus WebSocket server |
|
||||
| `db` | `postgres:<pinned>` | volume `db-data` |
|
||||
| `pandoc` | `pandoc/core:<pinned>` (server mode) | internal only |
|
||||
| `gotenberg` | `gotenberg/gotenberg:<pinned>` | internal only |
|
||||
| `backup` | `dorfteich-backup` | cron sidecar: pg_dump, volume archive, prune, mirror (ADR 0015) |
|
||||
|
||||
Volumes: `db-data`, `uploads` (uploads + installed plugins), `backups`.
|
||||
Networks: `frontend` (reverse proxy ↔ web/api/collab) and `internal`
|
||||
(api/collab ↔ db/pandoc/gotenberg); db and converters are never exposed.
|
||||
|
||||
Ingress is a host-level reverse proxy (existing Caddy/Traefik/nginx on the
|
||||
host), routing:
|
||||
|
||||
```
|
||||
/ → web
|
||||
/api/ → api
|
||||
/collab → collab (WebSocket upgrade required)
|
||||
/media/ → api (permission-checked file streaming)
|
||||
```
|
||||
|
||||
Self-hosters without a proxy can enable the optional `caddy` Compose profile
|
||||
(bundled Caddy with automatic TLS).
|
||||
|
||||
## Stages
|
||||
|
||||
| Stage | Where | Domain | Purpose | Data |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Dev** | contributor machine | `localhost` | feature work; hot reload via `compose.dev.yml` overlay (source mounts, vite dev server) | fixtures/seed script |
|
||||
| **Test** | LEISINGER, `/home/DOCKER/dorfteich-test/` | `dorfteich-test.101010.cloud` | auto-deploy target of `main`; e2e suite runs here | reset-able; seeded |
|
||||
| **Int** | LEISINGER, `/home/DOCKER/dorfteich-int/` | `dorfteich-int.101010.cloud` | stable preview; manual/exploratory testing; release candidates | persistent test data |
|
||||
| **Prod** | LEISINGER initially → dedicated host at go-live | `dorfteich.online` | public flagship instance | real data; full backup + mirror |
|
||||
|
||||
Stage layout follows the operator's Docker host convention:
|
||||
compose file + `.env` under `/home/DOCKER/dorfteich-<stage>/`, bulk data
|
||||
volumes under `/home/RAID/DOCKER/dorfteich-<stage>/` (bind-mounted).
|
||||
|
||||
**Prod relocation readiness** (kickoff requirement): all state lives in the
|
||||
three volumes + `.env`; the documented move procedure is: stop stack →
|
||||
final backup → restore backup set on the new host → switch DNS. The backup
|
||||
sidecar's restore runbook doubles as the migration procedure, and Test
|
||||
restore drills (ADR 0015) keep it honest.
|
||||
|
||||
## Configuration
|
||||
|
||||
- One `.env` per stage (never in git; `.env.example` in the repo documents
|
||||
every variable): database credentials, `APP_BASE_URL`, collab token
|
||||
signing key, SMTP settings, stage name shown in the UI for non-Prod.
|
||||
- First-run **setup wizard** (kickoff decision): when the API starts against
|
||||
an empty database it exposes only `/setup` (create Site Admin account,
|
||||
SMTP, instance name/locale, registration mode); the wizard locks itself
|
||||
after completion. `.env` can pre-seed these for automated deploys
|
||||
(Test/Int use exactly that).
|
||||
|
||||
## Pipeline (ADR 0014, concrete)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
PR[PR: lint + typecheck + unit + build] -->|merge| M[main]
|
||||
M --> B[build images :sha]
|
||||
B --> DT[deploy Test]
|
||||
DT --> E2E[Playwright e2e vs Test]
|
||||
E2E -->|green| DI[deploy Int - tag int]
|
||||
DI --> REL{manual: tag vX.Y.Z + approval}
|
||||
REL --> DP[deploy Prod - semver tag]
|
||||
```
|
||||
|
||||
- Deploy jobs SSH into the stage directory and run
|
||||
`docker compose pull && docker compose up -d`; migrations apply on api
|
||||
start. Rollback = re-deploy the previous tag (migrations must be
|
||||
backward-compatible one release back — contributor rule for schema
|
||||
stories).
|
||||
- The e2e suite is the Int-promotion gate; flaky tests are defects.
|
||||
- Release notes are generated from merged PR titles; releases with
|
||||
data-affecting migrations are labeled `migration` and called out.
|
||||
|
||||
## Self-hosting distribution
|
||||
|
||||
- Published artifacts per release: versioned images in the Gitea registry
|
||||
(mirrored to a public registry at first public release), a reference
|
||||
`docker-compose.yml` + `.env.example`, and the install/update/backup
|
||||
guide (`docs/self-hosting/`, written as part of the docs milestone).
|
||||
- Minimum requirements: Docker + Compose, 2 GB RAM, a domain (TLS via own
|
||||
proxy or the `caddy` profile). Setup = compose up + browser wizard.
|
||||
- Updates: `docker compose pull && up -d` on a new semver tag; migrations
|
||||
run automatically; the release notes flag anything manual. Downgrades are
|
||||
supported one release back.
|
||||
86
docs/architecture/operations.md
Normal file
86
docs/architecture/operations.md
Normal file
@ -0,0 +1,86 @@
|
||||
# Operations concept
|
||||
|
||||
Pragmatic monitoring (kickoff decision): health checks, external uptime
|
||||
monitoring, structured logs, backup alerting — no dedicated metrics stack.
|
||||
|
||||
## Health & monitoring
|
||||
|
||||
- **Health endpoints**: `api` exposes `/healthz` (liveness: process up) and
|
||||
`/readyz` (readiness: DB reachable, migrations applied, converters
|
||||
reachable, backup freshness < 26 h). `collab` exposes `/healthz`
|
||||
(process + DB). `web` serves a static `/healthz`.
|
||||
- **Docker healthchecks** on every service (compose `healthcheck:`), so
|
||||
`docker compose ps` and restarts reflect real state;
|
||||
`restart: unless-stopped` everywhere.
|
||||
- **External uptime monitoring**: the operator's existing Uptime-Kuma
|
||||
monitors `https://dorfteich.online/healthz` (web), `/api/v1/healthz`, and
|
||||
a WebSocket check on `/collab`, with notification on failure. Test/Int
|
||||
get web-check-only monitors (no paging).
|
||||
- **Backup alerting**: the backup sidecar writes
|
||||
`backups/status.json` after every run; `readyz` degrades when the last
|
||||
successful backup is older than 26 h, which surfaces through Uptime-Kuma
|
||||
without extra tooling. Additionally the sidecar sends a failure e-mail
|
||||
via the instance SMTP.
|
||||
|
||||
## Logging
|
||||
|
||||
- All services log **structured JSON to stdout** (pino); Docker's json-file
|
||||
driver with rotation (`max-size: 10m`, `max-file: 5`).
|
||||
- Log content rules: request logs with method/route/status/duration/user id
|
||||
(no request bodies), auth events (login success/failure, permission
|
||||
denials), admin actions (grants, plugin installs, quota changes) as an
|
||||
**audit trail**, collab session open/close. Never log passwords, tokens,
|
||||
session ids, or page content.
|
||||
- Reading logs = `docker compose logs` / `docker logs` on the host; no
|
||||
central log stack at this scale (revisit if a second Prod host appears).
|
||||
|
||||
## Backup & restore (operational view of ADR 0015)
|
||||
|
||||
- Nightly at 03:00 stage-local time: `pg_dump -Fc` → uploads/plugins volume
|
||||
archive → prune (> 30 days Prod, > 7 days Test/Int) → rsync mirror to
|
||||
BASEL `/home/RAID/BACKUPS/dorfteich-prod/` (Prod only, dedicated
|
||||
`dorfteich-backup` user).
|
||||
- **Restore runbook** (also the Prod-relocation procedure):
|
||||
1. `docker compose down` (keep volumes),
|
||||
2. restore DB: `pg_restore --clean --if-exists` into the `db` container,
|
||||
3. restore volume: unpack the matching uploads archive,
|
||||
4. `docker compose up -d`, verify `/readyz`, spot-check a page + a file.
|
||||
- **Drills**: monthly automated restore of the latest Prod dump into a
|
||||
scratch database on Test with a row-count sanity report; quarterly manual
|
||||
full-runbook drill on Test.
|
||||
- **Admin UI**: Site Admin can download the latest dump/archive and trigger
|
||||
an on-demand backup run (ADR 0015 — restore stays CLI-only).
|
||||
|
||||
## Maintenance jobs (in-app scheduler, `jobs` table)
|
||||
|
||||
| Job | Cadence | Purpose |
|
||||
| --- | --- | --- |
|
||||
| trash purge | daily | delete pages/ponds past trash retention (ADR 0013) |
|
||||
| version thinning | daily | auto-version retention policy (ADR 0013) |
|
||||
| update-log compaction | hourly, idle pages only | bound Yjs log growth |
|
||||
| quota reconciliation | nightly | recompute `pond_usage`, report drift |
|
||||
| orphan file sweep | nightly | volume ↔ DB consistency (ADR 0011) |
|
||||
| mail outbox retry | every minute | e-mail delivery with backoff |
|
||||
|
||||
Job outcomes are visible in the Site Admin UI (last run, status) — that
|
||||
panel is the operator's single glance for instance health.
|
||||
|
||||
## Update strategy
|
||||
|
||||
- **Own stages**: pipeline-driven (see `deployment.md`); Prod only via
|
||||
approved release tags.
|
||||
- **Self-hosters**: semver releases; `docker compose pull && up -d`;
|
||||
migrations auto-apply; release notes flag manual steps and `migration`
|
||||
label. Supported downgrade window: one minor release.
|
||||
- **Base image / dependency hygiene**: monthly dependency-update story
|
||||
(renovate-style batch PR); security advisories for pinned images tracked
|
||||
in the release checklist.
|
||||
|
||||
## Capacity & limits (initial values, instance-tunable)
|
||||
|
||||
| Limit | Default |
|
||||
| --- | --- |
|
||||
| max page document size | 5 MiB Yjs state |
|
||||
| max upload size | 25 MiB (quota ladder, ADR 0011) |
|
||||
| collab connections per instance | 500 concurrent |
|
||||
| rate limits | login 10/min/IP, signup 5/h/IP, API 100/min/user |
|
||||
107
docs/architecture/permissions.md
Normal file
107
docs/architecture/permissions.md
Normal file
@ -0,0 +1,107 @@
|
||||
# Permission model — "the most specific setting wins"
|
||||
|
||||
This document defines the authoritative semantics of roles and grants. The
|
||||
resolution algorithm is implemented **once** in
|
||||
`packages/shared/src/permissions/` and used by the API (guards), the collab
|
||||
server (token issuance), and the frontend (UI affordances only — the client
|
||||
never enforces security).
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Scope of existence | Capabilities |
|
||||
| --- | --- | --- |
|
||||
| **Site Admin** | instance (user flag) | everything: instance settings, users, quotas, plugins (install + set `disabled`/`optional`/`required`), all ponds' administration, legal pages, backups |
|
||||
| **Pond Admin** | per pond (grant) | manage the pond: settings/fonts, labels, members and their grants, enable/disable optional plugins, trash, quotas view; implies Editor everywhere in the pond |
|
||||
| **Editor** | per pond/label/page (grant) | create/edit/delete pages within the granted scope; view history, restore versions, use trash for pages they may edit |
|
||||
| **Reader** | per pond/label/page (grant) | read pages within the granted scope; no history access |
|
||||
| **Public** | pseudo-subject | what non-authenticated visitors may read (never write) |
|
||||
|
||||
Additional structural rules:
|
||||
|
||||
- Personal ponds (self-signup) have exactly **one** Pond Admin — the owner;
|
||||
additional `pond_admin` grants are rejected there. Shared ponds may have
|
||||
several.
|
||||
- `pond_admin` grants exist only at pond scope. Editor/Reader grants exist
|
||||
at pond, label, or page scope.
|
||||
- Site Admin bypasses resolution entirely.
|
||||
|
||||
## Grants
|
||||
|
||||
A grant is `(subject, role, scope, effect)` inside one pond
|
||||
(table `role_grants`, see `data-model.md`):
|
||||
|
||||
- **subject**: a specific user, `authenticated` (any logged-in user), or
|
||||
`public` (everyone, including anonymous visitors).
|
||||
- **scope**: the whole pond, one label, or one page.
|
||||
- **effect**: `allow` or `deny`. `deny` expresses the vision's "all pages
|
||||
*except* label X" (pond-scope allow + label-scope deny).
|
||||
|
||||
## Resolution algorithm
|
||||
|
||||
Question: *may user U perform action A (read / write) on page P?*
|
||||
|
||||
1. If U is Site Admin → **allow**.
|
||||
2. If the page or its pond is in trash → only roles that could edit it may
|
||||
see it in trash views; regular access is denied.
|
||||
3. Collect all grants in P's pond whose subject matches U
|
||||
(their user id; `authenticated` if logged in; `public` always).
|
||||
4. Keep grants whose role covers action A
|
||||
(write needs `editor`/`pond_admin`; read is covered by any role).
|
||||
5. Evaluate by **descending specificity**; the first level that contains
|
||||
any matching grant decides:
|
||||
1. **Page scope** — grants on P itself.
|
||||
2. **Label scope** — grants on any label assigned to P, **including
|
||||
inherited labels**: a grant on label L applies to L and all its
|
||||
descendants in the label hierarchy.
|
||||
3. **Pond scope** — grants on the pond.
|
||||
6. Within the deciding level: if any matching grant is `deny` → **deny**,
|
||||
else **allow**. (Deny wins ties at the same specificity; a more specific
|
||||
`allow` still beats a less specific `deny` — that is the point of
|
||||
"most specific wins".)
|
||||
7. No matching grant at any level → **deny** (default-closed).
|
||||
|
||||
### Worked examples
|
||||
|
||||
Pond "Handbook", user Uma has pond-scope `editor` (allow):
|
||||
|
||||
| Extra grants | Uma edits page "Salaries"? | Why |
|
||||
| --- | --- | --- |
|
||||
| — | yes | pond-scope allow |
|
||||
| label `confidential` on the page + label-scope `editor` **deny** for Uma | no | label level is more specific than pond level |
|
||||
| additionally page-scope `editor` **allow** for Uma on "Salaries" | yes | page level beats label level |
|
||||
| page has labels `confidential` (deny Uma) and `hr` (allow Uma) | no | same level → deny wins |
|
||||
|
||||
"Only pages with label Y" (vision) = no pond-scope editor grant + label-Y
|
||||
`editor` allow. "All except label X" = pond-scope allow + label-X deny.
|
||||
|
||||
### Non-page objects
|
||||
|
||||
- **Attachments** inherit the permissions of their page (or pond for
|
||||
pond-level files): read requires read on the page, upload/delete requires
|
||||
write.
|
||||
- **Comments** (later milestone): reading follows page read; writing
|
||||
comments requires page read + the pond setting "who may comment"
|
||||
(readers-and-up or editors-only).
|
||||
- **History & trash**: require write permission on the affected page
|
||||
(ADR 0013).
|
||||
- **Search** results are filtered through the same resolution (ADR 0010).
|
||||
- **Plugin API** calls execute with the viewing user's permissions
|
||||
(ADR 0008) — the API enforces this server-side.
|
||||
|
||||
## Performance
|
||||
|
||||
Resolution needs the page's labels (+ label ancestors) and the pond's
|
||||
grants — a handful of indexed queries, cacheable per (user, pond) with
|
||||
event-based invalidation on grant/label changes. The collab server resolves
|
||||
once at token issuance (token TTL ≤ 60 s) and re-checks on reconnect;
|
||||
revoking write access closes live sessions via a pond-level notification
|
||||
(`LISTEN/NOTIFY`).
|
||||
|
||||
## UI obligations
|
||||
|
||||
- The frontend hides actions the user lacks (buttons, routes) but every
|
||||
denied server response renders a proper i18n-ed error — client checks are
|
||||
convenience, not security.
|
||||
- Pond Admin UI must make effective permissions inspectable: "show effective
|
||||
access for user X / for Public" per page — this transparency feature is a
|
||||
story of its own and load-bearing for admin trust.
|
||||
109
docs/architecture/plugin-architecture.md
Normal file
109
docs/architecture/plugin-architecture.md
Normal file
@ -0,0 +1,109 @@
|
||||
# Plugin architecture
|
||||
|
||||
Extends ADR 0008 with the concrete contracts implementers need.
|
||||
|
||||
## Package format
|
||||
|
||||
A plugin is a ZIP archive:
|
||||
|
||||
```
|
||||
my-plugin.zip
|
||||
├── manifest.json (required)
|
||||
├── plugin.js (required for kind=code; single ES module bundle)
|
||||
├── styles.css (optional; required for kind=section_style)
|
||||
├── i18n/de.json (optional UI strings)
|
||||
├── i18n/en.json
|
||||
└── assets/… (optional images etc.)
|
||||
```
|
||||
|
||||
### `manifest.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "toc",
|
||||
"name": "Table of Contents",
|
||||
"version": "1.2.0",
|
||||
"apiVersion": "1",
|
||||
"kind": "code",
|
||||
"extensionPoints": [
|
||||
{ "type": "pageTool", "id": "toc", "title": { "de": "Inhaltsverzeichnis", "en": "Table of contents" } }
|
||||
],
|
||||
"permissions": ["readCurrentPage"],
|
||||
"fallback": { "type": "text", "value": "[Table of contents]" },
|
||||
"license": "MIT",
|
||||
"homepage": "https://…"
|
||||
}
|
||||
```
|
||||
|
||||
- `apiVersion`: host checks against its supported range at install time.
|
||||
- `permissions`: the capabilities the plugin may call (see API below);
|
||||
shown to the Site Admin at install time. Requests outside the declared
|
||||
set are rejected at runtime.
|
||||
- `fallback`: static representation used in Word/PDF exports and when the
|
||||
plugin is disabled but its blocks still exist in documents.
|
||||
|
||||
## Kinds and extension points
|
||||
|
||||
| Kind | Extension point | What it does | Sandbox |
|
||||
| --- | --- | --- | --- |
|
||||
| `section_style` | `sectionStyle` | declares named styles (name, i18n label, CSS class body) applicable to container blocks — e.g. colored background boxes | none needed: CSS is sanitized (no `@import`, no `url()` to external hosts) and scoped under `.dt-style-<pluginId>-<styleId>` |
|
||||
| `code` | `block` | a custom editor block (diagram, embed, …); host registers a ProseMirror node `plugin_block` instance with `pluginId`, `blockType`, `data` attrs | sandboxed iframe per block |
|
||||
| `code` | `pageTool` | read-only widget rendered in the page tools panel or embedded as a block (TOC, page index, cross-page block embed) | sandboxed iframe |
|
||||
|
||||
## Sandbox runtime
|
||||
|
||||
- Each code-plugin surface runs in `<iframe sandbox="allow-scripts">`
|
||||
**without** `allow-same-origin` → opaque origin: no cookies, storage, or
|
||||
parent DOM. The iframe document is generated by the host and loads only
|
||||
the plugin bundle + its assets from the plugin's static path.
|
||||
- CSP on plugin frames: `default-src 'none'; script-src <plugin path>;
|
||||
img-src <plugin path> blob: data:; style-src <plugin path> 'unsafe-inline'`.
|
||||
No network access (`connect-src 'none'`) in v1.
|
||||
- Host ↔ plugin communication: `postMessage` RPC with structured-clone
|
||||
payloads. `packages/plugin-sdk` provides both sides:
|
||||
- plugin side: `createPlugin({ onRender, onEdit, … })`, typed `host.*`
|
||||
calls;
|
||||
- host side: frame lifecycle, request routing, permission filtering,
|
||||
timeouts (a hung plugin never blocks the app).
|
||||
|
||||
## Plugin API (v1 capabilities)
|
||||
|
||||
All calls are mediated by the host and executed against the REST API with
|
||||
the **viewing user's** session — a plugin can never read more than the
|
||||
person looking at it could.
|
||||
|
||||
| Capability | Methods |
|
||||
| --- | --- |
|
||||
| `readCurrentPage` | `getOutline()`, `getContent()` (Markdown), `getMeta()` |
|
||||
| `readPond` | `listPages()`, `getPageOutline(pageId)`, `getPageContent(pageId)` |
|
||||
| `readBlock` | `getBlock(pageId, blockId)` — cross-page block embedding |
|
||||
| `blockData` | `getData()` / `setData(data)` for the plugin's own block instance (writes go through the editor as a normal document change — requires the viewer to have write permission) |
|
||||
| `ui` | `resize(height)`, `openPage(pageId)` (host navigates), `toast(msgKey)` |
|
||||
|
||||
## Lifecycle & administration
|
||||
|
||||
1. **Install** (Site Admin): upload ZIP in the admin UI **or** drop it into
|
||||
the `plugins/` volume directory (a watcher picks it up). The API
|
||||
validates: ZIP structure, manifest schema, `apiVersion`, CSS sanitation,
|
||||
bundle size limit. Invalid packages are rejected with a precise error.
|
||||
2. **Instance mode** (Site Admin): `disabled` | `optional` | `required`
|
||||
(vision: Site Admin activates plugins optionally or mandatorily).
|
||||
3. **Pond activation** (Pond Admin): toggle `optional` plugins per pond.
|
||||
4. **Update**: uploading the same id with a higher version replaces the
|
||||
package after the same validation; open clients use the new version on
|
||||
next load.
|
||||
5. **Uninstall**: blocked while `required`; otherwise the package is
|
||||
removed, existing `plugin_block` nodes render the manifest `fallback`
|
||||
(documents are never mutated by plugin removal).
|
||||
|
||||
## Reference plugins (shipped with the product, also serving as examples)
|
||||
|
||||
- `section-styles-basic` (`section_style`): a set of colored callout/box
|
||||
styles — proves the declarative path.
|
||||
- `toc` (`pageTool`): table of contents from the page outline.
|
||||
- `page-index` (`pageTool`): filtered page list by label.
|
||||
- `mermaid` (`block`): diagram block rendering Mermaid source — proves the
|
||||
code-block path end to end (editing UI inside the sandbox).
|
||||
|
||||
These live in `packages/plugins/` in the monorepo, are built by CI, and
|
||||
double as the plugin-SDK integration tests.
|
||||
96
docs/architecture/realtime-collaboration.md
Normal file
96
docs/architecture/realtime-collaboration.md
Normal file
@ -0,0 +1,96 @@
|
||||
# Real-time collaboration
|
||||
|
||||
How live editing, cursors, offline work, and persistence fit together.
|
||||
Foundational decisions: ADR 0003 (Yjs + Hocuspocus), ADR 0004 (TipTap),
|
||||
ADR 0013 (versions).
|
||||
|
||||
## Components
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant E as Editor (TipTap + Yjs)
|
||||
participant I as IndexedDB (y-indexeddb)
|
||||
participant A as api (REST)
|
||||
participant C as collab (Hocuspocus)
|
||||
participant P as PostgreSQL
|
||||
|
||||
E->>A: GET /pages/:id/collab-token
|
||||
A->>A: resolve permissions (shared lib)
|
||||
A-->>E: JWT {userId, pageId, mode: rw|ro, ttl 60s}
|
||||
E->>C: WebSocket connect (token)
|
||||
C->>C: onAuthenticate: verify JWT
|
||||
C->>P: onLoadDocument: state + updates
|
||||
C-->>E: initial sync (Yjs protocol)
|
||||
E->>I: persist locally (continuous)
|
||||
E->>C: updates + awareness (cursors)
|
||||
C-->>E: other participants' updates/awareness
|
||||
C->>P: onStoreDocument (debounced)
|
||||
C->>P: refresh page_content_cache, page_links
|
||||
```
|
||||
|
||||
## Document lifecycle
|
||||
|
||||
1. **Open**: client fetches a collab token from the API (permission check
|
||||
happens here), connects to `/collab` with it. Read-only users connect in
|
||||
`ro` mode: they receive updates and awareness but the server drops any
|
||||
update they send.
|
||||
2. **Edit**: Yjs syncs deltas both ways; TipTap renders remote changes;
|
||||
the collaboration-cursor extension renders remote cursors/selections
|
||||
with each participant's display name and a stable per-user color.
|
||||
3. **Persist**: Hocuspocus stores the merged state to PostgreSQL, debounced
|
||||
(default 2 s after last change, hard interval 30 s). The same hook
|
||||
refreshes `page_content_cache` (plain text, Markdown, HTML, outline) and
|
||||
the `page_links` wikilink index — search and backlinks are therefore
|
||||
near-real-time.
|
||||
4. **Close**: when the last participant disconnects, the server persists
|
||||
finally and triggers an automatic version snapshot if content changed
|
||||
(ADR 0013).
|
||||
|
||||
## Offline behavior
|
||||
|
||||
- `y-indexeddb` keeps every opened page's document local; the PWA service
|
||||
worker keeps the app shell loadable. A user can open previously-visited
|
||||
pages and edit without a connection.
|
||||
- On reconnect, Yjs's sync protocol exchanges state vectors; local and
|
||||
remote changes merge conflict-free. There is deliberately **no** conflict
|
||||
UI — CRDT semantics decide; version history is the safety net for
|
||||
surprising merges.
|
||||
- **Permission changes vs. offline edits**: tokens are re-acquired on every
|
||||
reconnect. If write permission was revoked while offline, the reconnect
|
||||
yields an `ro` token; the client keeps the local changes visible, informs
|
||||
the user ("your edit permission was removed — export your changes"), and
|
||||
offers copy/Markdown export of the local version. Local state for a page
|
||||
is discarded when the user leaves it after a successful sync.
|
||||
- Creating **new** pages offline is out of scope for v1 (requires the REST
|
||||
API); editing existing ones is the offline use case.
|
||||
|
||||
## Awareness (cursors & presence)
|
||||
|
||||
- Awareness state per participant: user id, display name, color, cursor
|
||||
anchor/head. The editor UI shows remote carets inline and an avatar strip
|
||||
of current participants at the top of the page.
|
||||
- Awareness is ephemeral (never persisted). Read-only participants appear
|
||||
in presence but without a caret.
|
||||
|
||||
## Server operations
|
||||
|
||||
- The collab server holds open documents in memory; `maxDocuments` and
|
||||
per-connection message size limits guard resources. It is safe to restart
|
||||
at any time — clients resync from IndexedDB + server state.
|
||||
- Update-log **compaction** (merge `page_updates` into `ydoc_state`) runs
|
||||
as a maintenance job when a page has > N log entries (default 500) and no
|
||||
open session. Version snapshots are self-contained, so compaction is
|
||||
invisible to history (ADR 0013).
|
||||
- **Scaling note**: one collab instance serves the target scale (ADR 0002).
|
||||
The sharding/pub-sub upgrade path (multiple instances + Redis adapter) is
|
||||
documented here as the known escape hatch and requires no data-model
|
||||
change.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Failure | Behavior |
|
||||
| --- | --- |
|
||||
| collab container down | editing degrades to offline mode (local persistence); banner "reconnecting…"; REST reads unaffected |
|
||||
| WebSocket blocked (proxy) | same as above; deployment docs require WebSocket pass-through for `/collab` |
|
||||
| stale collab token | client transparently re-fetches and reconnects |
|
||||
| document too large | server rejects updates beyond a size ceiling (instance setting) with a user-visible error; prevents runaway documents |
|
||||
107
docs/architecture/roadmap.md
Normal file
107
docs/architecture/roadmap.md
Normal file
@ -0,0 +1,107 @@
|
||||
# Roadmap — epics and milestones
|
||||
|
||||
Stories are cut so early milestones yield a **running skeleton** that every
|
||||
later story builds on, instead of opening all fronts in parallel. Each
|
||||
milestone below becomes a Gitea milestone; each bullet becomes one or more
|
||||
issues (0.5–2 implementer-days each) with the component labels shown.
|
||||
|
||||
Component labels: `backend`, `frontend`, `collab`, `deployment`, `auth`,
|
||||
`plugins`, `docs`, `qa`.
|
||||
|
||||
## M0 — Walking skeleton (`deployment`, `backend`, `frontend`)
|
||||
|
||||
Goal: empty but deployed. Monorepo scaffold (pnpm, ESLint/Prettier, Vitest,
|
||||
Playwright), NestJS api with `/healthz` + Prisma + first migration, React
|
||||
SPA shell with routing + i18n scaffold (ADR 0012), Dockerfiles + Compose
|
||||
stack + dev overlay, Gitea Actions pipeline (lint/test/build → images →
|
||||
deploy Test → e2e smoke → promote Int), stage setup on LEISINGER.
|
||||
**Exit criterion**: a commit to `main` automatically reaches
|
||||
`dorfteich-test.101010.cloud` and shows a styled "hello" shell.
|
||||
|
||||
## M1 — Accounts & authentication (`auth`, `backend`, `frontend`)
|
||||
|
||||
Signup with e-mail verification (SMTP + mail outbox), login/logout with
|
||||
sessions, password reset, rate limiting, account settings (display name,
|
||||
locale, password change), registration mode setting, session management UI.
|
||||
**Exit**: a person can register on Test, verify, log in, reset password —
|
||||
in German and English.
|
||||
|
||||
## M2 — Ponds & pages, single-user editing (`backend`, `frontend`)
|
||||
|
||||
Ponds CRUD (personal pond auto-created at signup; shared ponds respecting
|
||||
the additional-ponds quota), pond sidebar with page list + collapse +
|
||||
sort modes, page CRUD with TipTap editor (Yjs document persisted via REST
|
||||
for now — no collab server yet), image paste/upload with quota tracking,
|
||||
link edit/open-in-new-tab UX, page trash, Markdown copy/paste.
|
||||
**Exit**: a logged-in user manages pages in their pond with a real editor.
|
||||
|
||||
## M3 — Real-time collaboration & history (`collab`, `frontend`, `backend`)
|
||||
|
||||
Hocuspocus server + token issuance, live sync + remote cursors + presence
|
||||
strip, offline (y-indexeddb + PWA shell + reconnect UX + revoked-permission
|
||||
path), update-log persistence/compaction, automatic + named versions,
|
||||
version view/diff/restore.
|
||||
**Exit**: two browsers edit one page live with visible cursors; offline
|
||||
edits merge; history shows and restores versions.
|
||||
|
||||
## M4 — Organization & search (`backend`, `frontend`)
|
||||
|
||||
Hierarchical labels (CRUD, assignment, tree UI), label filter in sidebar,
|
||||
manual page ordering (fractional index, drag-and-drop), wikilinks
|
||||
(autocomplete node, link index, phantom links) + backlinks panel,
|
||||
PostgreSQL FTS behind `SearchProvider` + search UI with snippets.
|
||||
**Exit**: pages are organized by labels/hierarchy, `[[links]]` resolve,
|
||||
search finds only what you may read (verified with M5 in e2e later).
|
||||
|
||||
## M5 — Permissions & quotas (`auth`, `backend`, `frontend`)
|
||||
|
||||
Full grant model (`role_grants`, resolution in `packages/shared`, API
|
||||
guards), pond member management UI, label-/page-scope grants + deny,
|
||||
public access (anonymous read routes + public HTML rendering endpoint),
|
||||
effective-permissions inspector, quota ladder
|
||||
(instance/user/pond overrides) + Site Admin quota UI, Site Admin user
|
||||
management.
|
||||
**Exit**: the vision's role matrix works end to end, including
|
||||
"all-except-label-X" and public read.
|
||||
|
||||
## M6 — Import, export & attachments (`backend`, `frontend`)
|
||||
|
||||
Non-image attachments (allowlist, size limits, listing), pandoc sidecar +
|
||||
import `.docx`/`.odt` (fixture corpus), export Markdown / `.docx` / `.odt`,
|
||||
Gotenberg PDF export with pond fonts, conversion job queue + progress UI.
|
||||
**Exit**: round-trip a structured Word document per the best-effort
|
||||
fidelity contract; export any page as PDF.
|
||||
|
||||
## M7 — Plugins (`plugins`, `frontend`, `backend`)
|
||||
|
||||
Plugin SDK (manifest schema, postMessage RPC, sandbox host), package
|
||||
validation + install via GUI + directory watcher, instance modes +
|
||||
per-pond activation, capability-scoped plugin API endpoints, reference
|
||||
plugins (`section-styles-basic`, `toc`, `page-index`, `mermaid`), export
|
||||
fallbacks.
|
||||
**Exit**: Site Admin uploads a ZIP on Int; a Pond Admin enables it; a
|
||||
mermaid diagram renders in a page and degrades to fallback in PDF.
|
||||
|
||||
## M8 — Self-hosting & operations (`deployment`, `backend`, `docs`)
|
||||
|
||||
First-run setup wizard, legal pages feature (+ dorfteich.online texts),
|
||||
backup sidecar (dump + volume + prune + BASEL mirror + status), restore
|
||||
runbook + drill automation, health/readiness endpoints + Uptime-Kuma
|
||||
monitors, maintenance-job admin panel, self-hosting guide + reference
|
||||
compose, data export (GDPR), release process (semver tags, notes, manual
|
||||
Prod gate).
|
||||
**Exit**: a stranger can self-host with the guide; Prod go-live checklist
|
||||
is satisfiable; **dorfteich.online launches at the end of M8**.
|
||||
|
||||
## M9 — Comments & notifications (`backend`, `frontend`)
|
||||
|
||||
Page comments (threads, resolve), watches, in-app notification center,
|
||||
e-mail digests via outbox, pond setting "who may comment".
|
||||
**Exit**: a Reader comments (where allowed), an Editor gets notified.
|
||||
|
||||
## Deliberately after M9 (unscheduled backlog)
|
||||
|
||||
OIDC login (ADR 0007), external search engine profile (ADR 0010), plugin
|
||||
network allowlist (ADR 0008), admin approval for signups (ADR 0007),
|
||||
mobile editing, dorfteich.cloud static site (separate mini-project by
|
||||
kickoff decision).
|
||||
100
docs/architecture/security.md
Normal file
100
docs/architecture/security.md
Normal file
@ -0,0 +1,100 @@
|
||||
# Security concept
|
||||
|
||||
Threat-driven summary; detailed mechanics live in the referenced ADRs.
|
||||
|
||||
## Assets & main threats
|
||||
|
||||
Wiki content (possibly confidential per pond/label), user credentials and
|
||||
e-mail addresses, instance availability. Threat actors: anonymous internet
|
||||
(public instance with self-signup), malicious registered users, malicious
|
||||
or sloppy plugin authors, compromised dependencies.
|
||||
|
||||
## Authentication & session security (ADR 0007)
|
||||
|
||||
- Argon2id password hashing; opaque server-side sessions in HttpOnly,
|
||||
Secure, SameSite=Lax cookies; CSRF protected by SameSite + origin checks
|
||||
on mutating requests (double-submit token for the file-download edge
|
||||
cases).
|
||||
- E-mail verification (double opt-in) before an account can create content;
|
||||
password reset via single-use hashed tokens; both rate-limited.
|
||||
- Rate limiting (DB-backed) on login, signup, reset, and API; lockout
|
||||
backoff on repeated failed logins per account+IP.
|
||||
- Self-registration can be disabled instance-wide; personal-pond quotas
|
||||
(editors/readers/ponds/storage) bound the blast radius of spam accounts.
|
||||
|
||||
## Authorization
|
||||
|
||||
- Single resolution algorithm (`permissions.md`) in `packages/shared`,
|
||||
enforced in API guards and at collab token issuance — never in the client.
|
||||
- Default-closed: no grant → no access. Public access is always an explicit
|
||||
grant.
|
||||
- Admin actions are audit-logged (`operations.md`).
|
||||
|
||||
## Content & upload security
|
||||
|
||||
- Editor content is structured (ProseMirror schema) — no raw HTML from
|
||||
users. The HTML render endpoint escapes everything outside the schema;
|
||||
link protocols allowlisted (`https`, `http`, `mailto`).
|
||||
- Uploads (ADR 0011): MIME/extension allowlist, size limits, magic-byte
|
||||
checks, SVG sanitization or rejection, `Content-Disposition: attachment`
|
||||
for non-image types, no user content served same-origin as executable
|
||||
(`X-Content-Type-Options: nosniff`; uploads path never serves
|
||||
`text/html`).
|
||||
- App CSP (strict): `default-src 'self'`; `font-src 'self'` (ADR 0016);
|
||||
no third-party origins at all — the GDPR posture is "zero external
|
||||
requests".
|
||||
|
||||
## Plugin sandboxing (ADR 0008, operational)
|
||||
|
||||
- Code plugins: opaque-origin iframes, no network (`connect-src 'none'`),
|
||||
capability-scoped postMessage API executed with the **viewer's**
|
||||
permissions server-side; declared capabilities surfaced to the Site Admin
|
||||
at install time.
|
||||
- Style plugins: CSS sanitized (no `@import`/external `url()`), scoped
|
||||
class names.
|
||||
- Install surface restricted to Site Admins; packages size-limited and
|
||||
schema-validated; the `plugins/` directory watcher only trusts the volume
|
||||
(host-level access implies game over anyway).
|
||||
|
||||
## Collaboration layer
|
||||
|
||||
- WebSocket connect requires a short-lived (≤ 60 s) single-purpose JWT
|
||||
bound to user + page + mode; write revocation closes sessions via
|
||||
LISTEN/NOTIFY (`realtime-collaboration.md`).
|
||||
- Update size and document size ceilings prevent resource-exhaustion via
|
||||
crafted CRDT updates.
|
||||
|
||||
## Secrets & configuration
|
||||
|
||||
- Secrets (DB password, collab signing key, SMTP credentials) live only in
|
||||
the stage `.env` (mode 600, never in git) and container env — not in the
|
||||
database (`instance_settings` stores non-secret config; the SMTP password
|
||||
entered in the setup wizard is written to the env-backed secret store,
|
||||
not to a DB row).
|
||||
- Key rotation: collab signing key and session pepper rotate via env change
|
||||
+ rolling restart; procedure documented in `operations.md` runbooks.
|
||||
- Dependencies: lockfile-pinned; monthly update batch; images pinned to
|
||||
digests in Prod.
|
||||
|
||||
## Privacy (GDPR)
|
||||
|
||||
- No external requests from the browser (fonts self-hosted, no CDNs, no
|
||||
analytics by default).
|
||||
- Instance-configurable legal pages (imprint, privacy policy) are a core
|
||||
feature; dorfteich.online uses the operator's standard texts.
|
||||
- Data minimization: username, e-mail, password hash, locale — nothing
|
||||
else required. Account deletion: personal pond and authored ponds
|
||||
follow the trash/purge path; authorship on shared content is pseudonymized
|
||||
("deleted user"). A data-export endpoint (own profile + own ponds as
|
||||
Markdown/ZIP) supports access/portability requests.
|
||||
- IP addresses appear only in rate-limit counters (short TTL) and reverse
|
||||
proxy logs (host-level rotation) — documented in the privacy-policy
|
||||
template.
|
||||
|
||||
## Out of scope (v1, explicit)
|
||||
|
||||
- No end-to-end encryption of page content (server sees plaintext — needed
|
||||
for search, export, rendering).
|
||||
- No plugin marketplace/signing — installation is a deliberate Site Admin
|
||||
act of trust in the reviewed package.
|
||||
- No SSO in MVP (OIDC-ready per ADR 0007).
|
||||
Loading…
Reference in New Issue
Block a user