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:
Claude Fable 5 2026-07-04 14:36:16 +02:00
commit 0629411966
27 changed files with 1969 additions and 0 deletions

21
LICENSE Normal file
View 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
View 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
View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.52 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.

View 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.

View 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.

View 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".

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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 (~1525
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.

View 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).

View 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.

View 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 |

View 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.

View 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.

View 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 |

View 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.52 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).

View 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).