Scaffold pnpm monorepo with lint, format, and test tooling

pnpm workspace with apps/web, apps/api, apps/collab, and
packages/shared; strict TypeScript base config, repo-wide ESLint (flat)
+ Prettier, Vitest per package, and root scripts lint/typecheck/test/
build. @dorfteich/shared ships a first health-response helper consumed
by apps/api to prove workspace linking. Existing markdown docs are
reformatted once by the new Prettier setup.

Closes #1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-04 19:06:27 +02:00
parent 6517c8fb71
commit b16d23297e
36 changed files with 2938 additions and 150 deletions

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
coverage/
*.log
.env
.env.*
!.env.example
.DS_Store

4
.prettierignore Normal file
View File

@ -0,0 +1,4 @@
dist/
node_modules/
coverage/
pnpm-lock.yaml

5
.prettierrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}

View File

@ -1,7 +1,7 @@
# Dorfteich
Dorfteich is an open-source wiki system built around **ponds** (German:
*Teiche*) — self-contained wiki spaces that people and teams organize freely
_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.
@ -33,12 +33,28 @@ offline support.
## Repository layout
| Path | Contents |
| --- | --- |
| 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 |
| `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 |
## Development
Requirements: Node.js ≥ 22 and [pnpm](https://pnpm.io) (`npm install -g pnpm`).
```sh
pnpm install # install all workspace dependencies
pnpm lint # ESLint + Prettier check across the repo
pnpm typecheck # TypeScript --noEmit in every package
pnpm test # Vitest in every package
pnpm build # build every package (dependency order)
```
The workspace packages live under `apps/` (web, api, collab) and
`packages/` (shared). Shared logic goes into `packages/shared` and is
imported as `@dorfteich/shared` — never copy code between apps.
## Status

18
apps/api/package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "@dorfteich/api",
"version": "0.0.0",
"private": true,
"description": "Dorfteich REST API server",
"license": "MIT",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@dorfteich/shared": "workspace:*"
},
"devDependencies": {
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { apiHealth } from './index';
describe('workspace linking', () => {
it('api consumes @dorfteich/shared', () => {
expect(apiHealth().service).toBe('api');
});
});

7
apps/api/src/index.ts Normal file
View File

@ -0,0 +1,7 @@
// Placeholder entry point; replaced by the NestJS bootstrap in issue #2.
// It already imports from @dorfteich/shared to prove workspace linking.
import { healthResponse } from '@dorfteich/shared';
export function apiHealth(): ReturnType<typeof healthResponse> {
return healthResponse('api', '0.0.0');
}

10
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "dist"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

15
apps/collab/package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "@dorfteich/collab",
"version": "0.0.0",
"private": true,
"description": "Dorfteich collaboration server (Yjs/Hocuspocus)",
"license": "MIT",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"vitest": "^3.0.0"
}
}

3
apps/collab/src/index.ts Normal file
View File

@ -0,0 +1,3 @@
// Placeholder entry point; the Hocuspocus server arrives with milestone M3
// (issue #33). The package exists so workspace tooling covers it from day one.
export const SERVICE_NAME = 'collab';

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "dist"
},
"include": ["src"]
}

15
apps/web/package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "@dorfteich/web",
"version": "0.0.0",
"private": true,
"description": "Dorfteich single-page application",
"license": "MIT",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"vitest": "^3.0.0"
}
}

2
apps/web/src/index.ts Normal file
View File

@ -0,0 +1,2 @@
// Placeholder entry point; replaced by the Vite + React app in issue #4.
export const APP_NAME = 'Dorfteich';

9
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"outDir": "dist"
},
"include": ["src"]
}

View File

@ -52,48 +52,48 @@ flowchart LR
### 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 |
| 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 |
| 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 |
| ----------------------- | ---------------------------- |
| Teich | pond |
| Seite | page |
| Teich-Admin | Pond Admin |
| Bearbeitende | Editor |
| Lesende | Reader |
| Öffentlichkeit | Public |
## Conventions for implementers

View File

@ -32,9 +32,9 @@ confirmed).
- 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.
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.

View File

@ -29,7 +29,7 @@ natural hooks for both.
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
- **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
@ -64,5 +64,5 @@ natural hooks for both.
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).
rejected (Markdown diffs are still used for the _display_ layer).
- **Hard delete only**: data-loss risk contradicts kickoff decision.

View File

@ -35,7 +35,7 @@ automated and objective wherever possible.
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>/`)
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

View File

@ -32,42 +32,47 @@ erDiagram
## 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` | |
| 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 |
| 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`
@ -76,48 +81,55 @@ 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) |
| 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 |
| 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`.
@ -125,23 +137,27 @@ slug resolves those rows.
## 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 →
@ -149,6 +165,7 @@ 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.

View File

@ -11,15 +11,15 @@ choice and any later move cheap). DNS status: `*.dorfteich.online` and
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) |
| 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`
@ -40,12 +40,12 @@ Self-hosters without a proxy can enable the optional `caddy` Compose profile
## Stages
| Stage | Where | Domain | Purpose | Data |
| --- | --- | --- | --- | --- |
| **Dev** | contributor machine (e.g. the operator's MacBook), Docker Desktop | `localhost` | feature work; hot reload via `compose.dev.yml` overlay (source mounts, vite dev server) | fixtures/seed script |
| **Test** | VPS `188.245.116.44`, `/home/DOCKER/dorfteich-test/` | `test.dorfteich.cloud` | auto-deploy target of `main`; e2e suite runs here | reset-able; seeded |
| **Int** | VPS `188.245.116.44`, `/home/DOCKER/dorfteich-int/` | `int.dorfteich.cloud` | stable preview; manual/exploratory testing; release candidates | persistent test data |
| **Prod** | host decided at go-live (M8): the VPS or a dedicated host | `dorfteich.online` | public flagship instance | real data; full backup + mirror |
| Stage | Where | Domain | Purpose | Data |
| -------- | ----------------------------------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------- | ------------------------------- |
| **Dev** | contributor machine (e.g. the operator's MacBook), Docker Desktop | `localhost` | feature work; hot reload via `compose.dev.yml` overlay (source mounts, vite dev server) | fixtures/seed script |
| **Test** | VPS `188.245.116.44`, `/home/DOCKER/dorfteich-test/` | `test.dorfteich.cloud` | auto-deploy target of `main`; e2e suite runs here | reset-able; seeded |
| **Int** | VPS `188.245.116.44`, `/home/DOCKER/dorfteich-int/` | `int.dorfteich.cloud` | stable preview; manual/exploratory testing; release candidates | persistent test data |
| **Prod** | host decided at go-live (M8): the VPS or a dedicated host | `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

View File

@ -53,14 +53,14 @@ monitoring, structured logs, backup alerting — no dedicated metrics stack.
## 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 | 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.
@ -78,9 +78,9 @@ panel is the operator's single glance for instance health.
## 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 |
| 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

@ -8,13 +8,13 @@ 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) |
| 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:
@ -34,11 +34,11 @@ A grant is `(subject, role, scope, effect)` inside one pond
`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).
_except_ label X" (pond-scope allow + label-scope deny).
## Resolution algorithm
Question: *may user U perform action A (read / write) on page P?*
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
@ -64,12 +64,12 @@ Question: *may user U perform action A (read / write) on page P?*
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 |
| 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.

View File

@ -26,7 +26,11 @@ my-plugin.zip
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{ "type": "pageTool", "id": "toc", "title": { "de": "Inhaltsverzeichnis", "en": "Table of contents" } }
{
"type": "pageTool",
"id": "toc",
"title": { "de": "Inhaltsverzeichnis", "en": "Table of contents" }
}
],
"permissions": ["readCurrentPage"],
"fallback": { "type": "text", "value": "[Table of contents]" },
@ -44,11 +48,11 @@ my-plugin.zip
## 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 |
| 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
@ -57,7 +61,7 @@ my-plugin.zip
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'`.
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:
@ -72,13 +76,13 @@ 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)` |
| 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

View File

@ -88,9 +88,9 @@ sequenceDiagram
## 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 |
| 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

@ -72,7 +72,7 @@ or sloppy plugin authors, compromised dependencies.
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.
plus rolling restart; procedure documented in `operations.md` runbooks.
- Dependencies: lockfile-pinned; monthly update batch; images pinned to
digests in Prod.

26
eslint.config.mjs Normal file
View File

@ -0,0 +1,26 @@
// Repo-wide ESLint flat config. Packages inherit these rules; add
// package-specific overrides here (scoped by `files`) rather than with
// per-package config files, so rules stay consistent across the monorepo.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default tseslint.config(
{
ignores: ['**/dist/**', '**/node_modules/**', '**/coverage/**', '**/*.gen.ts'],
},
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
rules: {
// Unused values are usually bugs; underscore-prefix marks intentional ones.
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
// `any` defeats the shared Zod/type contracts between client and server.
'@typescript-eslint/no-explicit-any': 'error',
},
},
);

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "dorfteich",
"version": "0.0.0",
"private": true,
"description": "Dorfteich — an open-source wiki system with real-time collaboration",
"license": "MIT",
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@11.9.0",
"scripts": {
"lint": "eslint . && prettier --check .",
"format": "prettier --write .",
"typecheck": "pnpm -r run typecheck",
"test": "pnpm -r run test",
"build": "pnpm -r run build",
"i18n:check": "node scripts/i18n-check.mjs"
},
"devDependencies": {
"@eslint/js": "^9.20.0",
"eslint": "^9.20.0",
"eslint-config-prettier": "^10.0.0",
"prettier": "^3.5.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.24.0"
}
}

View File

@ -0,0 +1,32 @@
{
"name": "@dorfteich/shared",
"version": "0.0.0",
"private": true,
"description": "Types, schemas, and logic shared between web, api, and collab",
"license": "MIT",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"zod": "^3.24.0"
},
"devDependencies": {
"tsup": "^8.3.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { healthResponse } from './health';
describe('healthResponse', () => {
it('reports ok with service, version, and a valid timestamp', () => {
const res = healthResponse('api', '1.2.3');
expect(res.status).toBe('ok');
expect(res.service).toBe('api');
expect(res.version).toBe('1.2.3');
expect(Number.isNaN(Date.parse(res.time))).toBe(false);
});
});

View File

@ -0,0 +1,23 @@
/**
* Shape of the liveness responses served by every Dorfteich service
* (`/healthz` on web, api, and collab). Readiness (`/readyz`) has a richer,
* service-specific shape and lives with the api.
*/
export interface HealthResponse {
status: 'ok';
service: 'web' | 'api' | 'collab';
version: string;
time: string;
}
export function healthResponse(
service: HealthResponse['service'],
version: string,
): HealthResponse {
return {
status: 'ok',
service,
version,
time: new Date().toISOString(),
};
}

View File

@ -0,0 +1 @@
export * from './health';

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"outDir": "dist"
},
"include": ["src"]
}

2472
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,6 @@
packages:
- apps/*
- packages/*
# Postinstall scripts are opt-in with pnpm; esbuild needs its binary install.
allowBuilds:
esbuild: true

19
tsconfig.base.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"isolatedModules": true
}
}