Off-host backups for every self-hoster, configured entirely in the admin
UI — supersedes the host-specific mirror plan behind #84.
shared:
- webdav.ts (new package entry like token-crypto): minimal WebDAV client
with basic auth — PROPFIND (tolerant multistatus parser), MKCOL, PUT
(streamed), GET, DELETE; Nextcloud DAV path derived from the plain
server URL, explicit DAV bases pass through
- backup-status.ts: additive remote-upload status in status.json, the
restore-status.json contract (running/succeeded/failed + staleness
bound), the backup_command/backup_maintenance NOTIFY channels, and the
one-bundle-per-set naming (dorfteich-backup-<id>.tar.gz)
- backup-set.ts moved here from apps/backup (api lists local sets)
backup sidecar:
- reads the backup.* instance settings directly from the database (admin
changes apply next run; local retention row overrides the env) and the
app password from the secret store
- after each successful set: bundle dump + files archive + manifest into
ONE self-contained tar.gz, upload via WebDAV per schedule
(off/daily/weekly; manual runs always upload), prune remote bundles —
never the newest — and record the outcome in status.json; upload
failures alert via a new backupUploadFailed mail (de+en)
- command listener on backup_command (run / restore) with a serial queue
against the nightly timer
- restore orchestrator: restore-status.json → maintenance NOTIFY →
grace → (remote: download + manifest-verify bundle) → terminate other
DB connections → shared perform-restore path (same code as restore.sh)
→ final status + maintenance exit
api:
- MaintenanceGuard (global, registered before the setup gate): 503
maintenance_mode while restore-status says running; health endpoints
and the new public GET /backup/restore-status stay exempt; a stale
running state (crashed sidecar) unblocks after 30 min
- MaintenanceStateService watches the file and restarts the api after a
successful restore (fresh caches, migrate-on-start for older dumps);
main.ts refuses to touch the database while a restore runs — a
container restarting mid-restore must not race pg_restore with
migrate deploy
- worker sweeps (conversion, mail outbox, scheduler) catch transient
database failures instead of dying on an unhandled rejection — the
restore's connection termination crashed the api in verification
- backup admin endpoints under /admin/system/backup: settings (live
connection test before save, password write-only into the secret
store), nextcloud/test, sets (local via the ro backups mount + remote
via WebDAV), run + restore (type-to-confirm backstop, source
validation) — commands travel as NOTIFY payloads; audit actions
backup.settings_changed/run_triggered/restore_requested
- readyz: new warning-level backup_remote check while a target is
configured (26 h daily / 170 h weekly bound)
collab:
- maintenance listener: on enter, persist + close every live session and
refuse new connections until exit (failsafe timeout 30 min) — no
in-memory document may write pre-restore content back afterwards
web:
- Admin → System backup section: status card with remote facts and a
"Back up now" button, the Nextcloud settings form with test button,
and the restore picker (local + remote sets, type-to-confirm)
- global maintenance screen: any 503 maintenance_mode flips the SPA to a
status page polling the exempt endpoint, reloading when the instance
returns
Verified end-to-end against a live stack (fresh DB, native api + sidecar,
fake WebDAV server): configure → test → manual backup → bundle upload →
readyz/sets/status surfaces → remote restore with maintenance gate,
marker rollback and api restart; suites: shared 21, backup 9, collab 11,
api 58 files green, lint + i18n:check + typecheck clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
The api mints a short-lived (60 s) HS256 JWT per page open after an interim
permission check; the collab server authenticates every connection with it
(ADR 0003/0007 — the only JWTs in the system).
- packages/shared: browser-safe token schema/types in `collab-token`, and the
Node `crypto` sign/verify in `token-crypto` behind its own subpath export
(`@dorfteich/shared/token-crypto`) so the web bundle never pulls in
`node:crypto`. Only HS256 is produced/accepted; the signature is checked in
constant time before any untrusted field is read.
- api: `GET /pages/:id/collab-token` (auth-required) returns
{token, mode, expiresInSeconds}; `mode` is rw/ro via the interim access
service; issuance is logged at debug level without the token value.
- collab: `onAuthenticate` verifies the token, checks the pageId matches the
document name, stores {userId, mode} context, and enforces `ro` via
Hocuspocus' read-only connection flag. Hocuspocus' own signal handling is
disabled so index.ts remains the single shutdown owner.
- Shared COLLAB_TOKEN_SECRET env for api + collab (compose, dev overlay,
.env.example, stage docs); a dev default keeps native dev/test/CI running.
Tests: shared token round-trip/rejection; api endpoint e2e (auth required,
claims, 404 for non-members/unknown ids); collab integration via
HocuspocusProvider (valid token connects; expired/tampered/mismatched-page/
wrong-secret rejected; read-only writes dropped, verified with two clients).
Closes#34
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ProseMirror schema (headings 1-4, lists incl. task lists, blockquote,
code block, tables via prosemirror-tables, images, hard breaks; bold/
italic/code/strikethrough/link marks) plus docToMarkdown, markdownToDoc,
docToPlainText, docToHtml, and extractOutline built on it. Markdown
parsing extends markdown-it's default preset with a token-stream
transform for GFM task lists and table-cell paragraph wrapping.
docToHtml hand-rolls escaping and link-protocol allowlisting with zero
DOM dependencies, so it runs in the API/collab server as well as the
browser.
Node names `wikilink` and `plugin_block` are reserved for later stories.
Closes#24
Translation resources live in packages/shared/i18n/<lang>/<ns>.json
(common, errors) and ship with de and en. The web app initializes
react-i18next with bundled resources (?lng= wins, then the browser
language); all shell components use useTranslation and the temporary
t() stub is gone. The api localizes its uniform error bodies via a
minimal i18next instance negotiated from Accept-Language. `pnpm
i18n:check` fails CI when any key is missing in any language, backed
by tested helpers in @dorfteich/shared.
Closes#5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
apps/api boots a NestJS application with: Zod-validated environment
configuration (schema in @dorfteich/shared, fails fast listing every
invalid variable), structured pino request logging via nestjs-pino
(pretty in development, JSON otherwise, auth headers redacted), a
global exception filter producing the uniform ApiErrorBody shape, and
GET /api/v1/healthz. Vitest runs Nest through SWC for decorator
metadata; supertest covers healthz and the 404 error shape.
Closes#2
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>