From 352ffe0bf2d68451717bf0867e54295988224f52 Mon Sep 17 00:00:00 2001 From: Christian Johannesen Date: Sun, 18 Oct 2026 16:05:50 +0200 Subject: [PATCH] docs: add container operations and project guide --- .dockerignore | 9 ++++++ Dockerfile | 35 ++++++++++++++++++++++++ README.md | 65 ++++++++++++++++++++++++++++++++++++++++++++ deploy/nginx.conf | 14 ++++++++++ docker-compose.yml | 50 ++++++++++++++++++++++++++++++++++ docs/architecture.md | 32 ++++++++++++++++++++++ docs/operations.md | 20 ++++++++++++++ scripts/backup.ps1 | 10 +++++++ scripts/restore.ps1 | 10 +++++++ 9 files changed, 245 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 deploy/nginx.conf create mode 100644 docker-compose.yml create mode 100644 docs/architecture.md create mode 100644 docs/operations.md create mode 100644 scripts/backup.ps1 create mode 100644 scripts/restore.ps1 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3a206fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +**/dist +.git +data +backups +coverage +.env* +test-results +playwright-report diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b02826b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM node:24-bookworm-slim AS build +WORKDIR /app +COPY package*.json tsconfig.base.json ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/web/package.json apps/web/package.json +COPY apps/worker/package.json apps/worker/package.json +COPY packages/contracts/package.json packages/contracts/package.json +COPY packages/database/package.json packages/database/package.json +RUN npm ci +COPY . . +RUN npm run build + +FROM node:24-bookworm-slim AS api +WORKDIR /app +ENV NODE_ENV=production +COPY --from=build /app/package*.json ./ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/apps/api/dist ./apps/api/dist +COPY --from=build /app/apps/api/package.json ./apps/api/package.json +COPY --from=build /app/packages/contracts/dist ./packages/contracts/dist +COPY --from=build /app/packages/contracts/package.json ./packages/contracts/package.json +COPY --from=build /app/packages/database/dist ./packages/database/dist +COPY --from=build /app/packages/database/package.json ./packages/database/package.json +USER node +CMD ["node", "apps/api/dist/server.js"] + +FROM api AS worker +COPY --from=build /app/apps/worker/dist ./apps/worker/dist +COPY --from=build /app/apps/worker/package.json ./apps/worker/package.json +CMD ["node", "apps/worker/dist/index.js"] + +FROM nginx:1.29-alpine AS web +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/apps/web/dist /usr/share/nginx/html +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost/healthz || exit 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc3c08c --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Tech Library + +Tech Library is a modular, source-backed reference for modern web, app, and systems development. The Danish PWA serves layered English technical entries for readers at every experience level. Official-source monitoring proposes changes; a human editor always reviews them before publication. + +## Included + +- 32 curated starter topics across web, frontend, backend, data, mobile/desktop, DevOps, testing/observability, and security +- faceted browsing and SQLite FTS5 search +- immutable revisions, publication pointers, rollback, and claim-level provenance +- integrated editorial workspace with secure session and CSRF protection +- idempotent monitoring of official sources with a daily scheduler and manual trigger +- installable React/Vite PWA, Fastify API, SQLite/Drizzle data layer, and independent worker +- Docker Compose deployment, health checks, persistent data, backup and restore guidance + +## Quick start + +Requirements: Node.js 22+ and npm 10+. + +```powershell +npm install +npm run db:migrate +npm run db:seed +$env:ADMIN_PASSWORD_HASH = npm run --silent password:hash -w @tech-library/api -- "development password" +$env:SESSION_SECRET = "replace-this-with-a-long-random-development-secret" +npm run dev +``` + +Open `http://localhost:5173`. The API runs on port 3001. Local content is stored under `data/` and is intentionally ignored by Git. + +## Commands + +| Command | Purpose | +| --- | --- | +| `npm run dev` | Run web, API, and source worker | +| `npm run check` | Type-check, test, and build every workspace | +| `npm run db:migrate` | Apply idempotent SQLite migrations | +| `npm run db:seed` | Add the curated starter catalog | +| `docker compose up -d --build` | Build and run the production topology | + +## Repository structure + +```text +apps/web React, Vite, Radix UI, TanStack Query, Zustand, PWA +apps/api Fastify REST API, editorial policy, authentication +apps/worker Official-source monitoring and structured findings +packages/contracts Shared Zod schemas and public TypeScript types +packages/database Drizzle schema, migrations, seed catalog +deploy Reverse-proxy configuration +docs Architecture decisions and operations +scripts Backup and restore helpers +``` + +See [architecture](docs/architecture.md) and [operations](docs/operations.md) for system boundaries and production guidance. + +## Editorial flow + +1. The worker fetches enabled official sources and normalizes volatile page content. +2. A new fingerprint produces one deduplicated finding; failures only update source health. +3. An administrator reviews the source and creates an immutable draft revision. +4. A revision must be approved before it can be published. +5. Publication updates the active pointer and search index in one transaction; rollback selects a prior approved revision. + +## Development policy + +Use short feature branches and focused Conventional Commits. Tests and documentation belong in the same change as the behavior they describe. Comments should explain decisions or non-obvious constraints rather than restating code. diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..c33c37b --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,14 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + + location = /healthz { access_log off; return 200 "ok\n"; } + location /api/ { proxy_pass http://api:3001; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Real-IP $remote_addr; } + location / { try_files $uri $uri/ /index.html; } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5082fc4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + web: + build: + context: . + target: web + ports: + - "8080:80" + depends_on: + api: + condition: service_healthy + restart: unless-stopped + + api: + build: + context: . + target: api + environment: + NODE_ENV: production + PORT: 3001 + DATABASE_URL: /data/tech-library.db + WEB_ORIGIN: ${WEB_ORIGIN:-http://localhost:8080} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:?Set ADMIN_PASSWORD_HASH} + SESSION_SECRET: ${SESSION_SECRET:?Set SESSION_SECRET} + volumes: + - tech-library-data:/data + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3001/health').then(r=>{if(!r.ok)process.exit(1)})"] + interval: 15s + timeout: 5s + retries: 5 + restart: unless-stopped + + worker: + build: + context: . + target: worker + environment: + NODE_ENV: production + DATABASE_URL: /data/tech-library.db + SOURCE_POLL_INTERVAL_HOURS: ${SOURCE_POLL_INTERVAL_HOURS:-24} + volumes: + - tech-library-data:/data + depends_on: + api: + condition: service_healthy + restart: unless-stopped + +volumes: + tech-library-data: diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..efbb393 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,32 @@ +# Architecture + +Tech Library is split into deployable applications and reusable packages so content, presentation, automation, and infrastructure can evolve independently. + +```text +React/Vite PWA ──REST──> Fastify API ──> SQLite + ▲ ▲ + │ │ + Admin review Source worker +``` + +## Boundaries + +- `apps/web` owns Danish presentation, routing, query caching, and local UI state. +- `apps/api` owns authentication, authorization, publication rules, and versioned REST resources. +- `apps/worker` owns official-source collection, normalization, fingerprinting, and findings. +- `packages/contracts` owns runtime validation and shared public types. +- `packages/database` owns schema, migrations, connection policy, and curated seed data. + +Published revisions are immutable. `topics.active_revision_id` is the only publication pointer, so rollback does not rewrite history. Findings and draft revisions never become public merely because a worker produced them. + +## ADR-001: SQLite and one writer host + +SQLite is authoritative for v1 and runs on a persistent local volume. WAL mode supports concurrent readers while API and worker serialize short writes. Do not place the database on a network filesystem. Database access stays behind the database package and repository layer to preserve a future migration path. + +## ADR-002: Dynamic content delivery + +The PWA contains no production catalog bundle. Public content is fetched from the API, allowing editors to publish a revision without rebuilding the frontend. The service worker caches only the application shell and static assets. + +## ADR-003: Human publication gate + +Automated adapters may create source findings, never published revisions. A signed admin session and matching CSRF token are required for every mutating editorial request. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..2282ba4 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,20 @@ +# Operations + +## Deployment + +1. Generate an Argon2id hash with `npm run password:hash -w @tech-library/api -- "a strong password"`. +2. Copy `.env.example` to `.env`, set the hash, a random 32+ character session secret, and the public `WEB_ORIGIN`. +3. Run `docker compose up -d --build`. +4. Verify `http://host:8080/healthz` and the API health check through Docker Compose. + +Terminate TLS at a trusted reverse proxy and forward HTTPS to port 8080. Restrict filesystem access to the Docker volume and secrets file. + +## Backup and restore + +Run `scripts/backup.ps1` against a stopped instance or a host-installed `sqlite3` client. The command uses SQLite's online backup operation. Retain encrypted off-host copies and test restoration regularly. + +Before restoration, stop API and worker, validate the target path, run `scripts/restore.ps1 -Backup `, and restart the services. Confirm `/health` and a known topic afterward. + +## Monitoring + +All services expose structured container logs. Alert on API health failure, worker `source_poll.failed` events, a growing pending-findings queue, backup failure, and low volume capacity. Source fetch errors remain attached to subscriptions and never change published content. diff --git a/scripts/backup.ps1 b/scripts/backup.ps1 new file mode 100644 index 0000000..f359934 --- /dev/null +++ b/scripts/backup.ps1 @@ -0,0 +1,10 @@ +param( + [string]$Database = ".\data\tech-library.db", + [string]$Destination = ".\backups" +) +$ErrorActionPreference = "Stop" +New-Item -ItemType Directory -Force -Path $Destination | Out-Null +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$target = Join-Path $Destination "tech-library-$timestamp.db" +sqlite3 $Database ".backup '$target'" +Write-Host "Backup created: $target" diff --git a/scripts/restore.ps1 b/scripts/restore.ps1 new file mode 100644 index 0000000..45dbfc8 --- /dev/null +++ b/scripts/restore.ps1 @@ -0,0 +1,10 @@ +param( + [Parameter(Mandatory = $true)][string]$Backup, + [string]$Database = ".\data\tech-library.db" +) +$ErrorActionPreference = "Stop" +if (-not (Test-Path -LiteralPath $Backup)) { throw "Backup not found: $Backup" } +$parent = Split-Path -Parent $Database +if ($parent) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } +Copy-Item -LiteralPath $Backup -Destination $Database -Force +Write-Host "Database restored to: $Database"