Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:37:37 +02:00
commit ad8ad252a9
825 files changed
+124216

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.10",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+20
View File
@@ -0,0 +1,20 @@
.git
.github
.gitea
.claude
.env
.env.*
!.env.example
**/bin
**/obj
**/node_modules
**/dist
artifacts
data
backups
imports
attachments
postgres-data
playwright-report
test-results
*.log
+35
View File
@@ -0,0 +1,35 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.{cs,csproj,props,targets,slnx}]
indent_size = 4
[*.cs]
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
csharp_new_line_before_open_brace = all
csharp_style_namespace_declarations = file_scoped:suggestion
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = false:suggestion
# EF Core migrations are machine-generated by `dotnet ef migrations add` and should not
# be hand-edited to satisfy analyzer style preferences; the generator's shape is the
# source of truth here, not our usual code-quality rules.
[**/Persistence/Migrations/*.cs]
dotnet_diagnostic.CA1861.severity = none
dotnet_diagnostic.CA1852.severity = none
[*.md]
trim_trailing_whitespace = false
[*.ps1]
indent_size = 4
end_of_line = crlf
+21
View File
@@ -0,0 +1,21 @@
# Copy to .env for local Docker Compose use. Never commit the resulting .env file.
KNOWLEDGE_HUB_PORT=1240
KNOWLEDGE_HUB_IMAGE=itworx/knowledge-hub:local
KNOWLEDGE_HUB_BASE_URL=http://localhost:1240
KNOWLEDGE_HUB_ALLOWED_HOSTS=localhost;127.0.0.1;knowledge.example.com
# Semicolon-delimited IPs of the reverse proxy hop(s) directly in front of this container
# (e.g. an Authentik forward-auth outpost or NPM itself). Leave empty on a direct/LAN-only
# deployment. See docs/23-deployment-unraid.md "Reverse proxy".
KNOWLEDGE_HUB_TRUSTED_PROXIES=
POSTGRES_DB=knowledge_hub
POSTGRES_USER=knowledge_hub
POSTGRES_PASSWORD=replace-with-a-long-random-development-password
# nl-BE is the primary UI locale.
DEFAULT_LOCALE=nl-BE
TZ=Europe/Brussels
# Development only. Production setup is completed through the first-run owner flow.
ASPNETCORE_ENVIRONMENT=Production
+10
View File
@@ -0,0 +1,10 @@
* text=auto eol=lf
*.sln text eol=crlf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.pdf binary
*.zip binary
+108
View File
@@ -0,0 +1,108 @@
# Secrets and local configuration
.env
.env.*
!.env.example
secrets/
*.pem
*.key
*.pfx
*.p12
# Local docker-compose bind-mount data root (see docker-compose.yml's
# KNOWLEDGE_HUB_APPDATA_ROOT — real Postgres/attachment/key data for local
# verification only, never committed).
/.local-appdata/
# Build-time-generated release identity (scripts/generate-release-manifest.mjs,
# docs/adr/ADR-029-v2-r5-final-hardening.md) — regenerated fresh on every build, never
# a source-controlled file. Anchored to the repo root only (M8's .gitignore lesson:
# an unanchored pattern here would also match any nested release-manifest.json under
# src/tests, which must never happen).
/release-manifest.json
# .NET
**/bin/
**/obj/
*.user
*.suo
TestResults/
coverage/
artifacts/
# Versioned product-design evidence. Runtime/test artifacts remain ignored.
!artifacts/
artifacts/*
!artifacts/design/
artifacts/design/*
!artifacts/design/v2/
artifacts/design/v2/*
!artifacts/design/v2/current-product/
!artifacts/design/v2/current-product/**
!artifacts/stitch/
artifacts/stitch/*
!artifacts/stitch/v2/
!artifacts/stitch/v2/**
.nuget/
# Node
node_modules/
.pnpm-store/
dist/
.vite/
playwright-report/
test-results/
blob-report/
coverage-final.json
# Agent-local state and private operational memory
/.claude/agent-memory/
.claude/settings.local.json
CLAUDE.local.md
# IDE and OS
.vs/
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/
.DS_Store
Thumbs.db
# Runtime data — anchored to the repository root (leading `/`) so these never
# match same-named *source* directories at any depth (e.g.
# src/backend/ItWorx.KnowledgeHub.Domain/Attachments/, .../Application/Attachments/):
# an earlier unanchored `attachments/` pattern here matched those directories too
# (worse, case-insensitively on Windows/macOS default git configs, since git's
# default core.ignorecase silently matched "Attachments" as well), which meant every
# file under them was silently never committed from M4 onward despite building and
# testing successfully in every working copy that already had them on disk. Found via
# a real clean-room clone failing to build (M8 release gate), not by inspection —
# see ADR-017.
/data/
/backups/
/imports/
/attachments/
/postgres-data/
*.db
*.sqlite
*.sqlite3
*.dump
*.backup
# Local logs
*.log
logs/
# Python
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
# Temporary
tmp/
temp/
*.tmp
# Local one-off repository export/backup snapshots — never a deployable or
# reviewable artifact; see docs/v2/V2_HANDOFF.md §4.
/itworx-knowledge-hub.zip
+33
View File
@@ -0,0 +1,33 @@
# Contributing
Thank you for helping improve ItWorx Knowledge Hub.
## Before opening a change
- Keep MCP transport outside this repository.
- Use synthetic fixtures; never commit real knowledge, credentials, server addresses,
database dumps or production evidence.
- Preserve knowledge provenance, classification, redaction and publication approval.
- Treat agent settings and memory as internal development policy, never public source.
- Preserve server-side authorization, ETag concurrency and reversible data flows.
- Treat LeafWiki sources as immutable and read-only.
- Add behavior-focused tests and update documentation with the implementation.
- Do not weaken a gate or delete a failing test to make a change pass.
## Local checks
```bash
corepack pnpm install --frozen-lockfile
node scripts/verify.mjs --fast
```
Run `node scripts/verify.mjs --ci` before requesting review when Docker and
Testcontainers are available. Changes to migrations, imports, authorization,
attachments or backup/restore need focused integration tests against real PostgreSQL.
## Pull requests
Explain the user-visible outcome, security and migration impact, verification performed
and any remaining external validation. Also explain privacy and publication impact when
relevant. Keep commits reviewable and do not mix private deployment evidence into
reusable product changes. Report vulnerabilities according to `SECURITY.md`.
+11
View File
@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>
</Project>
+8
View File
@@ -0,0 +1,8 @@
<Project>
<Target Name="FailOnCommittedSecrets" BeforeTargets="BeforeBuild" Condition="'$(CI)' == 'true'">
<ItemGroup>
<ForbiddenFiles Include="$(MSBuildThisFileDirectory).env" Condition="Exists('$(MSBuildThisFileDirectory).env')" />
</ItemGroup>
<Error Text="A root .env file must never be present in CI." Condition="'@(ForbiddenFiles)' != ''" />
</Target>
</Project>
+35
View File
@@ -0,0 +1,35 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.13.0" />
<!-- Testcontainers.PostgreSql 4.13.0 transitively pulls SSH.NET 2025.1.0,
which Trivy flags as high severity (CVE-2026-48798). Test-scope only
(SSH.NET is used by Testcontainers' optional remote-Docker-context
support, not exercised by this project's own Docker-socket usage),
but pinned explicitly to the patched release regardless. -->
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
<PackageVersion Include="Markdig" Version="1.3.2" />
<PackageVersion Include="HtmlSanitizer" Version="9.1.974" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
<!-- Microsoft.Data.Sqlite 10.0.10 pulls SQLitePCLRaw.lib.e_sqlite3 2.1.11 by
default, which NuGet's vulnerability audit flags as high severity
(GHSA-2m69-gcr7-jv3q, CVE-2025-6965 — a SQLite memory-corruption issue fixed
upstream in SQLite 3.50.2). Pinned up to a patched SQLitePCLRaw release that
bundles a fixed SQLite build. -->
<PackageVersion Include="SQLitePCLRaw.core" Version="3.0.5" />
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.53.3" />
</ItemGroup>
</Project>
+15
View File
@@ -0,0 +1,15 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/backend/ItWorx.KnowledgeHub.Domain/ItWorx.KnowledgeHub.Domain.csproj" />
<Project Path="src/backend/ItWorx.KnowledgeHub.Application/ItWorx.KnowledgeHub.Application.csproj" />
<Project Path="src/backend/ItWorx.KnowledgeHub.Contracts/ItWorx.KnowledgeHub.Contracts.csproj" />
<Project Path="src/backend/ItWorx.KnowledgeHub.Infrastructure/ItWorx.KnowledgeHub.Infrastructure.csproj" />
<Project Path="src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/backend/ItWorx.KnowledgeHub.Domain.Tests/ItWorx.KnowledgeHub.Domain.Tests.csproj" />
<Project Path="tests/backend/ItWorx.KnowledgeHub.Architecture.Tests/ItWorx.KnowledgeHub.Architecture.Tests.csproj" />
<Project Path="tests/backend/ItWorx.KnowledgeHub.Api.Tests/ItWorx.KnowledgeHub.Api.Tests.csproj" />
<Project Path="tests/backend/ItWorx.KnowledgeHub.IntegrationTests/ItWorx.KnowledgeHub.IntegrationTests.csproj" />
</Folder>
</Solution>
+9
View File
@@ -0,0 +1,9 @@
Copyright (c) 2026 Jens / ItWorx
All rights reserved.
This repository and its contents are provided for the owner's private development,
deployment and evaluation. No permission is granted to copy, distribute, sublicense,
sell or publish the software without prior written permission from the copyright holder.
Third-party dependencies remain subject to their own licenses.
+35
View File
@@ -0,0 +1,35 @@
{
"name": "ItWorx Knowledge Hub",
"artifactType": "autonomous-build-kit",
"schemaVersion": 1,
"createdAt": "2026-07-31",
"rootInstructions": [
"START_HIER.md",
"CLAUDE_STARTPROMPT.md",
"CLAUDE.md"
],
"milestonePlan": "product/milestones.json",
"acceptanceMatrix": "docs/27-acceptance-matrix.md",
"mcpBoundary": "Separate future repository; API readiness only",
"productionTopology": [
"knowledge-hub application",
"PostgreSQL"
],
"defaultHostPort": 1240,
"primaryLocale": "nl-BE",
"validationReport": "BUNDLE_VALIDATION.md",
"baseline": {
"dotnetSdk": "10.0.302",
"aspnetRuntime": "10.0.11",
"dotnetImageOs": "noble",
"node": "24.18.1",
"pnpm": "11.14.0",
"postgresql": "18.4",
"react": "19.2.8",
"vite": "8.2.0",
"vitest": "4.1.10",
"playwright": "1.62.1"
},
"milestones": 10,
"acceptanceCriteria": 71
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageRestore>
<add key="enabled" value="True" />
</packageRestore>
</configuration>
+3
View File
@@ -0,0 +1,3 @@
# Public source export
This source snapshot was generated from the private canonical repository at revision `3b1751d0d9dee551aa6994eee0093704dc334817`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
File diff suppressed because it is too large. Load diff
+123
View File
@@ -0,0 +1,123 @@
# ItWorx Knowledge Hub
A self-hosted knowledge, documentation and project-intelligence platform for people
who want the speed of Markdown with the safety of revisions, permissions and verified
backups.
Knowledge Hub combines a technical wiki, project dossiers, architecture decisions and
runbooks in one searchable application. It remains fully useful without AI. A scoped
REST API supports external automation, while MCP transport deliberately lives outside
this repository.
## What you can do
- Organize Markdown pages in a fast, hierarchical knowledge tree.
- Search titles, content and tags with PostgreSQL full-text and fuzzy matching.
- Edit safely with private drafts, immutable revisions and explicit conflict handling.
- Upload deduplicated attachments with signature-based type validation.
- Track projects, milestones, updates, architecture decisions and runbook validation.
- Import LeafWiki content through a read-only, preflighted and reversible workflow.
- Create scoped, revocable service-account tokens for the versioned `/api/v1` API.
- Back up the database and attachments together, then verify a clean-target restore.
## Screenshots
These captures come from a local instance populated with synthetic demo data.
![Synthetic workspace dashboard](docs/assets/screenshots/02-dashboard.png)
![Synthetic Markdown knowledge workspace](docs/assets/screenshots/03-knowledge.png)
## Quick start
Requirements: Docker with Compose, Node.js 24 and Corepack.
```bash
cp .env.example .env
# Set a strong, unique POSTGRES_PASSWORD in .env.
node scripts/doctor.mjs
docker compose up --build -d
```
Open `http://localhost:1240` and complete the one-time owner setup. The default stack
contains one application container and one PostgreSQL container; PostgreSQL is not
published to the host.
For a production deployment, terminate TLS at a trusted reverse proxy, use mounted
secrets, persist every documented data volume and test restore before relying on a
backup. Start from the reviewed [Docker Compose configuration](docker-compose.yml) and
the [backup/restore guide](docs/guides/back-ups-herstel-en-unraid-beheer.md).
## Architecture
```text
Browser or scoped API client
|
HTTPS
|
ASP.NET Core + React
|
PostgreSQL
|
attachments / backups
```
The backend is a modular monolith with separate Domain, Application, Contracts,
Infrastructure and API projects. React is built into the production image. Markdown is
the canonical human-readable content format, PostgreSQL is the transactional source of
truth, and attachments are stored by SHA-256 outside the database.
## Development and verification
```bash
corepack pnpm install --frozen-lockfile
node scripts/verify.mjs --fast
node scripts/verify.mjs --ci
```
The CI gate covers formatting, compilation, static analysis, backend and frontend
tests, OpenAPI consistency and the production image. The release gate adds dependency,
secret and container scans plus the full-scale search proof:
```bash
node scripts/verify.mjs --release
```
Integration tests use real PostgreSQL through Testcontainers. Browser tests exercise
desktop and mobile flows and include automated accessibility checks.
## API and automation
The OpenAPI contract is in [docs/api/openapi.yaml](docs/api/openapi.yaml). External
clients use one-time-display service-account tokens with explicit scopes. Mutating
content routes use ETags and `If-Match`; retryable creates support `Idempotency-Key`.
This repository does not contain an MCP server, shell executor, arbitrary script
runtime or runbook execution engine.
## Security and data safety
- Content is private by default and authorization is enforced server-side.
- Cookie mutations require CSRF protection; API clients use scoped bearer tokens.
- Normal deletion uses trash and retention; permanent purge is an explicit admin act.
- LeafWiki inspection and import never modify the source.
- Restore targets must be clean and are verified against backup manifests and hashes.
- The production container runs non-root with a read-only root filesystem and dropped
capabilities.
Report vulnerabilities using [SECURITY.md](SECURITY.md). Do not include secrets or real
private knowledge in an issue.
## Project status and limitations
The implemented platform includes the wiki, search, projects, ADRs, runbooks,
attachments, reversible import, scoped API and backup/restore path. Current limitations
include no real-time collaborative editing, no built-in MCP transport, no arbitrary
runbook execution and no public anonymous publishing by default.
Historical delivery and production evidence is retained in the private canonical
repository. The `itworx-knowledge-hub-Public` repository is automatically generated from the curated source boundary after merges to private canonical `main`. The manifest identifies the exact source revision. Private history, real knowledge, operational evidence and internal Actions remain excluded.
## Contributing and license
See [CONTRIBUTING.md](CONTRIBUTING.md) before proposing a change. The project remains proprietary under [LICENSE](LICENSE). Public visibility grants no additional redistribution or open-source rights; third-party dependencies retain their own terms.
+45
View File
@@ -0,0 +1,45 @@
# Security policy
## Reporting a vulnerability
Do not open a public issue containing exploit details, credentials, private knowledge,
deployment addresses or production evidence.
Until a monitored public security address is configured, use Gitea's private reporting
channel or contact the repository owner privately. A public release must replace this
temporary owner-only route with a monitored address routed to at least two maintainers.
Include the affected version or commit, the security boundary involved, reproduction
steps using synthetic data, expected impact and any proposed mitigation. Never test
against a deployment or data set you do not own or have explicit permission to assess.
## Supported code
Security updates target the current default branch and the latest tagged release. Older
releases may not receive backports.
## Repository hygiene
Private agent memory, live environment configuration, production data and host-specific
access details belong outside version control. Shared agent settings stay
environment-neutral; workstation permissions belong in ignored local settings.
Never commit live `.env` files, service-account credentials, production backups,
personal data or private SSH material.
## Knowledge boundary
Access to canonical product, operational and agent context does not make it suitable
for public retrieval or publication. Preserve source classification, provenance,
redaction, publication approval and least-privilege tool access. Never include private
correspondence or legal, medical or employment material in a public source export.
## Security invariants
- Private content remains private by default.
- Authorization is enforced in application services, not only in the UI.
- Service-account tokens are scoped, revocable and stored only as verification data.
- LeafWiki sources are read-only and imports are preflighted and reversible.
- Runbooks are documentation and are never executed by the platform.
- The application exposes no shell, arbitrary SQL or arbitrary filesystem capability.
- The production container remains non-root, capability-dropped and read-only except
for documented data mounts.
+84
View File
@@ -0,0 +1,84 @@
# syntax=docker/dockerfile:1.7
FROM node:24.18.1-bookworm-slim@sha256:235600a8101ab264e117b1768e925532262668dc9b581ef1dd7d96ced463b8e7 AS web-build
WORKDIR /repo
RUN corepack enable
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY src/frontend/package.json src/frontend/package.json
COPY tests/e2e/package.json tests/e2e/package.json
RUN pnpm install --filter @itworx/knowledge-hub-web... --frozen-lockfile
COPY src/frontend src/frontend
RUN pnpm --filter @itworx/knowledge-hub-web build
# Canonical release identity (docs/adr/ADR-029-v2-r5-final-hardening.md): generated
# here, not in the .NET SDK stage, because this is the one build stage that already
# has both Node.js and the real built frontend output to hash — never a second,
# independently-drifting source of truth. Computed only from what is actually inside
# *this* build (source commit, the real docs/api/openapi.yaml, the real dist/ output);
# deployment-time-only fields (image tag/ID/digest, deployment timestamp, migration
# range) are deliberately left null here — see the script's own header comment.
ARG BUILD_COMMIT=development
ARG RELEASE_TAG=
ARG IMAGE_REPOSITORY=itworx/knowledge-hub
ARG IMAGE_TAG=
COPY scripts/generate-release-manifest.mjs scripts/generate-release-manifest.mjs
COPY docs/api/openapi.yaml docs/api/openapi.yaml
RUN BUILD_COMMIT=${BUILD_COMMIT} RELEASE_TAG=${RELEASE_TAG} IMAGE_REPOSITORY=${IMAGE_REPOSITORY} IMAGE_TAG=${IMAGE_TAG} \
node scripts/generate-release-manifest.mjs /repo/release-manifest.json
FROM mcr.microsoft.com/dotnet/sdk:10.0.302-noble@sha256:72dd743782f2ae7e5476fd64f6a460045e3998dc862218b80e6944cba79a01b0 AS backend-build
WORKDIR /repo
COPY global.json Directory.Build.props Directory.Build.targets Directory.Packages.props NuGet.Config .editorconfig ./
COPY ItWorx.KnowledgeHub.slnx ./
COPY src/backend src/backend
COPY tests/backend tests/backend
RUN dotnet restore ItWorx.KnowledgeHub.slnx
RUN dotnet publish \
src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj \
--configuration Release \
--no-restore \
--output /app/publish \
/p:UseAppHost=false
RUN rm -rf /app/publish/wwwroot/*
COPY --from=web-build /repo/src/frontend/dist /app/publish/wwwroot
COPY --from=web-build /repo/release-manifest.json /app/publish/release-manifest.json
FROM mcr.microsoft.com/dotnet/aspnet:10.0.11-noble@sha256:a4556ed033fa96f984bb7a8d348851cb2d36b1281dd2420070045f664fbb5f94 AS runtime
ARG BUILD_COMMIT=development
ENV ASPNETCORE_URLS=http://0.0.0.0:8080 \
DOTNET_EnableDiagnostics=0 \
Build__Commit=${BUILD_COMMIT}
RUN apt-get update \
&& apt-get install --yes --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 10001 knowledgehub \
&& useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin knowledgehub \
&& mkdir -p \
/app \
/var/lib/knowledge-hub/data \
/var/lib/knowledge-hub/attachments \
/var/lib/knowledge-hub/imports \
/var/lib/knowledge-hub/exports \
/var/lib/knowledge-hub/backups \
/var/lib/knowledge-hub/keys \
&& chown -R 10001:10001 /app /var/lib/knowledge-hub
WORKDIR /app
COPY --from=backend-build --chown=10001:10001 /app/publish ./
USER 10001:10001
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=10 \
CMD ["curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8080/health/live"]
ENTRYPOINT ["dotnet", "ItWorx.KnowledgeHub.Api.dll"]
+105
View File
@@ -0,0 +1,105 @@
name: itworx-knowledge-hub
# Every writable volume is a host bind mount below KNOWLEDGE_HUB_APPDATA_ROOT,
# defaulting to the project-local ./.local-appdata directory. Keep this storage model
# stable during upgrades: changing a populated deployment to fresh named volumes can
# make the application appear to have lost data even though the original bind-mounted
# files still exist. Back up and test restore before changing volume mappings.
services:
db:
image: postgres:18.4-bookworm@sha256:882236b897e39051d2368c5ccc6cda944904723506b2dfc97f2a8f5bc9afa382
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-knowledge_hub}
POSTGRES_USER: ${POSTGRES_USER:-knowledge_hub}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in the deployment environment}
TZ: ${TZ:-Europe/Brussels}
healthcheck:
test:
- CMD-SHELL
- pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
volumes:
- ${KNOWLEDGE_HUB_APPDATA_ROOT:-./.local-appdata}/postgres:/var/lib/postgresql
networks:
- knowledge-hub-internal
security_opt:
- no-new-privileges:true
app:
image: ${KNOWLEDGE_HUB_IMAGE:-itworx/knowledge-hub:local}
build:
context: .
dockerfile: deploy/docker/Dockerfile
args:
BUILD_COMMIT: ${BUILD_COMMIT:-development}
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT:-Production}
ASPNETCORE_URLS: http://0.0.0.0:8080
KnowledgeHub__Database__Host: db
KnowledgeHub__Database__Port: "5432"
KnowledgeHub__Database__Name: ${POSTGRES_DB:-knowledge_hub}
KnowledgeHub__Database__Username: ${POSTGRES_USER:-knowledge_hub}
KnowledgeHub__Database__Password: "${POSTGRES_PASSWORD}"
KnowledgeHub__Database__IncludeErrorDetail: "false"
KnowledgeHub__BaseUrl: ${KNOWLEDGE_HUB_BASE_URL:-http://localhost:1240}
AllowedHosts: "${KNOWLEDGE_HUB_ALLOWED_HOSTS:-localhost;127.0.0.1;[::1]}"
# Semicolon-delimited IPs of reverse proxies allowed to set X-Forwarded-For/-Proto
# (docs/23-deployment-unraid.md "Reverse proxy"). Left empty, forwarded headers from
# any source are ignored and every cookie is issued as if the request were plain HTTP.
KnowledgeHub__TrustedProxies: "${KNOWLEDGE_HUB_TRUSTED_PROXIES:-}"
KnowledgeHub__PrimaryLocale: ${DEFAULT_LOCALE:-nl-BE}
KnowledgeHub__TimeZone: ${TZ:-Europe/Brussels}
KnowledgeHub__Paths__Data: /var/lib/knowledge-hub/data
KnowledgeHub__Paths__Attachments: /var/lib/knowledge-hub/attachments
KnowledgeHub__Paths__Imports: /var/lib/knowledge-hub/imports
KnowledgeHub__Paths__Exports: /var/lib/knowledge-hub/exports
KnowledgeHub__Paths__Backups: /var/lib/knowledge-hub/backups
KnowledgeHub__Paths__Keys: /var/lib/knowledge-hub/keys
Build__Commit: ${BUILD_COMMIT:-development}
TZ: ${TZ:-Europe/Brussels}
ports:
- "${KNOWLEDGE_HUB_PORT:-1240}:8080"
healthcheck:
test:
- CMD
- curl
- --fail
- --silent
- --show-error
- http://127.0.0.1:8080/health/live
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
volumes:
- ${KNOWLEDGE_HUB_APPDATA_ROOT:-./.local-appdata}/data:/var/lib/knowledge-hub/data
- ${KNOWLEDGE_HUB_APPDATA_ROOT:-./.local-appdata}/attachments:/var/lib/knowledge-hub/attachments
- ${KNOWLEDGE_HUB_APPDATA_ROOT:-./.local-appdata}/imports:/var/lib/knowledge-hub/imports
- ${KNOWLEDGE_HUB_APPDATA_ROOT:-./.local-appdata}/exports:/var/lib/knowledge-hub/exports
- ${KNOWLEDGE_HUB_APPDATA_ROOT:-./.local-appdata}/backups:/var/lib/knowledge-hub/backups
- ${KNOWLEDGE_HUB_APPDATA_ROOT:-./.local-appdata}/keys:/var/lib/knowledge-hub/keys
tmpfs:
- /tmp:size=256m,mode=1770,uid=10001,gid=10001
read_only: true
user: "10001:10001"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
networks:
- knowledge-hub-internal
- knowledge-hub-edge
networks:
knowledge-hub-internal:
internal: true
knowledge-hub-edge:
+74
View File
@@ -0,0 +1,74 @@
# Product vision
## Product
**ItWorx Knowledge Hub** is a self-hosted knowledge, documentation and project
intelligence platform for one owner and trusted collaborators.
It replaces the user's current LeafWiki workflow and becomes the durable home for:
- school notes and course material;
- certification notes;
- technical reference documentation;
- infrastructure documentation;
- troubleshooting knowledge;
- operational runbooks;
- project dossiers, decisions and progress;
- imported attachments and supporting files.
## Problem
The current knowledge is primarily wiki-shaped, while project knowledge is spread across
repositories, status notes, deployment details and personal memory. A conventional wiki
can store those texts, but it does not provide enough structure to answer questions such
as:
- What is the current verified state of a project?
- Why was an architectural decision made?
- Which runbook was last validated?
- Which documents refer to an outdated system?
- What changed since a previous project milestone?
- Which content came from the legacy LeafWiki import?
The new platform must preserve the speed and durability of Markdown while making project
and operational knowledge explicit, searchable and auditable.
## Vision statement
> A private, reliable and recoverable knowledge hub that combines a fast technical wiki
> with structured project intelligence, and that can later serve external tools through
> a secure API without depending on those tools.
## Product principles
1. **Knowledge remains useful without AI.**
2. **Markdown remains portable and readable.**
3. **Structure is added where it creates real retrieval value.**
4. **User data is never silently overwritten or discarded.**
5. **Imports, revisions and backups are reversible and verifiable.**
6. **Search respects permissions at every layer.**
7. **The UI optimizes reading, writing and retrieval—not decoration.**
8. **The first production topology stays operationally simple.**
9. **External automation uses stable contracts and least privilege.**
10. **A future MCP server remains an independent service.**
## Primary success outcomes
- The owner can migrate representative LeafWiki content without source modification or
lost pages, links and attachments.
- School and project documentation can be found in seconds.
- Projects have one reliable current-state view and a traceable history.
- Accidental edits and deletions can be recovered.
- A complete backup can be restored into a clean environment.
- The system runs predictably on Unraid.
- A separate future MCP service can retrieve compact, permission-filtered context without
scraping the UI or reading the database directly.
## Product identity
- Product name: **ItWorx Knowledge Hub**
- Primary language: Dutch (`nl-BE`)
- Technical identifiers and API field names: English
- Suggested hostname: `knowledge.example.com` (replace it with an operator-controlled
HTTPS hostname when configuring the reverse proxy).
- Default local host port: `1240`
+96
View File
@@ -0,0 +1,96 @@
# Scope and non-goals
## In scope for the first complete release
### Knowledge management
- Hierarchical pages and collections.
- Markdown editing and safe rendering.
- Stable internal links.
- Tags, backlinks, favorites and recent pages.
- Templates and typed document metadata.
- Draft recovery and optimistic-concurrency conflict handling.
- Immutable revisions, comparison and restoration.
- Trash, retention and controlled purge.
- Permission-aware full-text and fuzzy title search.
- Attachments with deduplication and integrity hashes.
- Durable Markdown/project export.
### Structured project knowledge
- Project dossiers and lifecycle status.
- Current-state summary.
- Technology and external-link metadata.
- Project timeline entries.
- Milestones.
- Linked project documents.
- Architecture Decision Records.
- Runbook profiles and validation dates.
- Compact project-context assembly.
### Migration and operations
- Read-only LeafWiki discovery.
- Deterministic preflight.
- Idempotent import.
- Validation report and rollback by import.
- PostgreSQL migrations.
- Backup, retention, clean restore and integrity validation.
- Health endpoints, structured logs and audit trail.
- Docker Compose deployment on Unraid.
- Reverse-proxy documentation.
### Integration surface
- Versioned REST API.
- OpenAPI description.
- Scoped service accounts.
- Revocable one-time-display API tokens.
- Permission-filtered search, pages, projects and context.
- Controlled write endpoints with optimistic concurrency.
- Transactional outbox.
## Explicit non-goals
The first release does **not** include:
- an MCP server or MCP transport;
- an embedded chatbot or autonomous AI agent;
- automatic code execution or shell execution from runbooks;
- live repository write access;
- automatic Gitea synchronization;
- public multi-tenant SaaS hosting;
- real-time collaborative editing;
- native mobile applications;
- a marketplace or plugin runtime;
- arbitrary user-defined server-side scripts;
- Elasticsearch, Redis, Kafka, Kubernetes or microservices;
- public anonymous publishing by default;
- a full Notion-style relational database builder;
- spreadsheet, whiteboard or presentation editing;
- OCR of imported documents;
- secrets management or password-vault behavior.
## Deferred candidates
These may be considered only after release evidence and a new milestone/ADR:
- a separate MCP server;
- read-only Gitea document synchronization;
- semantic search with embeddings;
- public share links with expiry;
- additional workspaces;
- WebAuthn/passkeys;
- external identity providers;
- scheduled page review reminders;
- offline-first editing;
- document-level approval workflows.
## Scope-change rule
A new feature cannot displace a P0 acceptance criterion. It must be:
1. added to the backlog;
2. assigned a priority and milestone;
3. evaluated for security, migration and operational impact;
4. accepted through an ADR when it changes a core boundary.
+121
View File
@@ -0,0 +1,121 @@
# Non-functional requirements
## Reliability and integrity
- Published content, revisions, audit and outbox changes are transactionally consistent.
- A failed request cannot leave a partially published page.
- Attachment references cannot point to missing committed blobs without a detectable
integrity violation.
- Imports are idempotent and reversible by import ID.
- Backups are considered successful only after manifest and hash validation.
- Restore is tested into a clean target.
- Database migrations are forward-tested from every supported released schema.
## Performance targets
Targets are measured on a modest x86-64 Unraid host with SSD-backed PostgreSQL after warm
startup, using the documented synthetic dataset.
| Operation | Dataset | Target |
|---|---:|---:|
| Application shell first authenticated response | normal | p95 < 1.0 s |
| Page API read | 100,000 pages | p95 < 300 ms |
| Search first page | 100,000 pages / 1,000,000 revisions | p95 < 750 ms |
| Lazy tree child load | 10,000-node collection | p95 < 300 ms |
| Page publish without attachment | normal | p95 < 500 ms |
| Project context bundle | 10,000 project-linked records total | p95 < 800 ms |
| API health liveness | normal | p95 < 100 ms |
The UI should remain responsive under a 4× CPU slowdown profile. Performance regressions
must be measured; arbitrary timeout increases are not fixes.
## Capacity targets
Initial validated design target:
- 100,000 current pages;
- 1,000,000 page revisions;
- 10,000 projects;
- 1,000,000 project updates/relations combined;
- 100,000 attachment references;
- 500 GiB attachment storage, constrained by host capacity;
- 20 concurrent interactive sessions;
- 10 service accounts.
These are validation ceilings, not expected normal usage.
## Availability
- Single-host design; no false high-availability claim.
- Graceful shutdown and database connection draining.
- Readiness becomes false when mandatory dependencies are unavailable.
- Liveness remains independent of transient PostgreSQL unavailability.
- Background jobs use leases/idempotency to recover after restart.
- No required outbound internet dependency for normal operation.
## Security
- Private by default.
- Least privilege.
- Secure session cookies and CSRF protection.
- Strong password policy and lockout.
- Scoped, revocable service tokens.
- Authorization at use-case/query boundaries.
- Safe Markdown and attachment handling.
- Security headers.
- Sensitive-data log redaction.
- Non-root production container.
- Dependency, secret and container scans in release gate.
- Threat model maintained in `docs/26-threat-model.md`.
## Accessibility
- WCAG 2.2 AA target for implemented flows.
- Keyboard-complete operation.
- Visible focus.
- Semantic landmarks and headings.
- Dialog focus trap/return.
- Sufficient contrast.
- 200% zoom and narrow viewport without loss of function.
- Reduced-motion support.
- Automated axe coverage plus manual keyboard/screen-reader spot checks.
## Compatibility
Supported clients for release validation:
- current stable Chromium-based desktop browser;
- current stable Firefox desktop;
- representative Android Chromium viewport;
- touch and keyboard interaction.
The server supports Linux containers on x86-64. ARM64 support is desirable when the
dependency chain and CI can verify it.
## Maintainability
- Modular-monolith dependency rules enforced by tests.
- Strict nullability and TypeScript.
- Warnings as errors.
- Versioned API.
- Documented migrations and ADRs.
- One high-level verification command.
- No hidden manual production build steps.
- Current-state and evidence documents updated per milestone.
## Observability
- Structured JSON logs in production.
- Correlation/request ID.
- Health status and bounded system metrics.
- Audit trail separate from diagnostic logs.
- No user page bodies or attachment contents in normal logs.
- Background-job state visible to administrators.
## Localization and timezone
- Primary UI locale: `nl-BE`.
- Store instants in UTC.
- Render dates/times in the configured installation timezone, default
`Europe/Brussels`.
- Store locale-independent status values and translate in the UI.
+203
View File
@@ -0,0 +1,203 @@
# System architecture
## Decision summary
The first release is a **modular monolith** deployed as one application container plus
PostgreSQL.
The production application contains:
- ASP.NET Core API and authentication;
- compiled React static assets;
- application/use-case modules;
- background hosted services;
- filesystem attachment adapter;
- import/export and backup orchestration.
This topology minimizes operational overhead on Unraid while retaining internal
boundaries that can be tested and evolved.
## Context
```mermaid
flowchart LR
User[Browser user] -->|HTTPS| Proxy[Reverse proxy]
Proxy --> App[ItWorx Knowledge Hub]
ApiClient[Scoped API client] -->|HTTPS /api/v1| Proxy
App --> Db[(PostgreSQL)]
App --> Files[(Attachments / imports / backups)]
Leaf[Read-only LeafWiki snapshot] -->|Read-only import mount| App
FutureMCP[Future separate MCP server] -.->|HTTPS /api/v1| Proxy
```
The future MCP server is shown only as an external client. It is not built or deployed by
this repository.
## Production containers
```text
knowledge-hub
postgres
```
Development may run the React Vite server separately for fast refresh. Production serves
its built files through ASP.NET Core.
## Logical projects
```text
ItWorx.KnowledgeHub.Domain
Pure domain concepts and invariants
ItWorx.KnowledgeHub.Application
Use cases, authorization requirements, ports and orchestration
ItWorx.KnowledgeHub.Contracts
Stable request/response and event contracts
ItWorx.KnowledgeHub.Infrastructure
EF Core, PostgreSQL, files, hashing, import adapters, jobs
ItWorx.KnowledgeHub.Api
HTTP, auth/session transport, middleware, static SPA hosting
src/frontend
React application
tests/backend/*
tests/e2e
```
Allowed dependency direction:
```mermaid
flowchart TD
Api --> Application
Api --> Contracts
Api --> Infrastructure
Infrastructure --> Application
Infrastructure --> Domain
Infrastructure --> Contracts
Application --> Domain
Application --> Contracts
```
Forbidden:
- Domain → Application/Infrastructure/API/UI
- Application → Infrastructure/API/UI
- Contracts → Infrastructure/API/UI
- Frontend → database/filesystem
Architecture tests must enforce this.
## Application modules
Initial module boundaries:
- Identity
- Workspaces
- Knowledge
- Search
- Attachments
- Projects
- Decisions
- Runbooks
- Imports
- Exports
- Audit
- Integration
- Operations
Modules may share infrastructure composition but must not mutate each other's tables
through ad hoc DbContext access. Cross-module operations use application contracts and a
single transaction boundary where required.
## Data flow: publish page
```mermaid
sequenceDiagram
participant UI
participant API
participant App as Application
participant DB as PostgreSQL
participant Outbox
UI->>API: PUT page + If-Match
API->>App: PublishPage command
App->>DB: Load page + authorization
App->>App: Validate version, Markdown and links
App->>DB: Update current page
App->>DB: Insert immutable revision
App->>DB: Replace derived link/search records
App->>DB: Insert audit event
App->>Outbox: Insert page.updated event
DB-->>App: Commit
App-->>API: DTO + new ETag
API-->>UI: 200 OK
```
## Data flow: attachment upload
Uploads use a bounded streaming pipeline:
1. authenticate and authorize;
2. stream to a quarantined temporary file with a hard size limit;
3. calculate SHA-256;
4. inspect signature and type;
5. reject active/unsafe inline behavior;
6. atomically move to content-addressed storage;
7. commit blob/reference metadata;
8. schedule safe preview generation where supported;
9. remove temporary data on failure.
No ordinary upload is executed or automatically extracted.
## Data flow: import
1. Discover source in read-only mode.
2. Fingerprint all relevant source items.
3. Select a versioned adapter.
4. Produce a preflight plan with conflicts/ambiguities.
5. Persist the plan.
6. Execute in bounded transactional batches with import provenance.
7. Resolve stable internal relations.
8. Validate counts, tree, links and file hashes.
9. Re-fingerprint the source.
10. Mark complete only when source is unchanged and validation passes.
## Background jobs
Use database-backed jobs/leases rather than a mandatory external broker.
Initial job types:
- search projection repair;
- attachment preview;
- orphan blob cleanup;
- import execution and validation;
- export generation;
- backup creation and verification;
- retention purge;
- outbox dispatch/retention;
- integrity scan.
Jobs require idempotency, bounded retries, backoff, cancellation and administrator-visible
status.
## Configuration
Configuration precedence:
1. secure mounted secret/file where applicable;
2. environment variables;
3. `appsettings.Production.json`;
4. compiled safe defaults.
Database credentials and key material are not stored in repository configuration.
## Future evolution
Possible later extraction candidates are backup/export workers or an MCP adapter, but only
after measured pressure. The domain and API boundaries must not assume extraction is
required.
+325
View File
@@ -0,0 +1,325 @@
# Domain model
## Installation and workspace
### Installation
Represents system-wide configuration and bootstrap state.
Invariants:
- exactly one installation record;
- owner bootstrap can complete only once;
- configured schema/product version is traceable;
- primary locale and timezone are validated.
### Workspace
The first release exposes one active workspace, but content rows carry `WorkspaceId` to
make authorization boundaries explicit.
Fields:
- ID
- Name
- Slug
- CreatedAt
- Settings version
- ArchivedAt (future-safe)
### Membership
Connects an interactive user to a workspace role:
- Reader
- Editor
- Administrator
Invariants:
- at least one enabled administrator exists;
- disabled users cannot act through existing sessions;
- service accounts are not memberships and receive no implicit role.
## Knowledge
### Page
The current published state and tree location.
Core fields:
- ID / WorkspaceId
- ParentPageId
- SortKey
- Title
- Slug
- Markdown
- DocumentType
- DocumentStatus
- CurrentRevisionNumber
- ConcurrencyVersion
- CreatedBy / UpdatedBy
- CreatedAt / UpdatedAt / PublishedAt
- TrashedAt / PurgeAfter
Invariants:
- title is non-empty and bounded;
- parent is in the same workspace;
- a page cannot parent itself or create a cycle;
- current revision number increases monotonically;
- ordinary changes cannot mutate immutable revisions;
- trashed pages are excluded from normal navigation/search.
### PageRevision
Immutable snapshot of semantically relevant page state:
- Page identity and revision number
- Title, slug and Markdown
- Parent/order snapshot
- Document type/status
- typed metadata snapshot
- author/source/change summary
- created timestamp
- content hash
Restoring creates a new revision.
### Draft
User-private mutable work based on a specific page revision:
- User / Page or draft key
- Base revision/concurrency version
- Markdown and metadata
- UpdatedAt / ExpiresAt
A draft never silently publishes.
### PageLink
Derived relation from an authored internal link:
- SourcePageId
- target stable PageId when resolved
- original target text
- display label
- occurrence/anchor information
- resolution state
Derived records can be rebuilt from revisions.
### PageRelation
Explicit semantic relationship:
- Source / Target
- relation type
- label
- creator/time
### Tag and PageTag
Tags are case-insensitively unique per workspace. Display casing is preserved.
### Favorite and RecentVisit
User-specific convenience records. They never grant content access.
## Attachments
### AttachmentBlob
Physical deduplicated content:
- SHA-256 key
- byte length
- detected media type
- storage state
- created timestamp
- integrity status
- preview state
Blob paths are derived only from the hash.
### AttachmentReference
Logical attachment:
- ID / WorkspaceId
- Blob hash
- Original filename
- Description / alt text / caption
- Uploader
- created/trashed timestamps
### AttachmentUsage
Connects a reference to a page, project update or other supported owner.
Deleting a usage does not remove shared content. Orphan cleanup follows retention.
## Projects
### Project
- ID / WorkspaceId
- Name / Slug
- Summary
- Lifecycle status
- Priority
- Start/target/completion dates
- Repository/live/documentation links
- Current-state Markdown
- Owner
- Created/updated metadata
- concurrency version
### ProjectDocument
Connects a project to a page with a role:
- Overview
- Architecture
- Deployment
- Testing
- Troubleshooting
- Security
- Release
- Other
### ProjectUpdate
Immutable chronological update:
- project
- timestamp and author/source
- category
- title
- summary Markdown
- verification status
- optional commit/release/issue/URL
- linked pages/decisions/milestones
- correction/supersession link
Corrections append; they do not rewrite historical audit.
### ProjectMilestone
Named project milestone, distinct from repository build milestones:
- state
- target/completion date
- summary
- order
- linked updates/pages
### TechnologyTag
Normalized project technology classification.
## Decisions
### DecisionRecord
Typed profile linked to one canonical page:
- ID / ProjectId / PageId
- Decision number
- Status
- DecisionDate
- SupersedesDecisionId
- SupersededByDecisionId
- Summary
The page holds durable Markdown sections. The profile holds queryable lifecycle metadata.
Invariants:
- accepted numbering is unique per project;
- supersession cannot cycle;
- a superseded record identifies its replacement;
- decision history is not deleted during ordinary project deletion.
## Runbooks
### RunbookProfile
Typed profile linked to a canonical page:
- ID / PageId
- Owner
- Risk level
- LastValidatedAt / ValidatedBy
- validation status
- expected section anchors
The page's Markdown must contain or explicitly mark:
- Purpose
- Scope
- Prerequisites
- Steps
- Validation
- Rollback
- Risks
The platform displays and validates structure; it never executes steps.
## Imports
### ImportJob
- ID / adapter/version
- source fingerprint and safe descriptor
- status
- preflight plan version
- started/completed timestamps
- actor
- counts and validation outcome
- rollback status
### ImportItem
One discovered source item and its target mapping:
- stable source identity/hash
- source kind/path descriptor
- target entity ID
- planned action
- status
- conflict/diagnostic data
All imported entities carry import provenance sufficient for scoped rollback.
## Audit and integration
### AuditEvent
Append-only security and content action record:
- actor kind and ID
- source (web, API, import, system)
- action
- target type/ID
- safe summary
- correlation ID
- timestamp
- network/user-agent metadata under retention policy
Page bodies, passwords and token secrets are excluded.
### OutboxMessage
Transactional integration event:
- ID
- event type/version
- aggregate identity
- safe payload
- occurred/available time
- processing attempts/status
The initial product may use it for cache/index repair and future integrations.
+256
View File
@@ -0,0 +1,256 @@
# Database schema
This document defines the intended relational shape. EF Core migrations are the
executable source once implemented. Names may be refined in M0/M1 through an ADR, but
constraints and invariants must remain.
## Conventions
- PostgreSQL `uuid` primary keys generated by the application or database.
- `timestamptz` for instants.
- UTC application semantics.
- lowercase snake_case database names.
- explicit foreign keys and delete behavior.
- `citext` or normalized columns for case-insensitive unique values.
- `jsonb` only for versioned, non-core metadata and import diagnostics.
- integer/bigint monotonic revision numbers where scoped.
- byte/string concurrency token exposed as ETag.
- no database credentials or secrets in data migrations.
## Identity and workspace
### installation
- `id` singleton key
- `instance_id` uuid unique
- `product_version`
- `schema_version`
- `primary_locale`
- `time_zone`
- `setup_completed_at`
- timestamps / concurrency version
### users
Use ASP.NET Core Identity-compatible tables with application extensions:
- display name
- enabled state
- last login
- security stamp
- created/updated timestamps
### workspaces
- `id`
- `name`
- normalized `slug`
- settings version
- timestamps
- archived timestamp
Unique: normalized slug.
### workspace_memberships
- workspace/user composite uniqueness
- role
- timestamps
Constraint: valid role enum. Application invariant: final admin cannot be removed.
## Knowledge tables
### pages
Important columns:
```text
id uuid primary key
workspace_id uuid not null
parent_page_id uuid null
sort_key text not null
title varchar(300) not null
normalized_title varchar(300) not null
slug varchar(320) not null
markdown text not null
document_type smallint not null
document_status smallint not null
current_revision bigint not null
content_hash char(64) not null
concurrency_version bigint not null
created_by / updated_by uuid
created_at / updated_at / published_at timestamptz
trashed_at / purge_after timestamptz null
search_vector tsvector generated or maintained
```
Indexes:
- `(workspace_id, parent_page_id, sort_key, id)`
- `(workspace_id, slug)`
- `(workspace_id, updated_at desc, id)`
- partial active-page indexes where `trashed_at is null`
- GIN `search_vector`
- GIN/GiST trigram on normalized title
- optional path/ancestor closure only if measured need justifies it
Cycle prevention is primarily application-controlled and integration-tested. A recursive
query validates moves transactionally.
### page_revisions
Unique `(page_id, revision_number)`. Store immutable snapshots and content hash.
Indexes:
- page/revision descending
- workspace/created timestamp
- author/time where audit UI needs it
No cascade delete during ordinary content lifecycle.
### drafts
Unique `(workspace_id, user_id, draft_key)`. Draft content is private to the author.
### page_links
Unique identity includes source page, occurrence key and source revision. Index target
page for backlinks and unresolved text for maintenance.
### page_relations
Unique `(source_page_id, target_page_id, relation_type)` unless multiple labels are
explicitly supported.
### tags / page_tags
- normalized tag unique per workspace
- composite join primary key
- indexes for tag-filter search
### favorites / recent_visits
Composite user/content uniqueness. Recent visits use upserted last-view time and bounded
retention.
## Attachments
### attachment_blobs
Primary key is 64-character lowercase SHA-256.
Columns include:
- length
- detected media type
- storage/preview/integrity states
- created/verified timestamps
- reference count cache only if transactionally maintained
### attachment_references
Stable logical ID, workspace, blob hash, safe original filename and descriptive metadata.
### attachment_usages
Polymorphic ownership must not use an unconstrained arbitrary string alone. Prefer
separate nullable constrained columns or typed join tables for page/project update usage.
The selected design requires an ADR.
Indexes support owner lookup and orphan discovery.
## Projects
### projects
Unique normalized project slug per workspace. Index lifecycle status and last update.
### project_documents
Unique `(project_id, page_id, role)`.
### project_updates
Ordered by `(occurred_at desc, id desc)`. Immutable content with correction/supersession
links.
### project_milestones
Unique name or sequence per project; stable sort key.
### technology_tags / project_technology_tags
Normalized unique technology name per workspace.
## Decisions and runbooks
### decision_records
- unique page profile
- unique accepted decision number per project
- self-referencing supersession constraints
- indexes by project/status/date
### runbook_profiles
- unique page profile
- risk/validation metadata
- review/validation indexes
## Imports
### import_jobs
Unique source fingerprint plus invocation identity does not automatically prevent a new
deliberate import, but idempotency keys prevent duplicate execution.
### import_items
Unique `(import_job_id, source_identity)`. Index target identities and status.
### import_provenance
Links imported target rows to job/item. This table is the authoritative rollback scope.
## Audit, jobs and outbox
### audit_events
Append-only, indexed by workspace/time, actor/time, target/time and correlation ID.
Partitioning is deferred until measured volume warrants it.
### background_jobs
Durable state, lease owner/expiry, attempt count, schedule, progress and safe diagnostics.
### outbox_messages
Index unprocessed `(available_at, id)`. Retention after successful dispatch is
configurable.
## Search implementation
Use the PostgreSQL `simple` text-search configuration initially because documents mix
Dutch, English, code, commands and product names. Maintain normalized searchable text
from:
- title;
- stripped Markdown text;
- tags;
- selected structured metadata.
Use trigram matching for titles/slugs/tags and explicit ranking weights. Permission
filters must be part of the query, not a post-filter after result selection.
## Migration requirements
Every migration must:
- be deterministic;
- be tested from the previous released schema;
- avoid silent data truncation;
- include data backfill verification where needed;
- document locks/expected duration for large tables;
- have a tested application rollback strategy even when SQL downgrade is unsafe.
+240
View File
@@ -0,0 +1,240 @@
# API contract
The API is built for the web client and trusted scoped automation. A future separate MCP
server consumes the same API.
The draft OpenAPI document is in `docs/api/openapi.yaml`. Implementation and contract
tests must keep it current.
## Conventions
- Base path: `/api/v1`
- JSON: UTF-8, camelCase
- IDs: opaque UUID strings
- Times: RFC 3339 UTC
- Errors: `application/problem+json` (RFC 7807)
- Pagination: opaque cursor
- Concurrency: strong ETag plus `If-Match`
- Correlation: accept/return `X-Correlation-ID`
- Idempotency: `Idempotency-Key` for retryable create/append endpoints
- Locale: server returns invariant values plus localized UI copy remains client-side
## Authentication
### Browser
Secure cookie session plus anti-forgery token for mutations.
### Service account
```http
Authorization: Bearer ikh_<public-id>_<secret>
```
The full token is shown once. The server stores:
- service account ID;
- token public ID/prefix;
- SHA-256 or keyed verifier of a cryptographically random 256-bit secret;
- created/last-used/expiry/revoked timestamps;
- scopes and optional allowed network policy.
For controlled curation, `pages:read` authorizes page detail, roots, children,
revisions and backlinks; `pages:write` authorizes create, update, move and reversible
trash. Browser callers retain their role-based cookie policy. Update and move accept an
optional `Idempotency-Key` in addition to mandatory `If-Match`, so bounded automation
can retry without duplicating a successful mutation.
The API never returns or logs the secret.
## Core scopes
```text
knowledge:read
knowledge:search
pages:read
pages:write
projects:read
projects:write
project-updates:append
decisions:read
decisions:write
runbooks:read
attachments:read
attachments:write
imports:read
imports:execute
audit:read
system:read
```
Scopes never bypass workspace/content authorization.
## Resource endpoints
### System
- `GET /system/info`
- `GET /health` is outside `/api/v1` and contains no private details
### Search
- `GET /search?q=&types=&tags=&projectId=&cursor=&limit=`
Returns permission-filtered hits and facets. No inaccessible counts or snippets.
### Pages
- `GET /pages/{id}`
- `GET /pages/{id}/children`
- `GET /pages/{id}/relations`
- `GET /pages/{id}/revisions`
- `GET /pages/{id}/revisions/{revision}`
- `POST /pages`
- `PUT /pages/{id}` with `If-Match`
- `POST /pages/{id}/move` with `If-Match`
- `POST /pages/{id}/trash` with `If-Match`
- `POST /pages/{id}/restore`
- `POST /pages/{id}/restore-revision`
Page responses include:
- stable ID;
- current title/slug/breadcrumb;
- raw Markdown when scope permits;
- typed metadata;
- current revision;
- ETag;
- stable source citation object.
### Projects
- `GET /projects`
- `GET /projects/{id}`
- `POST /projects`
- `PUT /projects/{id}` with `If-Match`
- `GET /projects/{id}/updates`
- `POST /projects/{id}/updates`
- `GET /projects/{id}/decisions`
- `GET /projects/{id}/context`
### Project context
`GET /projects/{id}/context` returns a bounded context bundle:
```json
{
"project": {
"id": "...",
"name": "Ludarium",
"status": "active",
"summary": "...",
"currentStateMarkdown": "...",
"updatedAt": "..."
},
"recentUpdates": [],
"activeMilestones": [],
"decisions": [],
"runbooks": [],
"documents": [],
"knownIssues": [],
"citations": [],
"truncation": {
"truncated": false,
"omittedCounts": {}
}
}
```
Requirements:
- default bounded item counts;
- explicit `updatedAt` and source IDs;
- deterministic ordering;
- permission filtering before assembly;
- maximum serialized size;
- no secrets, raw attachment bodies or private filesystem paths;
- truncation metadata rather than silent omission.
### RAG publication manifest
`GET /rag/publication-manifest?space=&cursor=&limit=` — a single, deliberately narrow
read-only endpoint for RAGcore's n8n sync, reusing the existing `pages:read` scope (no
new scope). There is no general `GET /pages` enumeration in this API — only
`/pages/roots` plus a tree walk — so this is the one reliable way an external consumer
discovers the full curated publication set. Selection and shape are entirely driven by
the `rag:publish`/`rag:space:`/`rag:allow-deprecated`/`locale:` tag convention; see
`docs/32-rag-publication-convention.md` for the full convention and
`docs/api/openapi.yaml`'s `/rag/publication-manifest` path for the exact response shape.
### Attachments
The implemented v1 routes accept either the normal cookie role or the matching
least-privilege service-account scope: `attachments:read` for listing/content and
`attachments:write` for upload. `pages:write` also authorizes move and the reversible
`POST /pages/{id}/trash` operation so an importer can roll back only the pages from
its own recorded batch; it does not grant permanent purge access.
- `POST /attachments` streaming multipart
- `GET /attachments/{id}/metadata`
- `GET /attachments/{id}/content`
- `DELETE /attachments/{id}` logical delete
Range requests may be supported after authorization. Unsafe types download as attachment.
### Imports
Administrative API only:
- create discovery/preflight;
- retrieve plan/report;
- execute accepted plan;
- validate;
- rollback.
Source paths are configured server-side; clients cannot supply arbitrary unrestricted
host paths.
### Service accounts
Administrative browser/API endpoints create, list, rotate and revoke accounts/tokens.
Creation/rotation returns the secret once.
## Error types
At minimum:
- validation error;
- authentication required;
- forbidden;
- not found (without existence leakage);
- concurrency conflict;
- idempotency conflict;
- rate limited;
- unsupported media type;
- payload too large;
- import preflight conflict;
- dependency unavailable.
Every problem includes a stable machine-readable `type` and correlation ID.
## Rate limits
Separate policies:
- login/recovery/bootstrap;
- service-token authentication failures;
- search;
- attachment upload;
- import/backup operations;
- general API.
Limits are configurable and return standards-compatible retry guidance.
## Versioning policy
- Additive compatible fields may appear within v1.
- Clients must ignore unknown fields.
- Breaking changes require `/api/v2` or a documented transition.
- Outbox event payloads carry their own schema version.
- The future MCP server may never query the database directly.
+132
View File
@@ -0,0 +1,132 @@
# Search and indexing
## Goals
Search must find mixed Dutch, English and technical content quickly without leaking
inaccessible titles, snippets, facets or counts.
The first release uses PostgreSQL full-text search and `pg_trgm`. Embeddings and external
search services are out of scope.
## Indexed sources
### Page document
Weighted fields:
- A: title
- B: tags, document type, breadcrumb labels
- C: stripped Markdown headings and body
- D: selected metadata such as course/module and attachment descriptions
### Project document
- name and summary;
- current state;
- status and technologies;
- project updates;
- linked decision summaries;
- linked document titles.
### Attachment metadata
Only safe metadata is indexed:
- original filename;
- description;
- caption;
- detected media type.
Attachment body extraction and OCR are deferred.
## Text normalization
- Store original content unchanged.
- Build a normalized search projection.
- Use PostgreSQL `simple` configuration initially to avoid damaging code, commands,
product names and bilingual terms through aggressive stemming.
- Normalize Unicode and case for fuzzy title matching.
- Strip Markdown syntax without concatenating unrelated tokens.
- Keep code identifiers searchable.
- Never index hidden secrets or operator-only source paths.
## Query behavior
1. Parse and bound the query.
2. Apply workspace, visibility, trash and permission filters.
3. Generate workspace- and permission-filtered full-text candidates.
4. If exact candidates exist, rank those first and skip workspace-wide fuzzy work; otherwise use the indexed `pg_trgm` title predicate at the measured 0.2 threshold for typo-only searches.
5. Rank with title/exact phrase/recency weights.
6. Generate snippets only from permitted candidates.
7. Return deterministic `(rank, updatedAt, id)` cursor ordering.
Exact title and exact quoted phrase matches outrank fuzzy matches.
This exact-first fallback is intentional: it keeps common-term latency bounded while preserving typo tolerance. The threshold is applied with PostgreSQL `SET LOCAL` in the request transaction; see ADR-020 and the 2026-08-01 production audit.
## Filters
- document type;
- document status;
- collection/root;
- tag;
- project;
- author/editor;
- changed date range;
- content kind;
- stale/review state.
Facets are calculated from the permitted candidate population.
## Index consistency
Publishing a page updates its current search projection transactionally where practical.
Background repair handles:
- parser/version changes;
- project context rollups;
- bulk import;
- failed projection updates;
- full reindex.
Every projection stores a source version/hash. Administrators can see lag and repair it.
## Permission safety
Search authorization is part of SQL selection. It is not acceptable to:
1. retrieve the top global results;
2. filter inaccessible rows in application memory;
3. return fewer results or leaked facets.
Tests must include private pages whose titles and rare terms would reveal their existence.
## Scale verification
Synthetic data must include:
- 100,000 current pages;
- mixed Dutch/English prose;
- code and command blocks;
- 1,000,000 revisions not all included in current-content search;
- tags and project links;
- deliberately inaccessible content;
- typo and phrase queries.
Capture:
- p50/p95/p99 latency;
- query plans;
- index sizes;
- result correctness;
- no-leak assertions.
## Search API safeguards
- query length bound;
- result limit cap;
- cursor expiry/versioning;
- rate limit;
- cancellation/timeout;
- safe highlight tags generated by server or client—not arbitrary HTML;
- generic not-found/forbidden behavior where existence is sensitive.
+157
View File
@@ -0,0 +1,157 @@
# Markdown and linking
## Canonical content
The canonical human-authored body of a page is Markdown stored in PostgreSQL and included
in immutable revisions. HTML is derived and disposable.
Full exports use Markdown plus a manifest/frontmatter representation. Database metadata
remains authoritative during normal operation.
## Supported syntax
Initial profile:
- CommonMark;
- headings;
- emphasis and strikethrough;
- ordered/unordered lists;
- task lists;
- block quotes;
- fenced code blocks with language;
- inline code;
- tables;
- links;
- safe images through attachment references;
- footnotes when the selected parser supports deterministic export;
- Mermaid fenced blocks.
Raw HTML is disabled by default.
### Managed import regions
Imported and derived pages may store four exact control tokens in canonical Markdown:
```text
<!-- managed-source:start -->
<!-- managed-source:end -->
<!-- user-notes:start -->
<!-- user-notes:end -->
```
These tokens remain in PostgreSQL and immutable revisions so synchronization can replace
only the managed source region and preserve user notes byte-exact. The renderer removes
only these four exact tokens from a transient render copy, replacing them with line
boundaries before Markdown parsing. Stored Markdown is never normalized by rendering.
Unknown comments, HTML, scripts and event handlers are not recognized as controls and
remain subject to the normal raw-HTML-disabled and allowlist-sanitized pipeline.
## Internal wiki links
Author syntax:
```text
[[Target title]]
[[Target title|Visible label]]
```
The editor autocomplete may insert an invisible stable marker or structured editor
metadata, but exported Markdown must remain human-readable.
At publish time, each link records:
- source page/revision;
- authored target text;
- optional display text;
- resolved stable target ID;
- resolution state;
- occurrence or source range where practical.
Resolution order:
1. explicit stable target marker;
2. exact title in the same collection/subtree;
3. exact title in the workspace;
4. unique normalized match;
5. unresolved or ambiguous.
The UI must ask the editor to resolve ambiguity before or after publishing; it must not
silently choose an arbitrary page.
## Slugs
- Derived from title for readability.
- Unique only where required by routing policy; stable page ID prevents identity loss.
- Old canonical URLs redirect using ID.
- Slugs contain no authorization meaning.
- Import preserves the legacy path separately as provenance.
## Heading anchors
Heading IDs are deterministic within a rendered revision. Duplicate headings receive
stable suffixes. Links to headings use page ID plus anchor.
A heading rename may break an authored anchor; the system should detect this as a link
maintenance warning but need not maintain a permanent heading identity in v1.
## Rendering safety
- Parse using a fixed configured extension set.
- Do not pass raw HTML through.
- Remove only the four documented import control tokens from render input; never expose
them in reader views, snippets or revision previews.
- Sanitize generated HTML with an allowlist.
- Rewrite external links with safe `rel` attributes.
- Reject `javascript:`, `data:` and unsafe protocols except explicitly generated safe
image data where controlled.
- Render Mermaid with strict security and sanitized labels.
- Never allow Mermaid directives to access network/filesystem.
- Syntax highlighting is client-side or server-side without executing code.
- Cap nesting, token count and rendered size to resist pathological input.
## Attachments in Markdown
Use a stable application URI:
```text
![Alt text](attachment:550e8400-e29b-41d4-a716-446655440000 "Caption")
```
The renderer resolves it through authorized metadata/content endpoints. Exports rewrite it
to a deterministic relative file path and include the attachment.
Ordinary external image hotlinking is disabled by default for privacy. The editor may
store an external hyperlink instead.
## Draft and revision behavior
The draft stores raw Markdown and typed metadata. Preview never changes published
relations or search.
Publish:
1. validates size and parser limits;
2. parses internal links and attachment references;
3. authorizes all referenced private resources;
4. creates current state and immutable revision;
5. updates derived relations/search;
6. writes audit/outbox;
7. returns the new ETag.
## Export frontmatter
Export may include:
```yaml
id: 550e8400-e29b-41d4-a716-446655440000
title: PostgreSQL back-up herstellen
documentType: runbook
status: active
tags:
- PostgreSQL
- Back-up
updatedAt: 2026-07-31T18:00:00Z
```
Importers must not trust privileged fields such as author IDs, permissions or executable
paths from frontmatter.
+203
View File
@@ -0,0 +1,203 @@
# Authentication and security design
## Threat posture
The service is private and self-hosted but must assume:
- the reverse-proxy endpoint may become internet reachable;
- an authenticated low-privilege account can be malicious;
- imported Markdown and files are untrusted;
- database or backup theft is possible;
- API tokens can leak;
- container compromise must not automatically expose the host.
The complete threat register is in `docs/26-threat-model.md`.
## First-run owner setup
First-run setup is intentionally available without a separate installation code. The
endpoint remains a tightly bounded one-time operation:
1. Expose only that setup is required.
2. Require the new workspace and owner credentials over the setup form.
3. Protect the mutation with CSRF validation and a setup-specific rate limit.
4. Atomically create installation, workspace, owner and settings.
5. Permanently disable first-run registration after successful completion.
6. Audit successful completion without recording passwords or tokens.
The setup endpoint must not be exposed through an internet-facing reverse proxy before
owner setup is complete. Complete setup over the trusted LAN or HTTPS endpoint, then
configure the reverse proxy and create the first backup.
## Password authentication
Use ASP.NET Core Identity or an equivalent reviewed implementation.
Policy:
- minimum 12 characters by default;
- allow password-manager generated long passphrases;
- maximum input bound high enough for passphrases;
- no arbitrary composition rules;
- check against a small bundled common-password denylist where feasible without an
outbound service;
- adaptive, versioned password hashing;
- transparent rehash on successful login when policy changes;
- generic authentication failure;
- exponential/threshold lockout;
- session/security stamp invalidation after password reset or account disable.
## Operator-issued recovery
No email provider is required.
1. Administrator or local operator issues a single-use recovery token.
2. Token has at least 256 bits of entropy, a short expiry and one-use state.
3. Store only a verifier.
4. Deliver through an operator-visible channel.
5. Prefer a URL fragment so the raw token never enters access logs:
`https://knowledge.example/#/recover/<token>`.
6. Client posts the token body to the API.
7. Successful reset consumes the token and invalidates sessions.
8. Record safe audit metadata.
For the final administrator, provide a documented container operator command that creates
a recovery token without database surgery.
## Browser sessions
- Secure cookie in HTTPS production.
- HttpOnly.
- SameSite=Lax or stricter based on tested flows.
- Bounded idle and absolute lifetime.
- Session rotation after authentication/privilege change.
- Anti-forgery token on all cookie-authenticated mutations.
- No auth tokens in localStorage.
- Logout invalidates server-side session/security state where implemented.
- Concurrent session view/revocation is desirable in M1.
## Authorization
Authorization is workspace- and resource-aware.
- Reader: view/search permitted content.
- Editor: reader plus content/project creation and editing.
- Administrator: editor plus users, imports, backup, retention, audit and service accounts.
- Service account: scopes plus explicitly assigned workspace/content access; no role
inheritance.
Rules are enforced in Application use cases and query specifications. Endpoint attributes
alone are insufficient.
Tests cover:
- unauthenticated access;
- role escalation;
- object-ID substitution;
- cross-workspace access even though UI exposes one workspace;
- trashed/private content;
- attachment and revision access;
- project context aggregation;
- disabled users and revoked tokens.
## Service-account tokens
Format contains a non-secret public ID/prefix and a random secret. Store only a verifier.
- Show once.
- Optional expiry.
- Multiple independently revocable tokens per account.
- Named purpose and last-used time.
- Explicit scopes.
- Immediate revocation.
- Constant-time verification.
- Rate limit failed token authentication.
- Never accept token in query string.
- Never log the authorization header.
A database compromise should not make offline token recovery feasible because secrets are
uniform random 256-bit values.
## CSRF, CORS and origins
- Browser API is same-origin in production.
- CORS disabled unless a specific trusted integration requires it.
- Anti-forgery for cookie mutation.
- Bearer-token calls do not use cookies.
- Validate configured public/base URL.
- Proxy headers accepted only from configured trusted proxy networks.
- Host filtering enabled.
## Content security
Recommended production headers:
- Content-Security-Policy with no unsafe inline script;
- `frame-ancestors 'none'` unless explicitly required;
- `object-src 'none'`;
- `base-uri 'self'`;
- `form-action 'self'`;
- HSTS at reverse proxy after HTTPS validation;
- X-Content-Type-Options;
- Referrer-Policy;
- Permissions-Policy.
Rendered Markdown disallows raw HTML and unsafe URLs. Mermaid uses strict mode. Uploads
are not served as executable same-origin content.
## Secrets and encryption
- Secrets enter through mounted secret files or environment variables.
- ASP.NET data-protection keys persist outside the read-only root filesystem.
- Production data-protection keys should be protected with an operator-managed secret
where supported.
- Backup encryption is operator-configurable and must not rely on a key stored only inside
the backup.
- API tokens, passwords and bootstrap/recovery secrets are never recoverable from the DB.
- Do not pretend application-level encryption protects against a fully compromised
running application; document the threat boundary.
## Logging and privacy
Never log:
- passwords;
- raw tokens;
- cookies/session IDs;
- CSRF tokens;
- page bodies by default;
- attachment content;
- full recovery/bootstrap links;
- database connection strings.
Use safe IDs, correlation IDs and redacted structured properties. Audit retention and
diagnostic-log retention are separate.
## Rate limits and abuse controls
At minimum isolate policies for:
- setup/login/recovery;
- failed bearer authentication;
- global search;
- upload;
- import/export/backup;
- expensive project-context requests.
Return a generic response that does not confirm account/content existence.
## Security verification
Release requires:
- dependency vulnerability scan;
- secret scan;
- container scan;
- authorization matrix tests;
- CSRF tests;
- XSS corpus;
- malicious upload/path corpus;
- session/token lifecycle tests;
- security header test;
- non-root/read-only container evidence;
- independent security review.
+132
View File
@@ -0,0 +1,132 @@
# Attachments
## Goals
- Preserve school and project files.
- Avoid filename/path vulnerabilities.
- Deduplicate identical content.
- Maintain authorization and provenance.
- Detect corruption.
- Never execute uploaded content.
## Storage model
Physical blobs are addressed by lowercase SHA-256:
```text
/var/lib/knowledge-hub/attachments/ab/cd/<full-sha256>
```
The physical path never contains the user's filename. A logical attachment reference
stores the original display filename and metadata in PostgreSQL.
Temporary uploads use a separate same-filesystem quarantine directory so a validated file
can be atomically moved.
## Upload pipeline
1. Authenticate and authorize.
2. Enforce request and streaming limits.
3. Sanitize display filename; reject path separators/control characters.
4. Stream once to quarantine while calculating SHA-256.
5. Verify actual byte length.
6. Detect known signatures.
7. Compare declared type, extension and detected type.
8. Apply policy:
- safe raster image: eligible for inline preview;
- PDF/Office/text/archive/config: download by default;
- HTML/SVG/script/executable: download-only or reject according to policy;
- unknown: `application/octet-stream`, download-only.
9. Atomically commit blob and logical reference.
10. Generate preview asynchronously where supported.
11. Remove quarantine data on every failure/cancellation.
Default maximum: 100 MiB per file, configurable by administrator. The reverse proxy,
Kestrel and application limits must agree.
## Supported initial categories
- PNG, JPEG, GIF, WebP;
- PDF;
- plain text and common configuration files;
- Office documents;
- common archives as opaque downloads;
- other opaque files by administrator policy.
SVG is not rendered inline in v1. Archives are not extracted as ordinary attachments.
## Serving
Every content request re-authorizes the logical reference.
Headers:
- `X-Content-Type-Options: nosniff`;
- safe `Content-Disposition`;
- bounded cache policy for private content;
- CSP/sandbox where inline documents are later enabled;
- no user-controlled response headers.
Inline rendering is initially restricted to known safe raster images. Range requests may
be added for large downloads with authorization tests.
## Deduplication
Two logical references may point to one blob.
- Deduplication is global inside the installation storage, but authorization always uses
the logical reference.
- A caller cannot discover whether a matching private blob already exists.
- Content hash is not sufficient permission to retrieve.
- Concurrent uploads with the same SHA-256 serialize the blob lookup, atomic commit
and database insert. Every upload receives its own logical reference while exactly
one physical blob is retained; uploads with different hashes remain concurrent.
- Blob cleanup occurs only when no active or retained reference remains.
- Reference counts are derived or transactionally maintained and periodically verified.
## Image metadata
Logical references may store:
- alt text;
- caption;
- width/height;
- rotation-normalized preview;
- uploader and upload time.
Strip unnecessary metadata from generated previews. Preserve the original bytes as the
blob unless policy explicitly rejects them.
## Integrity
- New blobs are verified after atomic move.
- Scheduled sampling/full scan recalculates hashes.
- Missing/corrupt blobs mark references degraded and alert administrators.
- Backups include a blob manifest and hashes.
- Restore validates before enabling normal write traffic.
## Deletion
Deleting an attachment use/reference is logical first. Retention protects accidental
deletion. Orphan physical blobs are cleaned in a background job after the configured
window and a final transactional reference check.
## Security tests
Include:
- double extensions;
- path traversal names;
- null/control characters;
- oversized/chunked uploads;
- declared/detected MIME mismatch;
- HTML/SVG stored XSS;
- polyglot samples where feasible;
- executable/script files;
- authorization ID substitution;
- interrupted upload cleanup;
- duplicate content;
- concurrent duplicate uploads;
- malicious archive names without extraction;
- race between deletion and download;
- corruption detection.
+129
View File
@@ -0,0 +1,129 @@
# Revisions and audit
## Distinction
**Revisions** preserve content state and support comparison/restoration.
**Audit events** preserve security- and operation-relevant attribution.
They are related but not interchangeable.
## Page revisions
A revision is immutable and includes:
- page ID;
- monotonically increasing revision number;
- title and slug;
- parent/order snapshot;
- Markdown;
- typed metadata snapshot;
- content hash;
- author/source;
- change summary;
- timestamp.
The current `pages` row is a projection of the newest published revision plus operational
fields.
### When to create a revision
- content publish;
- title/slug change;
- document type/status change;
- structural move when it changes meaning/breadcrumb;
- restore;
- import;
- API mutation.
Purely personal favorite/recent state does not create a page revision.
### Restore
Restoring revision N creates revision N+1 (or later) containing that snapshot. It does not
delete intervening history. The restore action itself is audited.
### Diff
Provide:
- line-based Markdown diff;
- metadata field diff;
- safe rendered comparison where useful;
- author/time/change summary.
Do not render removed unsafe HTML as active content.
## Project history
Project lifecycle changes and current-state mutations create an immutable project-state
revision or equivalent history record. Timeline entries are immutable; corrections append
a linked correction.
ADR supersession and runbook validation are explicitly audited.
## Audit event requirements
Audit events are append-only application records:
- actor kind: user, service account, import, system;
- actor ID and safe display snapshot;
- source channel;
- action;
- target type and stable ID;
- workspace;
- result;
- correlation ID;
- timestamp;
- safe changed-field names/summary;
- optional network/client metadata under policy.
No page body diff, password, token or raw attachment content is stored in the audit event.
## Atomicity
A published mutation succeeds only when current state, revision, audit and required
outbox record commit together. A failed transaction creates none of them.
Authentication failures may need audit/security logging outside a content transaction,
with rate limiting and privacy controls.
## Retention
- Revisions: retained by default until explicit administrative policy.
- Audit: configurable long retention, minimum recommended one year for a personal system.
- Diagnostic logs: much shorter operational retention.
- Trashed content: configurable grace period.
- Purge: explicit, audited and blocked while legal/operational holds exist (future-ready).
## Audit access
Administrator only by default. Search/filter:
- time range;
- actor;
- action;
- target;
- source;
- correlation ID;
- result.
Audit export excludes sensitive request data.
## Integrity and tamper evidence
PostgreSQL permissions prevent ordinary application paths from updating/deleting audit
rows. A future hash chain may be added, but v1 must not claim tamper-proof logging against
a database administrator. Backups and restricted DB roles provide practical protection.
## Tests
- concurrent publish produces exactly one accepted revision;
- restore appends;
- failed mutation creates no partial audit/revision;
- service-account attribution;
- import attribution;
- disabled user attribution remains readable;
- audit UI authorization;
- sensitive-field redaction;
- retention and purge boundaries.
+170
View File
@@ -0,0 +1,170 @@
# Project hub
## Purpose
Project dossiers turn scattered project notes into a reliable current-state and history
view while keeping long-form documents as normal wiki pages.
A project is not a duplicate folder. It is a structured aggregate linked to pages,
decisions, runbooks, updates and milestones.
## Project fields
Required:
- name;
- slug;
- concise summary;
- lifecycle status;
- priority;
- current-state Markdown;
- owner;
- created/updated timestamps.
Optional:
- start/target/completion dates;
- repository URL;
- live URL;
- documentation URL;
- issue tracker URL;
- technologies;
- icon/image;
- visibility classification;
- external identifiers.
URLs are validated and never used for server-side fetches in v1.
## Lifecycle
```text
Concept → Planned → Active ↔ Blocked
↘ Maintenance
Any non-archived state → Paused
Active/Maintenance → Completed
Any state → Archived
```
Transitions are flexible enough for personal projects but recorded in project history.
Archived projects remain searchable by explicit filter and API permission.
## Project overview
The page contains:
1. status and summary;
2. current verified state;
3. primary links and technologies;
4. active milestones;
5. recent project updates;
6. accepted/current decisions;
7. important runbooks;
8. linked architecture/deployment/testing/troubleshooting pages;
9. known issues/documentation warnings;
10. revision/history access.
The default view is concise. It does not render every full linked document.
## Project documents
A page can be linked with a role:
- Overview
- Current state
- Architecture
- Deployment
- Infrastructure
- Security
- Testing
- Troubleshooting
- Release
- User documentation
- Other
One page may be linked to multiple projects where meaningful. The page remains canonical;
the project stores the relationship, not a copy.
## Project updates
Categories:
- Progress
- Feature
- Fix
- Deployment
- Validation
- Release
- Decision
- Risk
- Blocker
- Documentation
- Maintenance
An update includes title, bounded summary Markdown, occurred time, actor/source,
verification status and optional references:
- commit SHA;
- release/tag;
- issue identifier;
- URL;
- page;
- decision;
- milestone;
- attachment.
Verification status:
- Reported
- Verified
- Superseded
The UI must distinguish reported agent/user claims from validated evidence.
## Project milestones
Project-specific milestones have:
- name;
- order;
- state;
- target date;
- completion date;
- summary;
- success criteria;
- linked updates/documents.
They do not reuse repository build milestone IDs.
## Project context bundle
The bounded context API is designed for external retrieval:
- project identity/status/summary/current state;
- configurable number of recent verified updates;
- active milestones;
- accepted decisions;
- validated runbooks;
- selected document summaries and citations;
- known blockers;
- explicit truncation information.
It must not synthesize facts that are not stored. Summaries are stored/maintained fields,
not automatic LLM output in v1.
## Dashboard behavior
Active projects show:
- name/status;
- one-line current state;
- last verified update;
- stale/documentation warning;
- direct open action.
Cards use consistent heights and alignment without hiding essential content.
## Deletion/archive
Projects archive by default. Deletion cannot delete linked pages. Timeline, decision and
audit history remains recoverable. A privileged purge path requires explicit dependency
review.
+106
View File
@@ -0,0 +1,106 @@
# Runbooks and Architecture Decision Records
## Architecture Decision Records
ADRs provide durable reasoning, not only outcomes.
### Required sections
- Context
- Decision criteria
- Options considered
- Decision
- Consequences
- Validation
### Metadata
- stable ID;
- project;
- human-readable decision number;
- status;
- decision date;
- supersedes/superseded-by;
- owner/author;
- linked page;
- tags.
### Lifecycle
- Proposed
- Accepted
- Rejected
- Superseded
- Withdrawn
An accepted ADR can be superseded only by another accepted ADR. Links are bidirectional
and cycle-free.
Editing the ADR page creates revisions. Changing lifecycle metadata is audited and
revisioned. Historical decisions remain readable.
## Runbooks
A runbook is a human-executed procedure. The platform never runs its commands.
### Required sections
1. Purpose
2. Scope
3. Prerequisites
4. Steps
5. Validation
6. Rollback
7. Risks/notes
### Metadata
- owner;
- risk: low/medium/high/critical;
- system/project tags;
- last validated time and validator;
- review due time;
- estimated duration (optional);
- maintenance-window requirement (optional);
- runbook status.
### Checklist behavior
Markdown task lists may be used as a transient execution checklist, but checking boxes in
a normal read session must not silently modify the canonical runbook.
A future “run session” can create an execution copy, but that is deferred. In v1 the UI
may provide a local/non-persistent checklist mode with an explicit warning.
### Validation
Marking a runbook validated requires:
- permission;
- current revision;
- validator;
- time;
- optional evidence/comment.
A new content revision makes prior validation stale unless the change is explicitly
classified non-procedural and policy allows it.
### Safety
- Commands render as text/code only.
- No terminal bridge.
- No secret interpolation.
- No copy button may include hidden text.
- Sensitive-value placeholders are visibly marked.
- High/critical runbooks emphasize prerequisites, validation and rollback.
## Templates
Canonical templates live in `product/templates/`. The application may seed editable
copies, but template version/provenance is retained.
## API behavior
ADRs and runbooks are retrievable through their own typed endpoints and through page
resources. The context API returns bounded summaries and stable page citations rather
than silently parsing arbitrary headings at request time.
+281
View File
@@ -0,0 +1,281 @@
# LeafWiki migration
## Objective
Migrate the owner's existing LeafWiki knowledge into ItWorx Knowledge Hub without
modifying the source and without silently losing or overwriting content.
LeafWiki versions and deployments may differ. The importer must be discovery-driven and
adapter-versioned rather than assuming one undocumented fixed schema.
## Safety contract
- Source mount is read-only at the operating-system/container level.
- SQLite files are opened with read-only/immutable semantics.
- No source file is renamed, normalized, repaired or touched.
- The importer never writes cache, lock, journal or temporary files beside the source.
- A complete source fingerprint is captured before and after.
- Import writes only to an isolated target workspace/database.
- Every target record is attributable to an import ID and source item.
- Re-running an accepted plan is idempotent.
- Rollback removes only that import's creations/changes according to the recorded plan.
- Conflicts are explicit; no silent overwrite.
## Supported source discovery
Inventory recursively:
- Markdown files;
- SQLite databases, detected by header rather than extension only;
- images and other assets;
- JSON/YAML/config manifests;
- directory hierarchy;
- symlinks, which are recorded but not followed outside the source root;
- unknown files.
For SQLite, inspect read-only:
- SQLite version and pragmas safe to read;
- tables/views/indexes;
- columns and foreign keys;
- row counts;
- candidate content, tree, tag, revision and attachment tables;
- application version markers where present.
The bundled `tools/leafwiki-inspector` provides a neutral first inventory. It is not the
final importer.
## Adapter architecture
```text
IImportSource
Discover()
Fingerprint()
SelectAdapter()
ILeafWikiAdapter
Analyze()
BuildPlan()
ReadItem()
LeafWikiFilesystemMarkdownAdapter
LeafWikiSqliteAdapter_<known-version>
LeafWikiHybridAdapter_<known-version>
```
A known adapter must validate its expected signature before reading semantic columns. An
unknown signature produces a preflight blocker, not guessed mappings.
A filesystem-first adapter can import Markdown hierarchy even when DB enrichment is
unknown, but the preflight must clearly state which tags/revisions/metadata would be
omitted and require explicit acceptance.
### Implemented adapters
| Adapter name | Applies when | Recovers |
| --- | --- | --- |
| `synthetic-sqlite` | a SQLite file with the recognized synthetic schema | pages and hierarchy from the database |
| `leafwiki-filesystem-v5` | `schema.json` declares version 5 (or, absent that file, Markdown carries `leafwiki_id` frontmatter) | titles from `leafwiki_title` (YAML-escape decoded), directory hierarchy, the author's sibling order from each directory's `.order.json`, and assets |
| `filesystem-markdown` | any source containing Markdown | hierarchy from directory structure only; titles from `title:` frontmatter, first heading, or filename |
The catalog tries them in that order and takes the first that reports it applies, so a
version-validated adapter is always preferred over the signature-free fallback, and an
unrecognized LeafWiki version falls back rather than being guessed at
(`docs/adr/ADR-032-leafwiki-v5-import-fidelity.md`).
### Target-side transformations
An import writes Markdown that must work in *this* application, so two changes are made
to the imported copy — never to the source:
- an asset link whose bytes were genuinely imported (`](/assets/<page-id>/<file>)`)
becomes `](/api/v1/attachments/<reference-id>/content)`; an asset that was rejected or
unreadable keeps its original link and is reported as a diagnostic;
- a leading level-1 heading is dropped when it exactly repeats the page title, because
this application renders the title above the body and would otherwise show it twice.
A heading that differs from the title is content and is kept.
## Source fingerprint
A deterministic manifest includes:
- normalized relative path;
- item type;
- byte length;
- SHA-256;
- symlink target descriptor without traversal;
- for SQLite: schema fingerprint and selected row-count summary.
Sort entries ordinally and hash the manifest. Do not include source absolute paths in
user-facing logs unless administrator-only and redacted.
## Preflight plan
Preflight produces:
- adapter and version;
- source fingerprint;
- discovered counts;
- proposed target hierarchy;
- title/slug mapping;
- Markdown encoding/line-ending diagnostics;
- link-resolution plan;
- attachment mapping and hashes;
- tags/revision metadata availability;
- duplicate/conflict classifications;
- unsupported/ambiguous items;
- estimated target changes;
- deterministic plan hash.
No target mutation occurs during preflight.
## Content mapping
### Pages and hierarchy
Preference order:
1. authoritative LeafWiki tree metadata from a validated adapter;
2. Markdown directory hierarchy;
3. explicit operator mapping.
Title sources:
1. validated metadata;
2. frontmatter title;
3. first H1;
4. filename.
Keep original relative path and legacy ID as provenance.
### Markdown
- Preserve original Markdown bytes as an import artifact/hash.
- Decode according to detected/declared encoding; report replacements.
- Convert only syntax with a deterministic documented rule.
- Preserve unknown constructs as fenced/raw text rather than discard them.
- Convert internal links after all page identities are mapped.
- Record unresolved/ambiguous links.
### Tags
Import normalized tags when the adapter can prove their relation. Preserve original
display value.
### Revisions
When reliable legacy revisions exist:
- import them in chronological order;
- preserve source timestamp/author label as provenance;
- mark source attribution;
- never assign legacy author labels to current user identities without explicit mapping.
If revisions cannot be safely reconstructed, import current content only and report it.
### Attachments
- Hash and validate each asset.
- Map references after page identities exist.
- Reject path traversal.
- Preserve safe original filename and source path provenance.
- Deduplicate through normal attachment storage.
- Report missing references and unreferenced assets.
## Conflict policy
Classifications:
- no target conflict;
- exact duplicate by source provenance/hash;
- same title/path, different content;
- same attachment hash;
- ambiguous link;
- unsupported source item.
Default actions:
- exact rerun duplicate: skip idempotently;
- same hash attachment: reuse blob, create intended logical reference;
- title/path conflict: create under an explicit import namespace or require plan choice;
- existing user page: never overwrite;
- ambiguous link: leave unresolved and report.
## Execution
- Persist the accepted immutable plan.
- Use bounded transactions/checkpoints to avoid one giant lock.
- Ensure each item is idempotent.
- Create provenance with the target row.
- Defer cross-links until page mapping exists.
- Allow cancellation between batches.
- Resume only when source and plan fingerprints still match.
- On failure, keep a diagnosable job state; do not mark complete.
## Validation
At minimum compare:
- discovered vs planned vs imported/skipped/failed counts;
- hierarchy/root/subtree mapping;
- current page content hashes after deterministic conversion;
- link resolved/unresolved counts;
- tags;
- attachment bytes/hashes and references;
- revision counts when imported;
- target searchability;
- representative rendered pages;
- source fingerprint before/after.
A validation mismatch blocks completion.
## Rollback
Rollback uses import provenance and the accepted plan:
- remove imported relations;
- remove imported current entities when they were created by the job;
- preserve any subsequent user-edited target by moving it to a review/conflict state
rather than deleting it;
- release attachment references and clean blobs only through normal retention;
- append audit records;
- never touch pre-existing target content;
- never touch source.
Test full rollback and partial-failure rollback.
## Real migration procedure
1. Update and back up the existing LeafWiki.
2. Create an immutable/read-only snapshot.
3. Keep LeafWiki running normally during dry runs.
4. Run inspector and preflight.
5. Resolve all blockers.
6. Import into a non-production target.
7. Validate representative school pages, links, files and search with the user.
8. Rehearse rollback.
9. Schedule cutover.
10. Place LeafWiki read-only.
11. Capture a final snapshot/fingerprint.
12. Run final import or delta strategy supported by the adapter.
13. Validate and take a Knowledge Hub backup.
14. Keep LeafWiki read-only through the rollback window.
15. Retire only after explicit user sign-off.
## Synthetic fixtures
Tests must cover:
- filesystem-only Markdown tree;
- duplicate titles;
- nested pages;
- UTF-8 and special characters;
- wiki links and unresolved links;
- attachments and missing attachments;
- SQLite database with known synthetic schema;
- unknown SQLite schema;
- symlink escape attempt;
- corrupted database/file;
- rerun idempotency;
- rollback after user edit;
- source hash immutability.
+136
View File
@@ -0,0 +1,136 @@
# Backup and restore
## Principle
A backup is not considered valid until it is internally verified, and the backup system is
not considered production-ready until a clean-target restore is tested.
## Backup contents
A full backup set contains:
1. PostgreSQL logical dump in a supported restorable format.
2. Attachment blobs.
3. Optional generated exports required by policy.
4. Application configuration manifest without secrets.
5. Product, schema and container/image versions.
6. File inventory with sizes and SHA-256 hashes.
7. Backup job metadata and timestamps.
8. Encryption metadata when operator-managed encryption is enabled.
Do not include runtime session secrets or plaintext API tokens.
## Consistency strategy
Preferred v1 approach:
- create a PostgreSQL consistent logical dump;
- snapshot/copy immutable content-addressed attachment blobs;
- capture attachment metadata and blob manifest from the same defined backup window;
- validate that every active DB blob reference exists in the manifest;
- record files added after the cutoff for the next backup.
Because committed blobs are immutable, exact transaction coordination is simpler than for
mutable files. Deletions use retention, preventing a blob from disappearing during backup.
## Backup workflow
1. Acquire a backup job/lease.
2. Record application/schema version and cutoff.
3. Run PostgreSQL dump with failure-on-error.
4. Inventory referenced attachment hashes.
5. copy/link/archive required blobs without following unsafe paths;
6. create manifest;
7. verify dump readability and every file hash;
8. optionally encrypt with an operator-supplied mechanism;
9. atomically publish completed backup;
10. apply retention only after a verified new backup exists;
11. record audit and administrator-visible result.
A failed backup never replaces the last known-good backup.
## Retention defaults
Configurable starting policy:
- 7 daily;
- 4 weekly;
- 6 monthly;
- at least one manually protected backup.
Retention must account for available storage and never delete an in-progress restore
source.
## Restore workflow
Restore is deliberately separate from normal application startup.
1. Select backup.
2. Verify manifest, hashes, version compatibility and encryption.
3. Create clean PostgreSQL database and empty target directories.
4. Restore database.
5. Restore attachment blobs by hash.
6. Start the recorded/current compatible application in maintenance mode.
7. Apply supported migrations.
8. Run integrity checks:
- FK/schema;
- user/membership;
- page/revision counts;
- current content hashes;
- link counts;
- project counts;
- attachment references vs blobs;
- import/audit consistency.
9. Run authenticated smoke tests.
10. Produce restore report.
11. Promote target only through an explicit operator step.
Never test restore by overwriting the only production instance.
## Backup encryption
The application may integrate an operator-supplied encryption command or library after
threat review. Requirements:
- key is not stored only inside the backup;
- command arguments do not expose secrets in process listings where avoidable;
- restore can verify authentication/integrity before extraction;
- failure is explicit;
- documentation includes key-loss consequences.
Unencrypted backups must be clearly labeled and protected by host/storage controls.
## Disaster recovery targets
Initial targets, to be confirmed on real hardware:
- RPO: last successful scheduled daily backup by default.
- RTO: documented single-host restore, target under 2 hours for typical personal dataset,
excluding transfer of very large attachments.
Do not claim an RTO until a measured production-like rehearsal exists.
## Tests
### Trusted-intake pilot recovery
Before enabling intake, capture a verified backup and restore it into an isolated,
empty target. Record the source commit, image digest, migration range, backup ID,
table counts and attachment hashes. A rollback of intake starts by setting
`IngestionProposal:Enabled=false` and revoking the separate client's service
account. Keep proposals, source references, attachment usages and audit history;
disabling the feature is not a retention-cleanup instruction. Published attachments
remain referenced by their pages. Restore a pre-release backup only after an
isolated restore proves it is necessary and the operator has assessed newer writes.
- create backup with representative data;
- verify manifest;
- corrupt dump and blob tests;
- clean-target restore;
- compare logical counts and hashes;
- restore older supported schema then migrate;
- missing key/encrypted backup failure;
- interrupted backup cleanup;
- retention policy;
- no secret inclusion;
- large attachment set.
+145
View File
@@ -0,0 +1,145 @@
# Observability
## Goals
Operators must be able to answer:
- Is the application alive and ready?
- Is PostgreSQL available?
- Are migrations compatible?
- Are background jobs progressing?
- Did an import, backup or integrity scan fail?
- Is search projection current?
- Are attachment files missing or corrupt?
- Which release and schema are running?
- What request/job correlation identifies a failure?
Observability must not expose private knowledge or secrets.
## Health endpoints
### `/health/live`
Answers only whether the process can respond and has not entered a fatal internal state.
It must not fail because PostgreSQL is temporarily unavailable.
### `/health/ready`
Checks required runtime dependencies:
- compatible database/schema;
- database connectivity;
- writable required storage;
- data-protection key access;
- critical startup jobs complete;
- no maintenance lock preventing normal traffic.
Return minimal public details. Detailed diagnostics require administrator authentication.
### `/api/v1/system/info`
Permissioned information:
- product/version/commit;
- schema version;
- uptime;
- configured locale/timezone;
- storage/job/search status;
- feature flags;
- safe dependency versions.
## Logging
Production uses structured JSON to stdout/stderr for container collection.
Core properties:
- timestamp;
- level;
- event ID/name;
- message template;
- correlation/request ID;
- actor kind and safe ID where authenticated;
- route/status/duration;
- job/import/backup ID;
- exception type and safe stack in operator logs.
Never log page content, passwords, tokens, cookies, authorization headers, connection
strings or attachment bytes.
Use explicit redaction and tests for sensitive property names.
## Audit vs logs
- Diagnostic logs help operate and debug.
- Audit events prove who did what to protected resources.
Do not rely on transient container logs as the audit trail.
## Metrics
Expose bounded metrics to administrators and optionally a protected scrape endpoint:
- request count/duration/status;
- active sessions (approximate, non-identifying);
- DB pool saturation;
- job queue counts and oldest age;
- import/backup outcomes;
- search projection lag;
- attachment storage bytes/count;
- integrity failures;
- login failure/lockout counters without username labels.
Avoid high-cardinality labels such as page ID, user ID or query text.
## Tracing
Use correlation IDs across HTTP, application commands and jobs. OpenTelemetry-compatible
instrumentation is acceptable but no external collector is required.
## Background-job visibility
Admin UI shows:
- type;
- state;
- queued/started/completed time;
- progress counts;
- attempts;
- next retry;
- cancellation availability;
- safe failure summary;
- correlation ID.
Raw stack traces remain in operator logs, not ordinary UI.
## Alerts
The initial system provides persistent in-app administrator alerts for:
- repeated backup failure;
- failed restore rehearsal;
- import validation failure;
- storage unwritable/low;
- missing/corrupt blobs;
- incompatible schema;
- stuck critical job;
- token/security event requiring attention.
External alert integrations are deferred.
## Retention
### Trusted-intake signals
Record proposal count, processing-stage duration, review duration and publication
count with only bounded `stage`, `outcome` and `problem_type` labels. Correlation
IDs connect requests to safe operational events. Never put source bodies, source
URLs, filenames, bearer tokens, credentials or private paths in metrics, logs or
release evidence. Alert on sustained failed proposals, stuck review state and
publication failures without exposing proposal content.
- Configure log rotation at Docker/Unraid level.
- Keep audit retention in the application/database.
- Prune metrics/job detail according to policy.
- Preserve final import/backup/restore reports longer than ordinary job logs.
+204
View File
@@ -0,0 +1,204 @@
# UI/UX design system
## Design direction
ItWorx Knowledge Hub should feel like a focused professional technical workspace:
- calm;
- compact without being cramped;
- legible for long documents;
- fast;
- consistent;
- trustworthy;
- restrained in motion and decoration.
It should not look like a generic admin template or an overloaded analytics dashboard.
## Brand direction
Use the ItWorx identity with two controlled accents:
- a clear light blue for navigation/knowledge actions;
- one consistent green for positive/project accents.
The application must also work in monochrome/high-contrast contexts. Semantic states
(error, warning, success) may use distinct accessible colors but must not redefine the
brand.
Exact tokens are implemented and contrast-tested in M1. Avoid hardcoded colors inside
components.
## Typography
- UI: modern highly legible sans-serif.
- Code: reliable monospace.
- Long-form body target: 70–85 characters per line.
- Default body size at least 16 CSS px.
- Headings use a restrained scale and predictable spacing.
- Do not use all-caps for long labels.
Prefer system or openly licensed bundled/web-safe fonts. The product must not require an
external font CDN.
## Spacing and density
Use a 4 px base grid with common increments 4/8/12/16/24/32.
Two density modes may be considered later; v1 has one balanced default. Tree rows and
buttons must remain large enough for touch targets.
## Layout tokens
- Header: approximately 56 px.
- Left navigation: 280 px default, 224–420 px resizable.
- Context panel: 300 px default, collapsible.
- Document max line width independent of center pane width.
- Mobile breakpoint based on usable layout, not device names.
- Avoid horizontal page scrolling except deliberate code/table containers.
## Core components
- App shell
- Navigation tree
- Breadcrumbs
- Global search/command palette
- Page header
- Document renderer
- Markdown editor and split preview
- Table of contents
- Metadata panel
- Backlink/relations list
- Tag input
- Status badge
- Project card/list
- Timeline
- ADR state/lineage
- Runbook structure/validation
- Revision list/diff
- Attachment uploader/list
- Import plan/report
- Data table with cursor pagination
- Dialog, sheet and confirmation
- Toast plus persistent error summary
- Skeleton/loading indicator
- Empty state
- Conflict-resolution panel
Build shared primitives; avoid route-specific visual inventions.
## Navigation tree behavior
- Lazy load children.
- Expand/collapse with keyboard.
- Maintain expanded state per user/device.
- Support drag/move only when accessible alternatives exist.
- Context menu has an explicit button, not right-click only.
- Current page is indicated semantically and visually.
- Long titles truncate with accessible full-title access.
- Moving a subtree shows destination and impact before commit.
## Editor
Desktop modes:
- write;
- split;
- preview.
Mobile modes are separate tabs, never a squeezed split view.
Editor requirements:
- clear draft/published state;
- last autosave time;
- save/publish action;
- change summary;
- outline navigation;
- internal-link chooser;
- upload insertion;
- accessible keyboard shortcuts;
- conflict banner and merge flow;
- no toolbar that steals focus unpredictably.
A command palette can accelerate formatting and page creation but is not required to
complete basic tasks.
## Feedback states
### Loading
Use stable skeletons for page/tree/list layouts. Do not block the entire shell for one
panel.
### Empty
Explain what belongs here and provide one relevant action.
### Error
- state what failed;
- preserve user input;
- show retry or recovery;
- include correlation ID in expandable technical details;
- do not expose stack traces.
### Conflict
Distinguish:
- server version;
- local draft;
- common/base revision.
Never present “last save wins” as success.
### Success
Use subtle confirmation. Do not rely only on transient toast for important operations such
as import, backup or token creation.
## Responsive behavior
### Desktop
Three-pane reading where space allows.
### Tablet/narrow desktop
Context panel becomes a sheet. Navigation can collapse.
### Mobile
- one pane;
- bottom/top actions within thumb reach;
- no hover-only controls;
- editor toolbar scrolls or groups;
- dialogs avoid overflowing viewport/keyboard;
- tables transform or use contained horizontal scrolling.
## Dark mode
- user-selectable plus system preference;
- no flash of wrong theme;
- syntax highlighting and Mermaid remain readable;
- images/diagrams retain borders where needed;
- contrast verified independently.
## Motion
- brief functional transitions only;
- respect `prefers-reduced-motion`;
- no parallax, continuous decorative movement or animation blocking input;
- loading indicators must not cause layout shift.
## Content tone
Dutch UI copy is direct and professional:
- “Pagina maken”
- “Wijzigingen publiceren”
- “Versieconflict”
- “Import controleren”
- “Back-up herstellen”
Avoid anthropomorphic AI wording because AI is not part of the product.
+106
View File
@@ -0,0 +1,106 @@
# Accessibility
## Standard
Target WCAG 2.2 AA for all implemented user flows.
Automated tests are necessary but not sufficient. Each milestone with UI changes includes
manual keyboard review; M8 includes representative screen-reader checks.
## Global requirements
- Semantic HTML first.
- One logical `h1` per page view.
- Landmarks for header, navigation, main and complementary content.
- Skip link.
- Visible focus meeting contrast/area requirements.
- Logical DOM and tab order.
- No keyboard trap.
- 200% zoom without loss of function.
- Reflow at 320 CSS px width except genuinely two-dimensional content.
- Minimum target size or adequate spacing.
- Text contrast and non-text contrast.
- Status not conveyed by color alone.
- Accessible names for icon-only controls.
- Form labels, instructions and error association.
- Live regions only for concise relevant updates.
- Reduced motion.
- Language set to `nl-BE`, with code/foreign fragments where appropriate.
## Navigation tree
Implement the WAI-ARIA tree pattern only if the team can meet its complete keyboard model.
A nested disclosure/list pattern is acceptable and often simpler.
Required operations:
- Tab enters/leaves the tree predictably.
- Arrow or disclosure controls expand/collapse.
- Current page is announced.
- Actions are reachable without context menu/right-click.
- Drag-and-drop has move-dialog/keyboard equivalent.
- Loading more children is announced.
## Editor
- Text area/editor exposes a usable label and instructions.
- Toolbar controls have names and pressed state.
- Shortcuts do not conflict with browser/assistive technology and are documented.
- Preview mode is a distinct region.
- Heading outline is navigable.
- Autosave and publish state are announced without excessive interruption.
- Validation errors link to the relevant metadata/editor location.
- Conflict UI explains versions and preserves text.
- Markdown source remains available; no inaccessible rich-editor-only content.
## Markdown rendering
- Heading hierarchy is validated/warned.
- Tables have accessible overflow and author guidance for headers.
- Code blocks have language labels where known.
- Copy action is labeled and reports success.
- Images require alt text or explicit decorative status.
- Mermaid diagrams require an author-provided text description or alternative.
- Link text must be understandable in context.
- Deprecated/warning notices use semantic status patterns.
## Dialogs and sheets
- Initial focus is intentional.
- Focus is trapped only while modal.
- Escape closes when safe.
- Focus returns to trigger.
- Title/description relationships are correct.
- Destructive confirmation names the target and consequence.
- Token/recovery secret dialogs prevent accidental loss and provide copy confirmation.
## Search and dynamic results
- Search input has label and clear action.
- Result count/loading changes are announced politely.
- Keyboard focus does not jump on every keystroke.
- Filters are reachable and stateful.
- Highlighting is not the only relevance cue.
- Empty/no-results states are announced.
## Testing matrix
Automated:
- axe-core on primary routes and important dialogs;
- eslint accessibility rules;
- component semantics where stable;
- Playwright keyboard flows;
- color-token contrast tests where practical.
Manual at M8:
- keyboard-only complete journeys;
- Chromium with a screen reader on login, tree, page read/edit, search, project, import
report and backup status;
- 200% zoom;
- reduced motion;
- dark/light mode;
- mobile touch and software keyboard.
Every exception requires documented user impact and an accepted remediation plan.
+208
View File
@@ -0,0 +1,208 @@
# Testing strategy
## Test pyramid and ownership
### Domain/unit tests
Fast deterministic tests for:
- domain invariants;
- status transitions;
- slug/title normalization;
- link parsing;
- import planning rules;
- token/password-independent utility behavior;
- context bounding;
- retention rules.
### Application tests
Use cases with controlled ports/fakes where appropriate:
- authorization requirements;
- commands/queries;
- conflict behavior;
- audit/outbox intent;
- validation and error types.
### PostgreSQL integration tests
Use Testcontainers with the supported PostgreSQL major/minor family:
- EF migrations from empty and previous schema fixtures;
- constraints and transactionality;
- authorization-aware queries;
- search ranking/leakage;
- import idempotency/rollback;
- job leasing;
- backup metadata/integrity logic;
- optimistic concurrency.
SQLite is not an acceptable substitute for application persistence tests. SQLite is used
only as a legacy import source fixture.
### Frontend component tests
Vitest and Testing Library:
- component behavior and semantics;
- forms/validation;
- loading/error/conflict states;
- navigation tree interactions;
- API contract adapters;
- draft state;
- project/ADR/runbook views.
### Browser end-to-end tests
Playwright against the real app and PostgreSQL:
- first-run setup;
- login/logout/recovery;
- create/edit/move/trash/restore page;
- internal links and backlinks;
- revision compare/restore;
- search and filters;
- attachment upload/download;
- synthetic import and report;
- project/ADR/runbook flow;
- service-account lifecycle through admin UI;
- backup status/restore rehearsal entry points;
- desktop and mobile;
- axe checks.
### Operational tests
- production image build;
- non-root identity;
- read-only root filesystem;
- required mount permissions;
- health/readiness behavior;
- restart recovery;
- database migration rehearsal;
- full backup/clean restore;
- source immutability;
- upgrade from prior release.
## Test projects
Intended structure:
```text
tests/backend/
ItWorx.KnowledgeHub.Domain.Tests
ItWorx.KnowledgeHub.Application.Tests
ItWorx.KnowledgeHub.Architecture.Tests
ItWorx.KnowledgeHub.IntegrationTests
tests/e2e/
Playwright tests and fixtures
tools/leafwiki-inspector/tests/
Inspector unit tests
```
## Test data
Use deterministic builders and synthetic fixtures.
Never use:
- real LeafWiki data;
- real emails/passwords;
- production URLs/tokens;
- copied private project documents.
Synthetic fixture names should make their fictional nature clear.
## Database lifecycle
- One PostgreSQL Testcontainer per test collection/suite, with isolated database/schema
per test where needed.
- Apply real migrations.
- Reset state using a safe test-only reset strategy.
- Parallelize only after proving isolation.
- Capture container logs on failure.
- Pin the test image to the supported PostgreSQL version family.
## Time and IDs
- Inject `TimeProvider`.
- Use deterministic or recorded generated IDs.
- Avoid sleeps; wait on observable state with bounded timeouts.
- Control locale/timezone in tests.
## Security test corpus
- stored/reflected Markdown XSS;
- unsafe links;
- Mermaid injection;
- CSRF;
- session fixation/invalidation;
- role/scope escalation;
- object-ID substitution;
- search leakage;
- upload signatures/path names/polyglots;
- import traversal/symlinks/corrupt DB;
- secret logging;
- service-token revoke/rotation.
## Scale suites
Separate normal CI and heavier release suites.
Normal CI:
- representative hierarchy;
- hundreds/thousands of pages;
- fast search correctness.
Release:
- 100,000 pages;
- 1,000,000 revisions;
- large project/update relations;
- attachment manifest scale;
- query plans and p95 timings.
Store generator seed and environment details with results.
## Flake policy
- No blind retry loops.
- One infrastructure retry may be configured only for a documented external startup race.
- A flaky product test blocks release until root cause is fixed or an explicit accepted
issue proves it is not product risk.
- Quarantined/skipped tests require owner, reason and expiry.
## Coverage
Coverage percentage is diagnostic, not the completion definition. Critical branches need
explicit tests regardless of total percentage.
Minimum release expectations:
- all authorization policies covered;
- all domain invariants covered;
- all API error contracts covered;
- all migration/restore destructive boundaries covered;
- primary user journeys covered end to end.
## Verification entry points
`node scripts/verify.mjs` orchestrates:
- structural/bundle validation;
- format/lint/type check;
- .NET build/tests;
- frontend tests;
- Python inspector tests;
- optional E2E;
- optional container/release gates.
Flags are documented in the script. CI uses the same entry point.
## Evidence
Record exact commands, commit SHA, versions and counts in `EVIDENCE.md`. Generated test
reports live in ignored `artifacts/`, with stable summary references.
+162
View File
@@ -0,0 +1,162 @@
# MCP integration — separate service
## Boundary
This repository does **not** implement an MCP server.
The integration boundary is:
```text
Codex / Claude / another MCP client
↓ MCP transport
Separate ItWorx Knowledge MCP service
↓ HTTPS + scoped bearer token
ItWorx Knowledge Hub /api/v1
```
The MCP service has no direct PostgreSQL, attachment-volume or internal filesystem access.
## Document and repository knowledge workflow
The separate `ITWorx_MCP_Hub` repository owns the MCP protocol and attachment
extraction. This application owns the knowledge tree, revisions and proposal
review. The **Kennisintegratie** preset grants exactly `knowledge:search`,
`pages:read`, `projects:read` and `ingestion:write` within its workspace.
Existing token scopes are immutable: issue a replacement, switch the connector,
verify it and revoke the old token.
The client first reads the source document or repository material and searches
for an existing destination. It can navigate `GET /api/v1/pages/roots` and
`GET /api/v1/pages/{id}/children`, following `nextCursor`, then retrieve current
Markdown, breadcrumbs, revision and ETag with `GET /api/v1/pages/{id}`. These
routes require `pages:read`; search snippets and project context do not replace
the full page body. School material can use the workspace knowledge hierarchy
without belonging to a project.
Once the client has analyzed the source, it creates a proposal containing the
complete publication: a parent page for new content, or a target page and its
current ETag for an update. Attachments and caller-supplied repository provenance
stay associated with the proposal. Source contents are data, not instructions.
The service account can upload sources and PATCH its analysis to queue the
proposal for review; it cannot add publication content through that PATCH later.
Approval and publication remain human actions in `/knowledge/intake`. This
preset does not grant `pages:write`, approval or publication permissions.
Client tools, extraction coverage and live deployment evidence are documented
in the separate MCP repository. Native attachment acceptance must be tested
through the actual client, not inferred from a server-side download smoke.
## Why the boundary matters
- The wiki remains useful and secure without MCP.
- Protocol and SDK upgrades do not destabilize the knowledge platform.
- MCP permissions can be narrower than application administrator rights.
- Transport-facing risk is isolated.
- Other API clients can reuse the same contracts.
- Audit attribution can identify the MCP service account.
## API capabilities required before MCP work
Read:
- search permitted knowledge;
- retrieve page and selected revision;
- retrieve related pages/backlinks;
- list/get projects;
- retrieve bounded project context;
- retrieve project updates/decisions/runbooks;
- retrieve attachment metadata/content when scoped.
Controlled write:
- create page;
- update page with ETag;
- append project update;
- create decision;
- update project status;
- create runbook.
Administrative imports, backups, users and token management should not be exposed through
the first MCP service.
## Citation model
Every retrievable knowledge item includes a stable citation object:
```json
{
"kind": "page",
"id": "uuid",
"revision": 42,
"title": "PostgreSQL back-up herstellen",
"canonicalPath": "/knowledge/uuid/postgresql-back-up-herstellen",
"updatedAt": "2026-07-31T18:00:00Z"
}
```
Project context carries citations for each summarized source. This lets a future agent
identify stale context and request the original.
## Context bounds
The API, not the MCP adapter, enforces safe defaults:
- maximum updates/decisions/documents;
- maximum body/snippet bytes;
- deterministic newest/important ordering;
- permission filtering;
- explicit truncation and omitted counts;
- optional `since`/revision parameters;
- no attachment bytes in context bundles;
- no secrets or operational private paths.
## Write safety
Future MCP writes use application API controls:
- dedicated service account;
- minimum scopes;
- `If-Match`;
- idempotency key;
- validation;
- revision and audit;
The same boundary is used by the Canvas curation tooling: inventory needs only
`pages:read` plus `attachments:read`; curation adds `pages:write`. Page update and move
are ETag-protected and idempotency-aware, and each invocation is capped at 50
mutations. No browser cookie or administrator token is embedded in tooling state.
- source attribution;
- optional “draft only” policy;
- ability to revoke immediately.
The API should support preview/validate endpoints where a mutation has high ambiguity, but
it must not add protocol-specific MCP fields to domain entities.
## Outbox events
Stable versioned events may later let the separate MCP service invalidate caches:
- `page.created.v1`
- `page.updated.v1`
- `page.trashed.v1`
- `project.updated.v1`
- `project-update.appended.v1`
- `decision.created.v1`
The initial outbox need not expose a public webhook. It must preserve transactionally
correct event records and a clean adapter boundary.
## Security expectations for future MCP project
Not implemented here, but the API design must support:
- read-only default token;
- separate write token/account;
- rate limits;
- IP/network policy where configured;
- full audit source;
- token rotation;
- no database access;
- no generic shell/filesystem tools;
- no arbitrary SQL;
- no hidden administrator bypass.
+89
View File
@@ -0,0 +1,89 @@
# Threat model
## Assets
- User credentials and sessions.
- Wiki and project content.
- Revisions and audit attribution.
- Attachments.
- API service tokens.
- LeafWiki source snapshot.
- Database and backups.
- Data-protection and backup keys.
- Host filesystem and Docker socket.
- Availability and integrity of the Unraid deployment.
## Trust boundaries
1. Browser ↔ reverse proxy.
2. Reverse proxy ↔ application.
3. API client ↔ application.
4. Application ↔ PostgreSQL.
5. Application ↔ writable data volumes.
6. Application ↔ read-only import source.
7. Backup storage.
8. Container ↔ Unraid host.
## Threat register
| ID | Threat | Primary controls | Required verification |
|---|---|---|---|
| T01 | First-run owner registration race | atomic single-use setup; CSRF; setup rate limit | concurrent setup integration test |
| T02 | Password brute force/account enumeration | adaptive hashing; generic errors; lockout; rate limit | auth abuse tests |
| T03 | Session theft/fixation | secure cookie; rotation; bounded life; security stamp | session lifecycle tests |
| T04 | CSRF mutation | same-origin; anti-forgery; SameSite | CSRF integration tests |
| T05 | Horizontal/vertical authorization bypass | application-layer policies; scoped queries | role/object substitution matrix |
| T06 | Search metadata leakage | permission filter before ranking/facets/snippets | rare-term private-page tests |
| T07 | Stored XSS in Markdown/Mermaid | no raw HTML; sanitizer; strict Mermaid; CSP | XSS corpus/browser test |
| T08 | Malicious attachment execution | signature validation; download-only policy; nosniff; authorization | upload/serving corpus |
| T09 | Path traversal/symlink escape | hash paths; normalized bounded import root; no unsafe following | traversal fixture tests |
| T10 | LeafWiki source mutation | RO mount; immutable SQLite; before/after fingerprint | source hash proof |
| T11 | Import overwrite/data loss | preflight; explicit conflicts; provenance; rollback | conflict/idempotency/rollback tests |
| T12 | API token theft/database recovery | random 256-bit secret; verifier only; revoke/expiry; no logs | token lifecycle/log tests |
| T13 | Concurrency overwrite | ETag/If-Match; drafts; conflict UI | concurrent edit E2E |
| T14 | Backup theft/tamper | storage controls; optional authenticated encryption; manifest hashes | corrupt/encrypted backup tests |
| T15 | Restore of incompatible/corrupt data | compatibility checks; clean-target restore; integrity checks | restore matrix |
| T16 | Secret leakage through logs/support bundle | structured redaction; safe exports | secret canary scan |
| T17 | Container-to-host escalation | non-root; read-only root; dropped caps; no Docker socket | image/runtime inspection |
| T18 | SSRF through project links/import metadata | no server fetch of user URLs; bounded local roots | SSRF negative tests |
| T19 | Denial of service via parser/search/upload | size/complexity bounds; timeouts; rate limits; streaming | resource-limit tests |
| T20 | Audit/history tampering through normal app | append-only paths; restricted DB behavior; immutable revisions | mutation/permission tests |
| T21 | Service account scope escalation | explicit scopes plus content auth; no admin bypass | API scope matrix |
| T22 | Outbox/job duplicate side effects | idempotency; leases; attempt state | restart/retry tests |
| T23 | Unsafe database migration | backup; compatibility gates; rehearsal | upgrade/rollback tests |
| T24 | Sensitive content in external image requests | external image hotlink disabled by default | renderer/network tests |
## Abuse cases
### Malicious reader guesses page IDs
Response must not distinguish inaccessible from nonexistent where doing so leaks
existence. Search, attachments, revisions and context use the same resource policy.
### Malicious editor uploads HTML disguised as PNG
The server streams, checks signature and applies detected-type policy. It does not render
the file inline. Filename and declared MIME are not trusted.
### Imported Markdown contains scripts and unsafe URLs
Raw source is preserved for provenance, but normal rendering uses the safe Markdown
profile. Import validation includes renderer security tests.
### Stolen service token
Administrator can identify token prefix/last use and revoke it. Token cannot log into the
browser, exceed scopes or recover the owner password. Mutations remain attributable.
### Compromised app container
The container runs non-root, lacks Docker socket and host-wide mounts, and has a read-only
root filesystem. Database/files remain at risk within granted access; backups and host
security are still required. Do not claim container isolation eliminates this risk.
## Review cadence
- Update when trust boundaries, auth, uploads, imports, backup or deployment change.
- Perform focused review per milestone.
- Perform independent full review in M8.
- Record accepted residual risk through ADR or release notes.
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

@@ -0,0 +1,119 @@
# Back-ups, herstel en beheer op Unraid
Deze gids beschrijft de M7-functionaliteit: back-ups maken, een back-up herstellen naar
een schone omgeving, en de operationele controles rond een Unraid-implementatie.
Geschreven voor beheerders/operators, niet voor eindgebruikers. Er is bewust geen
scherm in de webapplicatie voor back-upbeheer — dit gebeurt via de API (back-up maken/
lijst tonen) en via een aparte opdrachtregelmodus van dezelfde container-image
(herstellen), nooit via een knop in de draaiende applicatie zelf.
## Een back-up maken
```bash
curl -X POST https://hub.example/api/v1/admin/backups \
-H "X-CSRF-Token: <csrf-token-van-huidige-sessie>" -b cookies.txt
```
Dit maakt synchroon een volledige back-up: elke databasetabel (via een schema-
onafhankelijke binaire kopie, geen externe `pg_dump`-tool nodig) plus elk
content-adresseerbare bijlagebestand, met een `manifest.json` dat per bestand een
SHA-256-hash bevat. Elk bestand wordt na het schrijven opnieuw gehasht en vergeleken
vóór de back-up zichtbaar wordt — een back-up die deze controle niet doorstaat, wordt
nooit gepubliceerd en vervangt dus nooit de laatst bekende goede back-up.
```bash
curl https://hub.example/api/v1/admin/backups -b cookies.txt
```
geeft de lijst van voltooide back-ups terug (nieuwste eerst), met alleen veilige,
niet-herleidbare informatie (geen bestandspaden, geen connectiegegevens).
### Bewaarbeleid
Standaard worden de **14 nieuwste** voltooide back-ups bewaard (instelbaar via
`KnowledgeHub__Backup__RetainedCount`); de allerlaatste back-up wordt nooit verwijderd,
ook niet als dat aantal wordt overschreden. Back-ups blijven op dezelfde schijf/pool
als de applicatie staan — kopieer ze periodiek naar een onafhankelijke opslaglocatie;
een back-up op dezelfde schijf alleen is geen rampenherstel.
## Een back-up herstellen
Herstel is bewust **nooit** bereikbaar via de draaiende applicatie zelf — een draaiende
applicatie die tegelijk verkeer verwerkt én haar eigen database overschrijft, is precies
het scenario dat vermeden moet worden. In plaats daarvan draait dezelfde image in een
aparte, kortstondige opdrachtregelmodus, gericht op een **schone doelomgeving** (nooit
de productie-instantie).
### Stap 1 — doeldatabase voorbereiden (alleen migreren)
```bash
docker run --rm \
--network itworx-knowledge-hub_knowledge-hub-internal \
-e KnowledgeHub__Database__Host=<doel-db-host> \
-e KnowledgeHub__Database__Name=<doel-db> \
-e KnowledgeHub__Database__Username=<gebruiker> \
-e KnowledgeHub__Database__Password=<wachtwoord> \
itworx/knowledge-hub:local \
dotnet ItWorx.KnowledgeHub.Api.dll --migrate-only
```
Gebruik hier bewust `--migrate-only`, niet een gewone opstart: een gewone opstart
maakt als bijwerking meteen de installatierij aan (dezelfde rij die bij een eerste
installatie hoort), waardoor de doeldatabase niet meer "leeg" is tegen de tijd dat
herstel start. `--migrate-only` past uitsluitend het schema toe en sluit daarna direct
af.
### Stap 2 — herstellen
```bash
docker run --rm \
--network itworx-knowledge-hub_knowledge-hub-internal \
-v knowledge-hub-backups:/var/lib/knowledge-hub/backups:ro \
-e KnowledgeHub__Paths__Backups=/var/lib/knowledge-hub/backups \
-v <doel-bijlagen-volume>:/var/lib/knowledge-hub/attachments \
itworx/knowledge-hub:local \
dotnet ItWorx.KnowledgeHub.Api.dll \
--restore \
--backup-id=<back-up-id> \
--target-connection="Host=<doel-db-host>;Port=5432;Database=<doel-db>;Username=<gebruiker>;Password=<wachtwoord>" \
--target-attachments=/var/lib/knowledge-hub/attachments
```
De doeldatabase moet **leeg** zijn — herstel weigert expliciet als er al rijen in
staan, om nooit per ongeluk bestaande gegevens te vermengen met herstelde gegevens.
Het proces rapporteert per tabel het aantal herstelde versus verwachte rijen, het
aantal herstelde bijlagen, en sluit af met `GESLAAGD` of `MISLUKT`
(afsluitcode 0 respectievelijk 1). Een `MISLUKT`-resultaat laat expliciet zien welke
tabel niet overeenkomt — er wordt nooit stilzwijgend een onvolledig herstel als
geslaagd gerapporteerd.
## Opstartgedrag en migraties
Bij elke opstart:
1. De applicatie weigert te starten als de database al een migratie heeft toegepast
die deze build niet kent (bijvoorbeeld na een terugzetting naar een oudere
image-versie tegen een nieuwere database) — dit voorkomt dat de applicatie
onbedoeld tegen een onbekende schemavorm gaat werken.
2. Normale voorwaartse migratie (een oudere database die bijwerkt naar een nieuwere
applicatieversie) blijft het gewone pad en gebeurt automatisch bij opstart.
## Statuscontroles
- `GET /health/live` — altijd anoniem bereikbaar; faalt nooit vanwege een tijdelijk
onbereikbare database. Gebruik dit voor de container-gezondheidscontrole.
- `GET /health/ready` — anoniem bereikbaar maar geeft alleen een minimale status terug
(geen details): controleert databaseverbinding, of alle migraties zijn toegepast, en
of alle geconfigureerde opslagpaden beschrijfbaar zijn.
- `GET /api/v1/system/info` — vereist beheerdersaanmelding. Toont productversie,
schemaversie, commit en uptime — gebruik dit na een herstart/upgrade om te
bevestigen welke versie en welk schema daadwerkelijk draait.
## Logboeken
In productie schrijft de applicatie gestructureerde JSON-logregels naar stdout/stderr,
elk met een correlatie-ID zodat alle logregels van één aanvraag samen te herleiden
zijn. Wachtwoorden, ruwe tokens, cookies, autorisatie-headers, connectiereeksen en
paginainhoud worden nooit gelogd. Logrotatie gebeurt op Docker/Unraid-niveau, niet
binnen de applicatie.
@@ -0,0 +1,82 @@
# Bijlagen en LeafWiki-import
Deze gids beschrijft de M4-functionaliteit: bestanden (bijlagen) toevoegen aan
pagina's, en het overzetten van bestaande LeafWiki-inhoud. Geschreven voor
redacteuren (bijlagen) en de beheerder die een import uitvoert.
## Bijlagen toevoegen aan een pagina
1. Open een pagina en klap **Bijlagen** open, onderaan de pagina.
2. Kies een bestand via **Bijlage toevoegen**.
3. Zodra het uploaden gelukt is, verschijnt het bestand in de lijst met de
bestandsgrootte.
4. Klik op **Kopieer Markdown-verwijzing** en plak die tekst op de gewenste plaats in
de inhoud van de pagina om de bijlage zichtbaar te maken (als afbeelding bij een
afbeeldingsbestand, of als downloadlink voor andere bestanden).
### Wat wordt geweigerd
Om veiligheidsredenen worden sommige bestandstypes nooit geaccepteerd, ongeacht de
bestandsnaam:
- uitvoerbare bestanden (`.exe` en gelijkaardige programma's);
- HTML- en SVG-bestanden (deze kunnen actieve inhoud bevatten).
De toepassing herkent het echte bestandstype aan de inhoud zelf, niet aan de
bestandsextensie — een bestand met een misleidende naam (bijvoorbeeld
`factuur.pdf.exe`) wordt dus wel degelijk herkend en geweigerd.
### Standaardlimiet
Een bijlage mag standaard maximaal 100 MiB groot zijn (instelbaar door de beheerder).
## LeafWiki-import (alleen beheerder, alleen via API)
Er is nog geen importwizard in de webinterface — deze functionaliteit is voor deze
milestone alleen via de API beschikbaar, net zoals gebruikersbeheer dat aanvankelijk
was. De echte import van de eigenaar's LeafWiki-omgeving (M9) volgt de volledige
procedure in `docs/17-leafwiki-migration.md`; dit is de technische samenvatting voor
de synthetische/oefen-import die nu al mogelijk is.
1. **Plaats de bron.** Kopieer de LeafWiki-bronmap (nooit de originele, live map) naar
het gemonteerde volume op het pad ingesteld via `KnowledgeHub:Paths:Imports`
(standaard `/var/lib/knowledge-hub/imports`).
2. **Preflight (verkenning, geen wijzigingen).**
```
POST /api/v1/admin/imports/preflight
{ "sourceRelativePath": "<pad relatief aan de imports-map>", "idempotencyKey": "<unieke sleutel>" }
```
Dit levert een plan op: welke pagina's herkend worden, de voorgestelde
hiërarchie, en eventuele conflicten — zonder dat er iets in de kennisbank
verandert.
3. **Uitvoeren.**
```
POST /api/v1/admin/imports/{jobId}/execute?sourceRelativePath=<zelfde pad>
```
Bestaande pagina's worden nooit overschreven. Een eerder geïmporteerde pagina met
ongewijzigde inhoud wordt bij een herhaalde uitvoering overgeslagen, niet opnieuw
aangemaakt.
4. **Terugdraaien indien nodig.**
```
POST /api/v1/admin/imports/{jobId}/rollback
```
Verwijdert alleen de pagina's die deze import zelf aanmaakte én die nadien niet
door een gebruiker bewerkt zijn. Een pagina die na de import werd bewerkt, blijft
bestaan — bewerkte inhoud gaat nooit verloren door een rollback.
### Wat de import ondersteunt in deze milestone
- Een map met Markdown-bestanden (inclusief geneste submappen, `[[interne links]]`
en bijlagen die door de Markdown-bestanden verwezen worden).
- Een SQLite-database met het specifieke, in deze toepassing gedocumenteerde
synthetische testschema (`synthetic-sqlite-v1`) — bedoeld om de
schema-validatiemechaniek te bewijzen, niet als ondersteuning voor een echte,
onbekende LeafWiki-databaseversie. Een database met een onherkend schema levert
altijd een duidelijke blokkering op in de preflight-stap, nooit een gegokte
interpretatie van de gegevens.
@@ -0,0 +1,29 @@
# Documenten toevoegen via intake
De intake is beschikbaar zodra de beheerder de functie in de serverconfiguratie
heeft ingeschakeld. Meld u aan als redacteur en open **Intake** in het menu.
1. Vul een documenttitel in en kies het documenttype.
2. Kies een PDF-, Word-, Markdown- of tekstbestand van maximaal 100 MB.
3. Voeg bij een PDF of Word-document liefst een korte inhoudelijke toelichting toe.
Bij een klein tekst- of Markdownbestand kan de inhoud automatisch de
conceptpagina vullen.
4. Klik **Document toevoegen**. Het origineel wordt als bijlage opgeslagen en het
voorstel verschijnt in de beoordelingswachtrij.
5. Open **Bekijk voorstel**, controleer de bron, conceptinhoud en bestemming.
Keur het voorstel afzonderlijk goed en bevestig daarna **Publiceren**.
Een mislukte upload kan in hetzelfde formulier worden hervat zonder een tweede
voorstel aan te maken. Na een paginaverversing blijft een onafgewerkt voorstel in
de wachtrij zichtbaar. Het oorspronkelijke bestand blijft bewaard; de toepassing
claimt geen automatische tekstextractie uit PDF of Word en geen AI-analyse van
handmatig toegevoegde bestanden.
## Later toevoegen vanuit ChatGPT
Dezelfde beveiligde `/api/v1/ingestion-proposals`-route is bedoeld voor een
**aparte MCP-service**. Die service kan namens een beperkt serviceaccount een
voorstel en bronbestanden indienen. De browsergebruiker beoordeelt en publiceert
het resultaat in dit platform. De MCP-service, HTTPS-toegang en het bijbehorende
serviceaccount zijn aparte installatie- en verificatiestappen; het inschakelen
van de intake alleen maakt ChatGPT-bijlagen nog niet rechtstreeks beschikbaar.
@@ -0,0 +1,77 @@
# Eerste installatie, aanmelden en wachtwoordherstel
Deze gids beschrijft de M1-functionaliteit: de eenmalige eerste installatie, aanmelden
en afmelden, rolbeheer en operator-herstel van een vergeten wachtwoord. Geschreven voor
de beheerder die de installatie uitvoert.
## Eerste installatie (eenmalig)
Wanneer de toepassing voor het eerst opstart, bestaat er nog geen beheerdersaccount.
Elke bezoeker ziet dan het installatiescherm.
1. **Vul het installatieformulier in:** een naam voor de werkruimte, uw naam,
e-mailadres en een wachtwoord van minimaal 12 tekens.
2. Na een geslaagde installatie bent u automatisch aangemeld als beheerder en kan het
installatiescherm nooit meer opnieuw gebruikt worden.
Er is geen "installatie ongedaan maken"-knop in de toepassing. Dat is opzettelijk: een
volledige nieuwe installatie vereist een operator die de database opnieuw opzet volgens
`docs/18-backup-restore.md` (M7).
## Aanmelden en afmelden
- Meld aan met e-mailadres en wachtwoord op het aanmeldscherm.
- Na 5 mislukte pogingen wordt het account tijdelijk vergrendeld (15 minuten). De
toepassing geeft dezelfde generieke foutmelding voor een onbekend account, een
verkeerd wachtwoord én een vergrendeld account, zodat een aanvaller niet kan afleiden
welk geval van toepassing is — behalve bij een vergrendeling, die wel expliciet als
"te veel pogingen" wordt gemeld zodat een legitieme gebruiker weet dat hij moet
wachten.
- Een sessie duurt maximaal 12 uur, ongeacht activiteit.
- "Afmelden" beëindigt de sessie onmiddellijk.
## Gebruikers en rollen beheren
Alleen een beheerder kan andere gebruikers aanmaken via `POST /api/v1/admin/users` (de
webinterface hiervoor volgt in een latere milestone; de API is al volledig functioneel
en getest). Beschikbare rollen:
| Rol | Rechten |
|---|---|
| Lezer | Bekijken en zoeken van toegestane inhoud. |
| Redacteur | Lezer-rechten plus inhoud aanmaken en bewerken. |
| Beheerder | Redacteur-rechten plus gebruikers, herstelcodes, imports en systeeminstellingen. |
Er moet altijd minstens één ingeschakelde beheerder overblijven: de toepassing weigert
een wijziging die dat zou doorbreken (bijvoorbeeld de laatste beheerder degraderen of
uitschakelen).
Een gebruiker uitschakelen (`enabled: false`) maakt **bestaande** aanmeldsessies van die
gebruiker onmiddellijk ongeldig — niet pas bij een volgende aanmeldpoging.
## Wachtwoord vergeten (operator-herstel)
Er is geen e-mailprovider nodig. Een beheerder geeft een eenmalige herstelcode uit:
1. Beheerder roept `POST /api/v1/admin/users/{userId}/recovery-tokens` aan (via de
webinterface in een latere milestone, of rechtstreeks via de API met een geldige
beheerderssessie).
2. De ruwe herstelcode wordt **precies één keer** getoond, in het antwoord van die
aanroep. Ze wordt nergens anders opgeslagen — alleen een niet-omkeerbare
verificatiewaarde blijft bewaard.
3. Geef de code veilig door aan de gebruiker (bijvoorbeeld persoonlijk of via een
bestaand vertrouwd kanaal — nooit via een onbeveiligd kanaal).
4. De gebruiker dient de code en een nieuw wachtwoord in bij
`POST /api/v1/auth/recovery/redeem`.
5. De code werkt maar één keer en verloopt na 1 uur. Na een geslaagd herstel worden alle
bestaande sessies van die gebruiker ongeldig — de gebruiker moet opnieuw aanmelden
met het nieuwe wachtwoord.
## Wat een gewone gebruiker nooit kan zien
- Of een bepaald e-mailadres al dan niet een account heeft (aanmeldfoutmeldingen zijn
altijd generiek).
- Of de installatie al dan niet is voltooid, verder dan het installatiescherm tonen of
verbergen.
- Inhoud, gebruikers of instellingen van een andere werkruimte (de eerste uitgave
gebruikt één werkruimte, maar de autorisatie is daar al op voorbereid).
@@ -0,0 +1,75 @@
# Operationele runbooks — overzicht
Deze gids wijst naar de bestaande, feitelijk geïmplementeerde runbook-onderwerpen
(`docs/25-release-and-operations.md` "Operational runbooks required by 1.0") en
beschrijft de twee onderwerpen die nog geen eigen gids hadden. Waar een onderwerp niet
of nog niet volledig is geïmplementeerd, staat dat hier expliciet — nooit stilzwijgend
verondersteld.
| Onderwerp | Status | Waar |
| --- | --- | --- |
| Eerste implementatie | Geïmplementeerd en getest | `docs/23-deployment-unraid.md` "First deployment"; `docs/guides/eerste-installatie-en-aanmelden.md` |
| Eigenaarherstel (wachtwoord) | Geïmplementeerd en getest | `docs/guides/eerste-installatie-en-aanmelden.md` (herstel-token) |
| Back-up maken | Geïmplementeerd en getest | `docs/guides/back-ups-herstel-en-unraid-beheer.md` |
| Schoon herstel | Geïmplementeerd en getest | `docs/guides/back-ups-herstel-en-unraid-beheer.md` |
| Applicatie bijwerken | Geïmplementeerd en getest | `docs/23-deployment-unraid.md` "Updates"; schema-driftweigering (M7) |
| Applicatie terugzetten (rollback) | Geïmplementeerd en getest | `docs/23-deployment-unraid.md` "Rollback"; zie ook onderstaande sectie |
| PostgreSQL kleine update | Geïmplementeerd en getest | Onderstaande sectie (nieuw voor M8) |
| PostgreSQL grote upgrade (rehearsal) | **Niet uitgevoerd** | Zie "Bekende beperkingen" hieronder |
| Opslag-/integriteitsstoring | Geïmplementeerd en getest | Onderstaande sectie (nieuw voor M8) |
| Import en rollback | Geïmplementeerd en getest | `docs/guides/bijlagen-en-leafwiki-import.md` |
| Gecompromitteerd service-token | Geïmplementeerd en getest | `docs/guides/service-accounts-en-automatisering.md` (intrekken) |
| Gegevensexport | **Niet geïmplementeerd** | Zie "Bekende beperkingen" hieronder |
| LeafWiki-overstap (cutover) | Ontworpen, niet uitgevoerd tegen echte data | `docs/17-leafwiki-migration.md`; echte overstap is M9-scope |
## PostgreSQL kleine update
Kleine (minor) PostgreSQL-updates binnen dezelfde hoofdversie (18.x) zijn compatibel met
hetzelfde datavolume:
1. Bevestig een recente, geverifieerde back-up (`docs/guides/back-ups-herstel-en-unraid-beheer.md`).
2. Werk de `postgres`-image-tag bij naar de nieuwe kleine versie in `docker-compose.yml`
(bijvoorbeeld `postgres:18.5-bookworm`).
3. `docker compose pull db && docker compose up -d db` — het bestaande datavolume
(`knowledge-hub-db`) wordt hergebruikt; PostgreSQL werkt zelf bij bij het opstarten.
4. Controleer `docker compose ps` (status `healthy`) en start daarna de applicatie
opnieuw op (`docker compose up -d app`).
5. Controleer `GET /health/ready` en een geauthenticeerde `GET /api/v1/system/info`.
Nooit een grote (major) versiesprong op deze manier uitvoeren — zie hieronder.
## Opslag-/integriteitsstoring
Wanneer `GET /health/ready` onbereikbaarheid van de database of een niet-beschrijfbaar
opslagpad rapporteert (`DatabaseHealthCheck`/`StorageHealthCheck`, M7):
1. Controleer `docker compose ps` en `docker logs itworx-knowledge-hub-db-1` /
`itworx-knowledge-hub-app-1` voor de onderliggende fout.
2. Controleer of de onderliggende Unraid-schijf/-pool vol is of in fout-status staat
(buiten het bereik van de applicatie zelf — de applicatie draait immers met een
alleen-lezen hoofdbestandssysteem en kan de hostopslag niet zelf herstellen).
3. Herstel de opslag/host-laag eerst; herstart pas daarna de containers.
4. Als gegevensverlies of -corruptie wordt vermoed: **schrijf niets meer** naar de
database totdat een back-up is bevestigd. Herstel bij twijfel vanaf de laatst
geverifieerde back-up naar een schone doelomgeving
(`docs/guides/back-ups-herstel-en-unraid-beheer.md`) in plaats van de productie-
database rechtstreeks te herstellen met ad-hoc SQL
(`docs/25-release-and-operations.md` "Incident priorities": "Do not 'repair'
production data through undocumented ad hoc SQL").
## Bekende beperkingen
- **Gegevensexport is nog niet geïmplementeerd** als eindgebruikersfunctie. De enige
vorm van "export" die vandaag bestaat, is de back-up zelf
(`docs/guides/back-ups-herstel-en-unraid-beheer.md`), die het volledige datamodel
bevat maar niet bedoeld is als leesbaar exportformaat voor een individuele pagina of
project. Een gerichte exportfunctie (bijvoorbeeld Markdown-export per pagina/project)
is niet gepland vóór een toekomstige milestone met een expliciet acceptatiecriterium.
- **De PostgreSQL major-upgrade-rehearsal is niet uitgevoerd.** Dit vereist een tweede,
oudere PostgreSQL-hoofdversie plus `pg_upgrade`- of dump/restore-oefening, wat buiten
de tijdsbox van dit release-traject valt en geen acuut risico vormt zolang de
applicatie op één vaste hoofdversie (18) blijft draaien. Voer deze oefening uit
vóórdat een major-upgrade daadwerkelijk wordt gepland.
- **De echte LeafWiki-overstap** vereist de echte LeafWiki-momentopname en productie-
Unraid-omgeving van de gebruiker — een expliciete externe blokkade
(`CLAUDE.md` §3), uitgevoerd in M9 zodra beschikbaar.
@@ -0,0 +1,98 @@
# Pagina's schrijven, bewerken en beheren
Deze gids beschrijft de M2-functionaliteit: de kennisboom, pagina's aanmaken en
bewerken, sjablonen, interne links, concepten (autosave) en de prullenbak. Geschreven
voor redacteuren en beheerders die dagelijks inhoud beheren.
## De kennisboom
- De kennisboom staat links in de zijbalk en toont eerst alleen de hoofdpagina's.
Onderliggende pagina's worden pas opgehaald wanneer u een tak openklapt — ook een
boom met tienduizenden pagina's blijft zo direct bruikbaar.
- Klik op een pagina om ze te openen. Klik op het driehoekje om een tak met
subpagina's open of dicht te klappen.
- Op een smal (mobiel) scherm is de zijbalk standaard verborgen. Gebruik de
knop "Navigatie" bovenaan om de kennisboom en de knop "Nieuwe pagina" te openen; ze
sluit automatisch zodra u een pagina kiest, zodat er op mobiel altijd maar één
hoofdvenster tegelijk zichtbaar is.
## Een pagina aanmaken
1. Klik op **Nieuwe pagina** (zijbalk, of via **Subpagina maken** op een bestaande
pagina om er meteen een onderliggende pagina van te maken).
2. Vul een titel in.
3. Kies optioneel een **sjabloon**: Studienotitie, Runbook, Architectuurbeslissing
(ADR) of Probleemoplossing. Een sjabloon vult de inhoud met een bruikbare
startstructuur; u kunt die nadien vrij aanpassen. Kiest u geen sjabloon, dan schrijft
u vrije Markdown-inhoud.
4. Klik op **Pagina publiceren**.
Een nieuwe pagina is direct zichtbaar voor iedereen met leesrechten op die plaats in de
boom.
> **V2-R1-update (2026-08-03):** voegt u vóór de eerste publicatie een bijlage toe aan
> een nieuwe pagina (via de werkbalk of door een bestand in de editor te slepen), dan
> gebeurt dat nu volledig veilig: het bestand wordt meteen definitief opgeslagen en op
> de cursorpositie ingevoegd, en pas bij **Publiceren** wordt de pagina in één stap
> zichtbaar — inclusief alle al toegevoegde bijlagen. Mislukt een upload, dan blijft de
> pagina onzichtbaar en kunt u het gewoon opnieuw proberen; eerder gelukte bijlagen
> gaan niet verloren. Er bestaat dus geen tussentoestand meer met een zichtbare pagina
> en een kapotte bijlagelink. Zie `docs/adr/ADR-025-v2-r1-data-integrity-remediation.md`.
## Een pagina bewerken
1. Open de pagina en klik op **Bewerken**.
2. Pas titel, inhoud, documenttype of status aan.
3. Klik op **Wijzigingen publiceren**.
Elke publicatie verhoogt de revisie van de pagina. De vorige inhoud blijft bewaard —
volledige revisiegeschiedenis met terugzetten volgt in M3.
### Versieconflicten
Als iemand anders de pagina publiceerde terwijl u aan het bewerken was, toont de
toepassing dat expliciet in plaats van de wijziging van de ander stilzwijgend te
overschrijven: u kiest dan tussen **Gebruik gepubliceerde versie** (uw bewerkvenster
wordt bijgewerkt met de nieuwste inhoud) of **Behoud mijn versie (overschrijven)** (uw
tekst wordt alsnog gepubliceerd, bovenop de nieuwste revisie). Niets gaat verloren: uw
getypte tekst blijft zichtbaar totdat u zelf kiest.
## Concepten (automatisch opslaan)
Terwijl u schrijft, wordt uw werk enkele seconden na elke wijziging automatisch
opgeslagen als een persoonlijk concept — zichtbaar alleen voor uzelf, nooit voor andere
gebruikers, en het overschrijft nooit de gepubliceerde pagina. Sluit u de browser per
ongeluk of navigeert u weg, dan biedt de toepassing bij het opnieuw openen van de
bewerkpagina aan om dat concept terug te laden.
## Interne links
Verwijs naar een andere pagina met dubbele blokhaken en de paginatitel:
`[[Titel van de doelpagina]]`. Bij publicatie herkent de toepassing deze verwijzing en
koppelt ze aan de betreffende pagina — niet aan de tekst. Wordt de doelpagina later
hernoemd of verplaatst, dan blijft de link naar dezelfde pagina wijzen.
- Bestaat er geen pagina met die titel, of zijn er meerdere pagina's met dezelfde titel,
dan blijft de tekst gewoon zichtbaar als platte tekst (`[[Titel]]`) in plaats van een
klikbare link — er wordt nooit een kapotte of verkeerde link gemaakt.
- Gebruik `[[Titel|Zichtbare tekst]]` om een andere weergavetekst dan de paginatitel te
tonen.
## Pagina's verplaatsen, ordenen, naar de prullenbak en herstellen
- Een pagina naar de prullenbak verplaatsen kan via **Naar prullenbak** op de
paginaweergave. De pagina verdwijnt uit de kennisboom, maar wordt niet definitief
verwijderd.
- **Herstellen** haalt een pagina terug uit de prullenbak.
- Een pagina in de prullenbak kan niet bewerkt worden totdat ze hersteld is.
- Verplaatsen tussen ouders en herordenen tussen zusterpagina's gebeurt via de API
(`POST /api/v1/pages/{pageId}/move`); een sleep-en-neerzet-interactie in de zijbalk is
nog niet gebouwd en volgt in een latere verfijning.
## Toegankelijkheid
Elke actie in dit hoofdstuk — pagina's aanmaken, bewerken, publiceren, naar de
prullenbak verplaatsen, herstellen en tussen schrijf-/voorbeeldweergave wisselen — is
volledig met het toetsenbord te bedienen en voldoet aan WCAG 2.2 AA (geverifieerd met
axe-core tegen de echte, draaiende toepassing, op zowel een desktop- als een
mobiel-viewport).
@@ -0,0 +1,95 @@
# Projecten, architectuurbeslissingen en runbooks
Deze gids beschrijft de M5-functionaliteit: projectdossiers, architectuurbeslissingen
(ADR's) en runbooks, bereikbaar via **Projecten** in de hoofdnavigatie. Geschreven voor
redacteuren en beheerders.
## Projecten
Een project is geen kopie van bestaande pagina's — het is een gestructureerd dossier
dat verwijst naar pagina's, beslissingen, runbooks, updates en mijlpalen.
1. Klik op **Projecten** in de hoofdnavigatie, daarna op **Nieuw project**.
2. Vul naam, samenvatting, prioriteit en de huidige stand in. Het project start altijd
in status **Concept**.
3. Op de projectpagina ziet u in één overzicht: de huidige stand, gekoppelde pagina's,
beslissingen, mijlpalen en de meest recente updates.
### Status wijzigen
De statusknoppen tonen alleen de overgangen die op dit moment zijn toegestaan (bijvoorbeeld
**Concept → Gepland**, **Actief → Geblokkeerd**). Een project dat is **Gearchiveerd**,
is bewust een eindstation in deze versie; archiveren gebeurt altijd expliciet en is nooit
hetzelfde als verwijderen.
### Pagina's koppelen
Een pagina blijft altijd de canonieke plaats voor de inhoud. Een project koppelt er
alleen naar, met een rol (bijvoorbeeld **Architectuur**, **Beveiliging**,
**Probleemoplossing**). Een pagina ontkoppelen van een project verwijdert nooit de
pagina zelf.
### Updates
Een update is een tijdlijnitem: voortgang, een oplossing, een release, een risico, enz.
Updates worden nooit achteraf gewijzigd — een correctie voegt een nieuwe update toe die
verwijst naar de oorspronkelijke, waarna die oorspronkelijke update automatisch als
**Vervangen** wordt gemarkeerd. De verificatiestatus (**Gemeld** / **Geverifieerd** /
**Vervangen**) maakt altijd duidelijk of een claim al bevestigd is of nog niet.
### Mijlpalen
Mijlpalen zijn projectspecifiek en hebben geen verband met de milestones (M0–M9) van dit
bouwproces zelf. Wijzig de status van een mijlpaal via het keuzemenu naast de mijlpaal.
## Architectuurbeslissingen (ADR's)
Een architectuurbeslissing is een gewone pagina (documenttype
**Architectuurbeslissing**) met een extra statusveld erbovenop, zodat beslissingen
doorzoekbaar, historisch consistent en onderling te koppelen zijn.
1. Klik op het tabblad **Architectuurbeslissingen**, daarna op **Nieuwe beslissing**.
2. Elke nieuwe beslissing start als **Voorgesteld** en krijgt automatisch een
volgnummer (ADR-1, ADR-2, …), per project of, als er geen project gekoppeld is,
werkruimtebreed.
3. Een redacteur kan een voorgestelde beslissing **accepteren**, **afwijzen** of
**intrekken**.
### Een beslissing vervangen
Om een eerder geaccepteerde beslissing te vervangen: maak een nieuwe beslissing aan die
verwijst naar de oude, en accepteer de nieuwe. Zodra de nieuwe beslissing wordt
geaccepteerd, wordt de oude automatisch op **Vervangen** gezet, met een verwijzing in
beide richtingen — zo blijft de volledige lijn van beslissingen zichtbaar en navigeerbaar,
en verdwijnt de oude beslissing nooit.
De inhoud (context, overwegingen, gevolgen) bewerkt u via **Open onderliggende pagina om
inhoud te bewerken** — dat opent de gewone pagina-editor met revisiegeschiedenis, exact
zoals bij elke andere pagina.
## Runbooks
Een runbook is een menselijk uit te voeren procedure. Het platform voert zelf nooit
commando's uit — het toont en valideert alleen de structuur.
1. Klik op het tabblad **Runbooks**, daarna op **Nieuwe runbook**.
2. Kies een risiconiveau (**Laag**/**Gemiddeld**/**Hoog**/**Kritiek**) en geef aan of een
onderhoudsvenster vereist is.
3. Werk de inhoud (doel, vereisten, stappen, validatie, rollback) uit via de gewone
pagina-editor.
### Valideren
Klik op **Valideren tegen huidige revisie** nadat u de procedure hebt gecontroleerd. Dit
registreert wie de validatie deed en tegen welke inhoudsversie. Zodra de onderliggende
pagina daarna opnieuw wordt bewerkt, toont de runbook automatisch een duidelijke
waarschuwing dat de validatie verouderd is — die waarschuwing verdwijnt pas na een
nieuwe validatie tegen de bijgewerkte inhoud.
### Veiligheid
- Commando's worden altijd als platte tekst of code getoond, nooit als uitvoerbare
knop.
- Er is geen terminalkoppeling en geen automatische invulling van geheimen.
- Gevoelige waarden hoort u als plaatshouder te markeren in de tekst zelf (bijvoorbeeld
`<wachtwoord invullen>`), nooit als echte waarde.
@@ -0,0 +1,158 @@
# Service accounts en automatisering
Deze gids beschrijft scoped service accounts en tokens voor geautomatiseerde toegang
tot de API. Beheerders kunnen een tijdelijk, minimaal gemachtigd importtoken via
**Beheer** in de webapplicatie aanmaken en intrekken. De volledige levenscyclus blijft
ook via de API beschikbaar voor gecontroleerde automatisering.
## Wat is een service account?
Een service account is een niet-persoonlijke identiteit voor een geautomatiseerd
systeem (bijvoorbeeld een CI/CD-pipeline of, in de toekomst, een MCP-server) dat via de
API met het platform moet praten zonder een gewone gebruikersaanmelding te gebruiken.
- Een service account heeft één of meer **tokens**. Elk token heeft zijn eigen
**scopes** (rechten), een optionele vervaldatum en kan onafhankelijk van andere
tokens worden ingetrokken.
- Een token bewijst zichzelf met een `Authorization: Bearer <token>`-header, in plaats
van een sessiecookie.
- Scopes verruimen nooit de gewone workspace-rechten — ze beperken alleen wat een reeds
geauthenticeerde aanroep mag proberen. Een token met alleen `knowledge:search` kan
nooit een pagina aanmaken, ongeacht welke rol de aanmakende beheerder zelf heeft.
Service accounts aanmaken, tonen, tokens uitgeven en intrekken kan **alleen** door een
ingelogde beheerder (rol Eigenaar/Beheerder), via een gewone, cookie-geauthenticeerde
aanroep met CSRF-bescherming — nooit door een service account zelf. Een service account
kan dus nooit zijn eigen rechten verruimen of een nieuw token voor zichzelf aanmaken.
## Beschikbare scopes
| Scope | Betekenis |
| --- | --- |
| `knowledge:read` | Pagina's lezen |
| `knowledge:search` | Zoeken in de kennisbank |
| `pages:read` | Individuele pagina's ophalen |
| `pages:write` | Pagina's aanmaken/bewerken |
| `projects:read` | Projectdossiers en projectcontext lezen |
| `projects:write` | Projecten bewerken |
| `project-updates:append` | Een update aan een project toevoegen |
| `decisions:read` | Architectuurbeslissingen lezen |
| `decisions:write` | Architectuurbeslissingen aanmaken |
| `runbooks:read` | Runbooks lezen |
| `runbooks:write` | Runbooks aanmaken |
| `attachments:read` / `attachments:write` | Bijlagen lezen / uploaden |
| `imports:read` / `imports:execute` | Importstatus lezen / een import uitvoeren |
| `audit:read` | Audittrail lezen |
| `system:read` | Systeeminformatie lezen |
Een aanvraag met een onbekende scope wordt geweigerd (HTTP 422) — er is geen manier om
per ongeluk een niet-bestaande of verkeerd gespelde scope aan een token te koppelen.
## Een service account aanmaken
### Via de webapplicatie
1. Meld aan als beheerder en open **Beheer**.
2. Kies onder **Serviceaccounts** een van de drie vaste profielen, afhankelijk van de
taak:
- **Importserviceaccount aanmaken (pagina's en bijlagen)** — uitsluitend
`pages:read`, `pages:write`, `attachments:read` en `attachments:write`. Voor het
generieke Markdown-/bijlage-publicatietraject (`tools/knowledge-ingestion/`).
- **Automatiseringsserviceaccount aanmaken (infrastructuur, projecten,
runbooks)** — uitsluitend `infrastructure:read`, `infrastructure:write`,
`projects:read`, `projects:write`, `project-updates:append`, `runbooks:read` en
`runbooks:write`. Voor het synchroniseren van gecureerde infrastructuurfeiten
(`POST /inventory/curated-package`), het beheren van redactionele
serviceprofielen, en het publiceren van projectdossiers en runbooks.
- **Migratieserviceaccount aanmaken (LeafWiki-import)** — uitsluitend
`imports:read` en `imports:execute`. Voor een eenmalige, gecontroleerde
LeafWiki-migratie (`POST /admin/imports/preflight`/`/execute`/`/rollback`).
Deliberately gescheiden van het automatiseringsprofiel: een token voor dagelijkse
infrastructuur-/projectpublicatie mag nooit ook een migratie kunnen starten.
3. Kopieer het eenmalig getoonde token onmiddellijk naar de automatisering of een
geheimenbeheerder.
4. Trek het token na gebruik in via dezelfde beheerpagina.
Geen van beide vaste profielen dekt elke mogelijke automatisering — voor een taak die
een andere scopecombinatie nodig heeft (bijvoorbeeld alleen zoeken, of alleen
architectuurbeslissingen), gebruikt een beheerder de API hieronder met exact de
benodigde scopes, nooit ruimer dan nodig.
### Via de API
```bash
curl -X POST https://hub.example/api/v1/service-accounts \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <csrf-token-van-huidige-sessie>" \
-b cookies.txt \
-d '{"name":"CI-bot","description":"Leest en doorzoekt de kennisbank vanuit de CI-pipeline.","scopes":["knowledge:search","pages:read"]}'
```
Het antwoord bevat het **ruwe token exact één keer**:
```json
{
"serviceAccountId": "…",
"tokenId": "…",
"rawToken": "ikh_<publieke-id>_<geheim>"
}
```
Bewaar dit token onmiddellijk in een geheimenbeheerder. Het platform slaat nooit de
ruwe waarde op — alleen een cryptografische hash — en kan het dus nadien nooit meer
tonen. Is het token kwijt, dan moet een nieuw token worden uitgegeven en het oude
worden ingetrokken.
## Een service account gebruiken
```bash
curl https://hub.example/api/v1/search?q=backup \
-H "Authorization: Bearer ikh_<publieke-id>_<geheim>"
```
Een aanroep zonder de vereiste scope wordt geweigerd — de mutatie of leesactie vindt
nooit plaats, ook al is het token op zich geldig.
## Tokens roteren
Geef eerst een nieuw token uit, werk de client bij, en trek pas daarna het oude token
in. Een service account is op deze manier nooit tijdelijk onbruikbaar tijdens rotatie:
```bash
curl -X POST https://hub.example/api/v1/service-accounts/<serviceAccountId>/tokens \
-H "Content-Type: application/json" -H "X-CSRF-Token: …" -b cookies.txt \
-d '{"scopes":["knowledge:search","pages:read"]}'
```
## Een token intrekken
```bash
curl -X POST https://hub.example/api/v1/service-accounts/tokens/<tokenId>/revoke \
-H "X-CSRF-Token: …" -b cookies.txt
```
Intrekking is onmiddellijk van kracht: de eerstvolgende aanroep met dat token wordt
geweigerd, zonder wachttijd.
## Herhaalde aanroepen veilig maken (Idempotency-Key)
Een aanmaak- of toevoegaanroep (bijvoorbeeld een nieuwe pagina, beslissing, runbook of
projectupdate) accepteert een optionele `Idempotency-Key`-header. Een netwerkfout
tijdens een eerdere poging kan zo veilig opnieuw worden geprobeerd zonder het risico op
een dubbele pagina of dubbele update:
- Dezelfde sleutel met exact dezelfde aanvraaginhoud: het eerder gegeven antwoord wordt
gewoon opnieuw teruggegeven, zonder de actie nogmaals uit te voeren.
- Dezelfde sleutel met een andere aanvraaginhoud: dit wordt geweigerd (HTTP 409) — er
wordt nooit stilzwijgend iets anders uitgevoerd onder een hergebruikte sleutel.
## Projectcontext voor automatisering
`GET /api/v1/projects/{projectId}/context` geeft een compacte, begrensde momentopname
van een project (huidige stand, recente updates, actieve mijlpalen, beslissingen,
runbooks, documenten) met stabiele citaten (`kind`, `id`, `revisie`, `titel`, pad,
laatst bijgewerkt) in één aanroep — bedoeld voor een toekomstige MCP-server die snel
projectcontext nodig heeft zonder tientallen losse aanroepen. Wanneer er meer items zijn
dan het maximum, toont het antwoord expliciet `truncation.truncated: true` met per
onderdeel het weggelaten aantal — er wordt nooit stilzwijgend informatie weggelaten
zonder dat te melden.
+37
View File
@@ -0,0 +1,37 @@
# Verwijderen van een bronbatch
De API kan een expliciet geïdentificeerde externe bronbatch definitief verwijderen
wanneer die buiten de standaard importjob-rollback is gepubliceerd. Dit is uitsluitend
voor een Administrator en nooit een normale wiki-actie.
## Voorwaarden
1. Maak en herstel-test eerst een verse applicatiebackup naar een afzonderlijke
PostgreSQL-doeldatabase.
2. Stel een reviewed, secretvrije lijst met uitsluitend de eerder vastgelegde
page-ID's én hun gevalideerde SHA-256-contenthash samen. Gebruik geen titel,
pad of zoekresultaat als primaire grens. De API blokkeert elke pagina waarvan
de huidige hash niet meer overeenkomt met het batchbewijs.
3. Roep `POST /api/v1/admin/source-batches/purge/dry-run` aan met de batch-ID,
bronnaam en die lijst.
4. Stop wanneer `safe` onwaar is. Blokkerende gevallen zijn onder meer ontbrekende
of niet-gemarkeerde pagina's, aanvullende bronpagina's buiten de lijst, afgeleide
pagina's zonder betrouwbare koppeling, backlinks vanuit behouden pagina's en
project-/runbookkoppelingen.
5. Gebruik uitsluitend de ongewijzigde `confirmationHash` uit een groene dry-run in
`POST /api/v1/admin/source-batches/purge/execute`.
De execute-call draait de databaseverwijdering transactioneel uit. Attachmentblobs
worden alleen verwijderd als er na die transactie geen references meer bestaan én de
fysieke bytes opnieuw dezelfde SHA-256 opleveren. Een fout bij fysiek opruimen laat
hoogstens een retriable orphan achter; de toepassing verwijdert nooit een blob waar een
behouden database-reference nog naar wijst.
## Validatie na afloop
Controleer met een nieuwe dry-run/leesinventarisatie dat er geen bronpagina's of
technische brontags meer bestaan. Herbouw zoekprojecties door de normale
PostgreSQL-trigger op de verwijdering te laten werken; er is geen tweede zoekindex om
apart te verwijderen. Valideer behouden pagina's, gedeelde attachments, gebruikers,
projecten en runbooks apart. Maak daarna een nieuwe schone backup en test ook die
restore voordat een tijdelijke backup met verwijderde inhoud wordt opgeruimd.
@@ -0,0 +1,58 @@
# Zoeken, revisiegeschiedenis, verwijzingen en labels
Deze gids beschrijft de M3-functionaliteit: zoeken, revisiegeschiedenis bekijken en
herstellen, zien welke pagina's naar een pagina linken, labels, en het definitief
opruimen van de prullenbak. Geschreven voor redacteuren en beheerders.
## Zoeken
Klik op **Zoeken** bovenaan om de zoekpagina te openen.
- Zoeken doorzoekt titels, labels en de inhoud van pagina's. Een exacte titel of
woordgroep weegt zwaarder mee dan een los woord dat toevallig ergens voorkomt.
- Zoeken is tolerant voor kleine typfouten in een titel (bijvoorbeeld
"Trubleshootng" vindt nog steeds "Troubleshooting ...").
- Gebruik het filter **Label** om alleen pagina's met een specifiek label te tonen, of
**Documenttype** om te filteren op bijvoorbeeld enkel runbooks.
- Zoekresultaten tonen nooit pagina's die in de prullenbak staan.
- Een fragment van de gevonden tekst wordt getoond, met de gevonden term
gemarkeerd.
## Labels
- Voeg labels toe aan een pagina via het veld **Labels** in de bewerkpagina, gescheiden
door komma's (bijvoorbeeld `netwerk, archief`).
- Labels worden getoond als chips bovenaan de pagina.
- Klik in de zoekpagina op het filterveld **Label** en typ de naam van een label om
alleen pagina's met dat label te zien.
## Revisiegeschiedenis
Elke publicatie van een pagina bewaart de vorige inhoud als een onveranderlijke
revisie — niets gaat ooit verloren, ook niet wanneer u een pagina hersteld naar een
oudere versie.
1. Open een pagina en klap **Revisiegeschiedenis** open, onderaan de pagina.
2. Klik op een revisie om de inhoud van dat moment te bekijken.
3. Klik op **Deze revisie herstellen** om die inhoud opnieuw te publiceren als de
nieuwste versie. De versie die u zojuist verliet, blijft ook bewaard als revisie —
er wordt nooit geschiedenis overschreven of verwijderd.
## Pagina's die hierheen linken
Klap **Pagina's die hierheen linken** open, onderaan een pagina, om te zien welke
andere pagina's naar deze pagina verwijzen via een `[[interne link]]`. Deze lijst blijft
correct nadat de huidige pagina van titel, locatie in de boom, of URL-pad verandert.
## Prullenbak definitief opruimen (alleen beheerder)
Een pagina die naar de prullenbak is verplaatst, blijft daar minstens 30 dagen bewaard
en is binnen die periode altijd herstelbaar. Na die periode kan een beheerder de
prullenbak definitief opruimen via `POST /api/v1/admin/trash/purge` (de webinterface
hiervoor volgt in een latere milestone; de API is al volledig functioneel en getest).
- Alleen pagina's waarvan de bewaarperiode al verstreken is, worden verwijderd.
- Een pagina die nog binnen de bewaarperiode valt, blijft ongemoeid — er is geen manier
om die termijn via deze aanroep te omzeilen.
- Dit is de enige plaats in de toepassing waar inhoud definitief en onomkeerbaar
verdwijnt; de gewone webinterface heeft hiervoor geen knop.
+15
View File
@@ -0,0 +1,15 @@
# Synthetic LeafWiki fixtures
M4 adds versioned, fully synthetic LeafWiki SQLite/hybrid fixtures after the actual source
shape is understood from official source code and/or a read-only user snapshot.
Rules:
- no real pages, usernames, paths or attachments;
- fixtures are deterministic;
- every known adapter has positive and signature-mismatch fixtures;
- source trees are hashed before and after tests;
- malformed/unknown schemas are represented deliberately;
- no test can write journal, cache or temp files inside a source fixture.
The generic Markdown fixture already exists under `fixtures/markdown-import/`.
@@ -0,0 +1,10 @@
---
title: Voorbeeldproject
status: active
---
# Voorbeeldproject
Dit is uitsluitend synthetische migratie-inhoud.
![Architectuur](../assets/architecture.svg)
@@ -0,0 +1,5 @@
# Linux-bestandsrechten
Bestandsrechten worden weergegeven voor eigenaar, groep en anderen.
Terug naar [[Shell-operators]].
@@ -0,0 +1,12 @@
---
title: Shell-operators
tags:
- Linux
- School
---
# Shell-operators
Gebruik `&&` wanneer het tweede commando alleen bij succes mag starten.
Zie ook [[Bestandsrechten|Linux-bestandsrechten]].
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="120" viewBox="0 0 320 120">
<rect width="320" height="120" rx="16" fill="#eef4f5"/>
<rect x="24" y="34" width="112" height="52" rx="10" fill="#2388c5"/>
<rect x="184" y="34" width="112" height="52" rx="10" fill="#2f8b57"/>
<path d="M136 60h48" stroke="#172126" stroke-width="4"/>
<text x="80" y="65" text-anchor="middle" font-family="sans-serif" font-size="14" fill="white">Wiki</text>
<text x="240" y="65" text-anchor="middle" font-family="sans-serif" font-size="14" fill="white">Project</text>
</svg>

After

Width:  |  Height:  |  Size: 575 B

@@ -0,0 +1,5 @@
{
"fixture": "synthetic-markdown-import",
"containsRealUserData": false,
"purpose": "LeafWiki migration discovery and import tests"
}
+7
View File
@@ -0,0 +1,7 @@
{
"sdk": {
"version": "10.0.302",
"rollForward": "latestFeature",
"allowPrerelease": false
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "itworx-knowledge-hub",
"private": true,
"packageManager": "pnpm@11.14.0",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"doctor": "node scripts/doctor.mjs",
"status": "node scripts/project-status.mjs",
"validate:bundle": "node scripts/validate-bundle.mjs",
"validate:openapi": "node scripts/validate-openapi.mjs",
"verify": "node scripts/verify.mjs",
"verify:fast": "node scripts/verify.mjs --fast",
"test": "pnpm --filter @itworx/knowledge-hub-web test",
"lint": "pnpm --filter @itworx/knowledge-hub-web lint",
"typecheck": "pnpm --filter @itworx/knowledge-hub-web typecheck",
"build": "pnpm --filter @itworx/knowledge-hub-web build",
"dev:web": "pnpm --filter @itworx/knowledge-hub-web dev",
"test:web": "pnpm --filter @itworx/knowledge-hub-web test",
"test:knowledge-ingestion": "node --test tools/knowledge-ingestion/tests/*.test.mjs",
"test:e2e": "pnpm --filter @itworx/knowledge-hub-e2e test"
},
"devDependencies": {
"js-yaml": "^4.1.0"
}
}
+3903
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
packages:
- "src/frontend"
- "tests/e2e"
minimumReleaseAgeExclude:
- '@playwright/test@1.62.1'
- playwright-core@1.62.1
- playwright@1.62.1
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env node
// Contract-coverage gate (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 1):
// fails when a route actually registered by an ASP.NET Minimal API endpoint file under
// src/backend/ItWorx.KnowledgeHub.Api/Endpoints does not appear in
// docs/api/openapi.yaml. This is what makes "the API must be reliably usable by a
// future separate MCP server" (CLAUDE.md) an enforced property, not an aspiration —
// a route can no longer silently ship undocumented.
//
// Deliberately a source-scan, not a live-server introspection: it runs with no
// database, no built application and no network, so it is cheap enough to run on every
// `node scripts/verify.mjs` (fast and full), not only the release gate.
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import yaml from "js-yaml";
const endpointsDir = "src/backend/ItWorx.KnowledgeHub.Api/Endpoints";
const openApiPath = "docs/api/openapi.yaml";
// Routes intentionally excluded from OpenAPI coverage: not `/api/v1/*` REST endpoints
// at all (health probes, the raw attachment byte stream already documented once under
// its real path, etc.) — see docs/09-api-contract.md "Base path: /api/v1".
const EXCLUDED_ROUTE_PREFIXES = ["/health"];
function listEndpointFiles(dir) {
return fs
.readdirSync(dir)
.filter((file) => file.endsWith(".cs"))
.map((file) => path.join(dir, file));
}
function extractRoutesFromFile(filePath) {
const source = fs.readFileSync(filePath, "utf8");
const routes = [];
// MapGroup("/api/v1/xyz") establishes a prefix for every Map* call chained off the
// returned variable within the same file — every endpoint file in this codebase uses
// exactly this one-group-per-file pattern (verified by inspection), so a single
// "most recent MapGroup literal in this file" tracking variable is sufficient and
// avoids needing a real C# parser.
const groupMatch = source.match(/MapGroup\(\s*"([^"]+)"\s*\)/);
const groupPrefix = groupMatch ? groupMatch[1] : "";
const mapCallPattern = /\.Map(Get|Post|Put|Delete|Patch)\(\s*"([^"]*)"/g;
let match;
while ((match = mapCallPattern.exec(source)) !== null) {
const method = match[1].toUpperCase();
const routeLiteral = match[2];
const fullPath = groupPrefix + routeLiteral;
routes.push({ method, path: normalizeRoute(fullPath), file: filePath });
}
return routes;
}
// Normalizes an ASP.NET route template's typed route constraints
// (`{attachmentId:guid}`) to OpenAPI's untyped `{attachmentId}` style, and strips the
// leading `/api/v1` prefix to match how docs/api/openapi.yaml keys its paths (relative
// to the documented `servers[0].url`).
function normalizeRoute(route) {
let normalized = route.replace(/\{([a-zA-Z0-9_]+)(:[a-zA-Z]+)?\}/g, "{$1}");
if (normalized.startsWith("/api/v1")) {
normalized = normalized.slice("/api/v1".length);
}
if (normalized === "") {
normalized = "/";
}
return normalized;
}
function isExcluded(routePath) {
return EXCLUDED_ROUTE_PREFIXES.some((prefix) => routePath.startsWith(prefix));
}
const registeredRoutes = listEndpointFiles(endpointsDir).flatMap(extractRoutesFromFile).filter((route) => !isExcluded(route.path));
const document = yaml.load(fs.readFileSync(openApiPath, "utf8"));
const documentedPaths = document?.paths ?? {};
const missing = [];
for (const route of registeredRoutes) {
const pathEntry = documentedPaths[route.path];
const documentedMethods = pathEntry ? Object.keys(pathEntry).map((key) => key.toUpperCase()) : [];
if (!documentedMethods.includes(route.method)) {
missing.push(route);
}
}
if (missing.length > 0) {
console.error(`OpenAPI coverage gate failed: ${missing.length} registered route(s) not documented in ${openApiPath}:`);
for (const route of missing.sort((a, b) => a.path.localeCompare(b.path))) {
console.error(`- ${route.method} /api/v1${route.path === "/" ? "" : route.path} (${route.file})`);
}
process.exit(1);
}
console.log(`OpenAPI coverage gate passed: all ${registeredRoutes.length} registered /api/v1 routes are documented.`);
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { spawnSync } from "node:child_process";
import { capture, commandExists, run } from "./lib/exec.mjs";
const root = process.cwd();
const requiredRootFiles = [
"CLAUDE.md",
"product/milestones.json",
"ItWorx.KnowledgeHub.slnx",
"src/frontend/package.json"
];
const missingRoot = requiredRootFiles.filter((entry) => !fs.existsSync(path.join(root, entry)));
if (missingRoot.length > 0) {
console.error("Run this command from the repository root. Missing:", missingRoot.join(", "));
process.exit(1);
}
const checks = [];
async function check(name, command, args, predicate, required = true) {
if (!commandExists(command)) {
checks.push({ name, value: "missing", ok: false, required });
return;
}
try {
const value = await capture(command, args);
checks.push({ name, value: value.split(/\r?\n/)[0], ok: predicate(value), required });
} catch (error) {
checks.push({ name, value: `error: ${error.message}`, ok: false, required });
}
}
await check("Git", "git", ["--version"], (value) => /git version/i.test(value));
await check("Node.js", "node", ["--version"], (value) => {
const major = Number(value.match(/v?(\d+)/)?.[1] ?? 0);
return major >= 24;
});
await check("Corepack", "corepack", ["--version"], (value) => value.length > 0);
await check("pnpm", "corepack", ["pnpm", "--version"], (value) => {
const major = Number(value.match(/(\d+)/)?.[1] ?? 0);
return major >= 11;
});
await check(".NET SDK", "dotnet", ["--version"], (value) => value.trim().startsWith("10."));
function findWorkingPython() {
for (const candidate of ["python3", "python"]) {
if (!commandExists(candidate)) {
continue;
}
const probe = spawnSync(candidate, ["--version"], { stdio: "ignore", shell: false });
if (probe.status === 0) {
return candidate;
}
}
return null;
}
const python = findWorkingPython();
if (python) {
await check("Python", python, ["--version"], (value) => {
const match = value.match(/Python\s+(\d+)\.(\d+)/i);
return Boolean(match && Number(match[1]) >= 3 && Number(match[2]) >= 11);
});
} else {
checks.push({ name: "Python", value: "missing or non-working", ok: false, required: true });
}
await check("Docker", "docker", ["--version"], (value) => /Docker version/i.test(value));
if (commandExists("docker")) {
try {
const compose = await capture("docker", ["compose", "version"]);
checks.push({ name: "Docker Compose", value: compose, ok: /Compose version\s+v?\d+\./i.test(compose), required: true });
} catch (error) {
checks.push({ name: "Docker Compose", value: `error: ${error.message}`, ok: false, required: true });
}
}
const width = Math.max(...checks.map((entry) => entry.name.length), 10);
for (const entry of checks) {
const marker = entry.ok ? "OK " : entry.required ? "ERR" : "WARN";
console.log(`${marker} ${entry.name.padEnd(width)} ${entry.value}`);
}
const failures = checks.filter((entry) => entry.required && !entry.ok);
if (failures.length > 0) {
console.error("\nRequired toolchain checks failed.");
process.exit(1);
}
if (!fs.existsSync(path.join(root, "pnpm-lock.yaml"))) {
console.warn("\nWARN pnpm-lock.yaml is not present yet. M0 must generate and commit it.");
}
console.log("\nToolchain baseline is available.");
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
// Canonical, machine-readable release identity (docs/adr/ADR-029-v2-r5-final-hardening.md,
// brief Phase 9). Computes everything genuinely knowable at *build* time — source
// commit, OpenAPI contract hash, frontend build hash, semantic version, build
// timestamp — and writes release-manifest.json, which the Dockerfile copies into the
// image and the running application reads at startup to enrich GET /api/v1/system/info.
//
// Deliberately leaves deployment-time-only fields (image tag, image ID, immutable
// digest, deployment timestamp, database migration range as actually applied,
// inventory snapshot ID, backup ID) as null here — those are not knowable until the
// image has actually been built and deployed (the registry/daemon assigns a digest
// only after push; a migration range depends on which database the running instance
// is actually talking to). Per the brief: "Tag de nieuwe release pas nadat de
// daadwerkelijke productie-image ... is gebouwd en succesvol gedeployed" — this script
// never invents those values, and a real deployment record (docs/deployments/*.md)
// is the source of truth for them, cross-referenced from here only after the fact.
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { spawnSync } from "node:child_process";
const repoRoot = process.cwd();
const outputPath = process.argv[2] ?? path.join(repoRoot, "release-manifest.json");
function sha256File(relativePath) {
const fullPath = path.join(repoRoot, relativePath);
if (!fs.existsSync(fullPath)) {
return null;
}
const hash = createHash("sha256");
hash.update(fs.readFileSync(fullPath));
return `sha256:${hash.digest("hex")}`;
}
// The built frontend bundle's content-hashed filenames already make every asset's own
// hash visible in its filename (Vite's default), but there is no single "the bundle"
// file — hash the whole dist/ directory's file list + each file's own bytes instead,
// so any output change (including a filename/hash change) changes this value too.
function sha256Directory(relativeDir) {
const fullDir = path.join(repoRoot, relativeDir);
if (!fs.existsSync(fullDir)) {
return null;
}
const files = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(entryPath);
} else {
files.push(entryPath);
}
}
};
walk(fullDir);
files.sort();
const hash = createHash("sha256");
for (const file of files) {
hash.update(path.relative(fullDir, file).replace(/\\/g, "/"));
hash.update(fs.readFileSync(file));
}
return `sha256:${hash.digest("hex")}`;
}
function gitCommit() {
if (process.env.BUILD_COMMIT && process.env.BUILD_COMMIT !== "development") {
return process.env.BUILD_COMMIT;
}
const result = spawnSync("git", ["rev-parse", "--short=12", "HEAD"], {
cwd: repoRoot,
encoding: "utf8"
});
return result.status === 0 ? result.stdout.trim() : "unknown";
}
function readOpenApiVersion() {
try {
const contents = fs.readFileSync(path.join(repoRoot, "docs/api/openapi.yaml"), "utf8");
const match = contents.match(/^\s*version:\s*(.+)$/m);
return match ? match[1].trim() : null;
} catch {
return null;
}
}
function readSemanticVersion() {
const versionFile = path.join(repoRoot, "VERSION");
if (fs.existsSync(versionFile)) {
return fs.readFileSync(versionFile, "utf8").trim();
}
// Falls back to the most recent annotated/lightweight tag reachable from HEAD, the
// same source `git describe` uses — never invents a version number.
const described = spawnSync("git", ["describe", "--tags", "--always"], {
cwd: repoRoot,
encoding: "utf8"
});
return described.status === 0 ? described.stdout.trim() : null;
}
// An empty string (as opposed to an unset variable — Docker build args with no
// override are still passed as "") means "not actually known" just as much as
// unset does; never record it as a real, empty-but-present value.
const nonEmpty = (value) => (value && value.trim().length > 0 ? value.trim() : null);
const manifest = {
schemaVersion: 1,
semanticVersion: readSemanticVersion(),
releaseTag: nonEmpty(process.env.RELEASE_TAG),
sourceCommit: gitCommit(),
imageRepository: process.env.IMAGE_REPOSITORY ?? "itworx/knowledge-hub",
imageTag: nonEmpty(process.env.IMAGE_TAG),
imageId: null,
imageDigest: null,
buildTimestamp: new Date().toISOString(),
deploymentTimestamp: null,
environment: process.env.BUILD_ENVIRONMENT ?? "production",
openApiVersion: readOpenApiVersion(),
openApiHash: sha256File("docs/api/openapi.yaml"),
frontendBuildHash: sha256Directory("src/frontend/dist"),
databaseMigrationRange: null,
inventorySnapshotId: null,
backupId: null
};
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
console.log(`Wrote release manifest to ${outputPath}:`);
console.log(JSON.stringify(manifest, null, 2));
+106
View File
@@ -0,0 +1,106 @@
import { spawn, spawnSync } from "node:child_process";
import process from "node:process";
export function commandExists(command) {
const locator = process.platform === "win32" ? "where" : "which";
const result = spawnSync(locator, [command], {
stdio: "ignore",
shell: false
});
return result.status === 0;
}
// On Windows, some tools (corepack, pnpm, npm, ...) are installed as `.cmd`/`.bat`
// shims. Those are not real PE executables, so libuv/CreateProcess cannot launch
// them directly: `child_process.spawn` fails with ENOENT even though the same
// command works in an interactive shell. Real executables (dotnet.exe, docker.exe,
// node.exe, python.exe) do not have this problem and must NOT be routed through a
// shell, because shell-quoting a path that contains spaces (e.g. "C:\Program
// Files\...") breaks it. Detect which case applies per command instead of
// guessing globally.
function requiresWindowsShell(command) {
if (process.platform !== "win32") {
return false;
}
if (/[\\/]/.test(command)) {
// Already a path; trust its extension.
return /\.(cmd|bat)$/i.test(command);
}
const located = spawnSync("where", [command], {
encoding: "utf8",
shell: false
});
if (located.status !== 0) {
return false;
}
// `where` can list several matches on Windows (e.g. an extension-less shebang
// launcher script alongside a `.cmd` shim for the same tool). Node can only
// ever run the `.cmd`/`.bat` variant through a shell, so treat the command as
// shell-requiring if *any* match needs one, not just the first line.
const matches = located.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return matches.some((match) => /\.(cmd|bat)$/i.test(match));
}
function quoteForWindowsShell(value) {
if (value === "") {
return '""';
}
if (!/[\s"^&|<>()%]/.test(value)) {
return value;
}
return `"${value.replace(/"/g, '\\"')}"`;
}
export async function run(command, args = [], options = {}) {
const printable = [command, ...args].join(" ");
if (!options.quiet) {
process.stdout.write(`\n> ${printable}\n`);
}
const useShell = requiresWindowsShell(command);
const spawnCommand = useShell
? [command, ...args].map(quoteForWindowsShell).join(" ")
: command;
const spawnArgs = useShell ? [] : args;
return await new Promise((resolve, reject) => {
const child = spawn(spawnCommand, spawnArgs, {
cwd: options.cwd ?? process.cwd(),
env: { ...process.env, ...options.env },
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
shell: useShell
});
let stdout = "";
let stderr = "";
if (options.capture) {
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
}
child.on("error", reject);
child.on("close", (code, signal) => {
if (code === 0) {
resolve({ code, stdout, stderr });
return;
}
const suffix = signal ? ` (signal ${signal})` : "";
const error = new Error(`Command failed (${code}${suffix}): ${printable}`);
error.code = code;
error.stdout = stdout;
error.stderr = stderr;
reject(error);
});
});
}
export async function capture(command, args = [], options = {}) {
const result = await run(command, args, { ...options, capture: true, quiet: true });
return result.stdout.trim();
}
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env node
// Lightweight, dependency-free OpenAPI consistency check: valid YAML, every internal
// $ref resolves, and every path/schema referenced by a $ref actually exists. This is
// not a full OpenAPI 3.1 schema validator, but it catches the class of error that
// actually breaks generated clients and docs sites: dangling references and typos.
import fs from "node:fs";
import process from "node:process";
import yaml from "js-yaml";
const path = "docs/api/openapi.yaml";
const document = yaml.load(fs.readFileSync(path, "utf8"));
const errors = [];
function resolveRef(ref) {
if (!ref.startsWith("#/")) {
errors.push(`External or malformed $ref not supported: ${ref}`);
return;
}
const segments = ref
.slice(2)
.split("/")
.map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
let cursor = document;
for (const segment of segments) {
if (cursor === undefined || cursor === null || !(segment in cursor)) {
errors.push(`Dangling $ref: ${ref}`);
return;
}
cursor = cursor[segment];
}
}
function walk(node) {
if (Array.isArray(node)) {
for (const item of node) {
walk(item);
}
return;
}
if (node && typeof node === "object") {
for (const [key, value] of Object.entries(node)) {
if (key === "$ref" && typeof value === "string") {
resolveRef(value);
} else {
walk(value);
}
}
}
}
if (!document || typeof document !== "object") {
errors.push("Document did not parse to an object.");
} else {
if (document.openapi === undefined) {
errors.push("Missing top-level 'openapi' version field.");
}
if (!document.paths || Object.keys(document.paths).length === 0) {
errors.push("No paths defined.");
}
walk(document);
}
if (errors.length > 0) {
console.error("OpenAPI validation failed:");
for (const error of errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
const pathCount = Object.keys(document.paths ?? {}).length;
const schemaCount = Object.keys(document.components?.schemas ?? {}).length;
console.log(`OpenAPI document is well-formed: ${pathCount} paths, ${schemaCount} schemas, all $ref pointers resolve.`);
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const required = [
"README.md", "SECURITY.md", "CONTRIBUTING.md", "LICENSE", "PUBLIC_SOURCE_EXPORT.md",
"PUBLIC_SOURCE_MANIFEST.json", "MANIFEST.json", "ItWorx.KnowledgeHub.slnx", "docker-compose.yml",
"deploy/docker/Dockerfile", "docs/api/openapi.yaml", "src/frontend/package.json"
];
const missing = required.filter((entry) => !fs.existsSync(path.join(process.cwd(), entry)));
if (missing.length > 0) {
console.error(`Missing public source files: ${missing.join(", ")}`);
process.exit(1);
}
for (const privatePath of [".claude", ".agents", ".codex", "artifacts", "docs/deployments", "docs/v2", "n8n"]) {
if (fs.existsSync(path.join(process.cwd(), privatePath))) {
console.error(`Private-only path present in public export: ${privatePath}`);
process.exit(1);
}
}
const manifest = JSON.parse(fs.readFileSync("PUBLIC_SOURCE_MANIFEST.json", "utf8"));
if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
console.error("PUBLIC_SOURCE_MANIFEST.json contains no files.");
process.exit(1);
}
console.log(`Public source structure passed (${manifest.files.length} allowlisted files).`);
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env node
import fs from "node:fs";
import process from "node:process";
import { spawnSync } from "node:child_process";
import { commandExists, run } from "./lib/exec.mjs";
const flags = new Set(process.argv.slice(2));
const structureOnly = flags.has("--structure-only");
const fast = flags.has("--fast");
const ci = flags.has("--ci");
const release = flags.has("--release");
const publicSourceExport = fs.existsSync("PUBLIC_SOURCE_EXPORT.md");
function findWorkingPython() {
// On some Windows setups `python3` resolves via PATH to the Microsoft Store
// app-execution-alias stub (present even when no Store install exists),
// which `where`/commandExists reports as found but which fails at runtime.
// Probe candidates by actually invoking them rather than trusting PATH
// resolution alone.
const candidates = ["python3", "python"];
for (const candidate of candidates) {
if (!commandExists(candidate)) {
continue;
}
const probe = spawnSync(candidate, ["--version"], { stdio: "ignore", shell: false });
if (probe.status === 0) {
return candidate;
}
}
throw new Error("No working Python 3 interpreter found (tried python3, python).");
}
const python = findWorkingPython();
async function section(name, action) {
console.log(`\n=== ${name} ===`);
await action();
}
try {
await section("Bundle structure", async () => {
if (publicSourceExport) {
await run(process.execPath, ["scripts/validate-public-source.mjs"]);
} else {
await run(process.execPath, ["scripts/validate-bundle.mjs"]);
await run(process.execPath, ["scripts/test-safety-hook.mjs"]);
}
});
await section("LeafWiki inspector tests", async () => {
await run(python, [
"-m",
"unittest",
"discover",
"-s",
"tools/leafwiki-inspector/tests",
"-p",
"test_*.py"
]);
});
await section("Knowledge-ingestion policy tests", async () => {
if (!publicSourceExport) {
await run(process.execPath, ["--test", "tools/knowledge-ingestion/tests/*.test.mjs"]);
}
});
if (structureOnly) {
console.log("\nStructure-only verification passed.");
process.exit(0);
}
await section(".NET restore, format, build and tests", async () => {
await run("dotnet", ["restore", "ItWorx.KnowledgeHub.slnx"]);
await run("dotnet", [
"format",
"ItWorx.KnowledgeHub.slnx",
"--verify-no-changes",
"--no-restore"
]);
await run("dotnet", [
"build",
"ItWorx.KnowledgeHub.slnx",
"--configuration",
"Release",
"--no-restore"
]);
await run("dotnet", [
"test",
"ItWorx.KnowledgeHub.slnx",
"--configuration",
"Release",
"--no-build",
// Excludes the M8 full-scale search/tree suite (100,000 pages / 1,000,000
// revisions, docs/10-search-and-indexing.md "Scale verification") — a
// deliberate, occasional release-gate exercise (run explicitly in the
// "release" section below), not a per-commit check; it alone would add well
// over a minute to every routine verification run.
"--filter",
"Category!=ReleaseScale",
// No explicit LogFileName: `dotnet test` runs every project in the solution
// through the same invocation, so a fixed name would have each project's
// results overwrite the previous one. Omitting it lets each project write its
// own default `<assembly>_<timestamp>.trx` file instead.
"--logger",
"trx",
"--results-directory",
"artifacts/test-results"
]);
});
await section("Frontend install and verification", async () => {
const installArgs = fs.existsSync("pnpm-lock.yaml")
? ["pnpm", "install", "--frozen-lockfile"]
: ["pnpm", "install", "--no-frozen-lockfile"];
if (!fs.existsSync("pnpm-lock.yaml")) {
console.warn("Pre-M0: generating pnpm-lock.yaml. Commit it after reviewing dependency versions.");
}
await run("corepack", installArgs);
await run("corepack", ["pnpm", "--filter", "@itworx/knowledge-hub-web", "lint"]);
await run("corepack", ["pnpm", "--filter", "@itworx/knowledge-hub-web", "typecheck"]);
await run("corepack", ["pnpm", "--filter", "@itworx/knowledge-hub-web", "test"]);
await run("corepack", ["pnpm", "--filter", "@itworx/knowledge-hub-web", "build"]);
// Depends on the root workspace's node_modules (js-yaml), so it must run after
// `pnpm install` above, not in the earlier structure-only "Bundle structure"
// section that runs before any dependency install exists on a clean checkout.
await run(process.execPath, ["scripts/validate-openapi.mjs"]);
// Contract-coverage gate (docs/adr/ADR-029-v2-r5-final-hardening.md): fails when a
// registered /api/v1 endpoint is not documented in docs/api/openapi.yaml. Same
// js-yaml dependency, same placement.
await run(process.execPath, ["scripts/check-openapi-coverage.mjs"]);
});
if (!fast) {
await section("Docker configuration", async () => {
await run("docker", ["compose", "config"], {
env: {
POSTGRES_PASSWORD: "verification-only-not-for-production"
}
});
});
}
if (ci || release) {
await section("Production image build", async () => {
await run("docker", ["compose", "build", "app"], {
env: {
POSTGRES_PASSWORD: "verification-only-not-for-production",
BUILD_COMMIT: process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? "verification"
}
});
});
}
if (release) {
await section("Full-scale search/tree suite (release-gate only)", async () => {
await run("dotnet", [
"test",
"tests/backend/ItWorx.KnowledgeHub.IntegrationTests/ItWorx.KnowledgeHub.IntegrationTests.csproj",
"--configuration",
"Release",
"--no-build",
"--filter",
"Category=ReleaseScale"
]);
});
await section(".NET dependency vulnerability scan", async () => {
const result = await run(
"dotnet",
["list", "ItWorx.KnowledgeHub.slnx", "package", "--vulnerable", "--include-transitive"],
{ capture: true }
);
// `dotnet list package --vulnerable` always exits 0 and just prints findings —
// the gate itself must fail the release on any reported advisory (M8-A02: "No
// known Critical or High security finding remains unresolved" — since the
// command does not separate by severity in its text output, any finding at all
// blocks release pending explicit triage, matching the release gate's
// zero-tolerance-until-reviewed posture).
if (/has the following vulnerable packages/i.test(result.stdout)) {
throw new Error(
"dotnet reported vulnerable NuGet packages — review the output above before releasing.\n" + result.stdout
);
}
});
await section("Frontend dependency vulnerability scan", async () => {
await run("corepack", ["pnpm", "audit", "--prod"]);
});
await section("Secret scan", async () => {
if (!commandExists("gitleaks")) {
throw new Error("gitleaks is required for the release secret scan but was not found on PATH.");
}
await run("gitleaks", ["detect", "--source", ".", "--redact"]);
});
if (commandExists("trivy")) {
await section("Container vulnerability scan", async () => {
await run("trivy", [
"image",
"--severity",
"CRITICAL,HIGH",
"--exit-code",
"1",
"--scanners",
"vuln",
"itworx/knowledge-hub:local"
]);
});
} else {
console.warn(
"\ntrivy not found on PATH — container vulnerability scan skipped. Install " +
"trivy before a real release; this is not a substitute for the scan."
);
}
console.warn(
"\nRelease verification still requires the milestone-defined E2E (desktop + " +
"mobile, axe), backup/restore rehearsal and runtime-inspection gates to be run " +
"manually against the built image, per the documented procedure in EVIDENCE.md " +
"— these involve bringing up docker compose and are recorded there rather than " +
"orchestrated here."
);
}
console.log("\nVerification completed successfully for the selected baseline gates.");
} catch (error) {
console.error(`\nVerification failed: ${error.message}`);
process.exit(typeof error.code === "number" ? error.code : 1);
}
@@ -0,0 +1,10 @@
namespace ItWorx.KnowledgeHub.Api.Authorization;
public static class AuthorizationPolicies
{
public const string RequireReader = "RequireReader";
public const string RequireEditor = "RequireEditor";
public const string RequireAdministrator = "RequireAdministrator";
public const string IngestionWrite = "IngestionWrite";
public const string IngestionReview = "IngestionReview";
}
@@ -0,0 +1,58 @@
using ItWorx.KnowledgeHub.Application.Identity;
using Microsoft.AspNetCore.Authorization;
namespace ItWorx.KnowledgeHub.Api.Authorization;
/// <summary>
/// Two independent success paths, checked in order — a service-token claim never needs
/// a database round trip for the common case, and a cookie-authenticated human is
/// resolved live from the database exactly as <c>WorkspaceRoleAuthorizationHandler</c>
/// already does (never from a stale role claim baked into the cookie).
/// </summary>
public sealed class ContentAccessAuthorizationHandler(
IUserAccountManager userAccounts,
IWorkspaceRepository workspaces,
IWorkspaceMembershipRepository memberships)
: AuthorizationHandler<ContentAccessRequirement>
{
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, ContentAccessRequirement requirement)
{
if (context.User.Identity?.IsAuthenticated != true)
{
return;
}
if (context.User.HasClaim(ServiceTokenAuthenticationHandler.ScopeClaimType, requirement.RequiredScope))
{
context.Succeed(requirement);
return;
}
// A service-token principal that lacks the required scope must not fall
// through to the human/cookie path below — GetSignedInAccountAsync would
// legitimately return null for it anyway, but failing fast here keeps the two
// paths clearly separated.
if (context.User.HasClaim(claim => claim.Type == ServiceTokenAuthenticationHandler.ServiceAccountClaimType))
{
return;
}
var account = await userAccounts.GetSignedInAccountAsync(CancellationToken.None);
if (account is null || !account.Enabled)
{
return;
}
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(CancellationToken.None);
if (workspaceId is null)
{
return;
}
var role = await memberships.GetRoleAsync(workspaceId.Value, account.Id, CancellationToken.None);
if (role is not null && role.Value >= requirement.MinimumRole)
{
context.Succeed(requirement);
}
}
}
@@ -0,0 +1,26 @@
using ItWorx.KnowledgeHub.Domain.Identity;
namespace ItWorx.KnowledgeHub.Api.Authorization;
public static class ContentAccessEndpointExtensions
{
/// <summary>Requires either the given minimum cookie-session workspace role, or a
/// bearer service-token carrying <paramref name="scope"/> — the same endpoint and
/// route serves both the browser SPA and scoped automation
/// (docs/09-api-contract.md, docs/24-mcp-readiness.md).
///
/// Both schemes must be listed explicitly: a policy with an explicit
/// <c>AuthenticationSchemes</c> list re-authenticates using *only* those schemes
/// for that check, rather than reusing whatever <c>app.UseAuthentication()</c>'s
/// default-scheme pass already populated on <c>HttpContext.User</c> — omitting the
/// cookie scheme here silently rejected every cookie-authenticated (browser)
/// caller with a 401, found via a real integration test failure across the whole
/// Automation test suite, not by inspection.</summary>
public static TBuilder RequireContentAccess<TBuilder>(this TBuilder builder, WorkspaceRole minimumRole, string scope)
where TBuilder : IEndpointConventionBuilder =>
builder.RequireAuthorization(policy => policy
.AddAuthenticationSchemes(
Microsoft.AspNetCore.Identity.IdentityConstants.ApplicationScheme,
ServiceTokenAuthenticationDefaults.Scheme)
.Requirements.Add(new ContentAccessRequirement(minimumRole, scope)));
}
@@ -0,0 +1,20 @@
using ItWorx.KnowledgeHub.Domain.Identity;
using Microsoft.AspNetCore.Authorization;
namespace ItWorx.KnowledgeHub.Api.Authorization;
/// <summary>
/// Satisfied by either a cookie-authenticated human whose workspace role is at least
/// <see cref="MinimumRole"/> (the original M1 <c>WorkspaceRoleRequirement</c> check), or
/// a bearer-token-authenticated service account carrying <see cref="RequiredScope"/>.
/// Scopes never bypass this — a service token with the right scope still only reaches
/// exactly the endpoints it is checked against, and every endpoint independently
/// re-verifies (M6-A02: "tested against horizontal and vertical privilege
/// escalation").
/// </summary>
public sealed class ContentAccessRequirement(WorkspaceRole minimumRole, string requiredScope) : IAuthorizationRequirement
{
public WorkspaceRole MinimumRole { get; } = minimumRole;
public string RequiredScope { get; } = requiredScope;
}
@@ -0,0 +1,89 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Automation.UseCases;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace ItWorx.KnowledgeHub.Api.Authorization;
public static class ServiceTokenAuthenticationDefaults
{
public const string Scheme = "ServiceToken";
}
/// <summary>
/// Authenticates <c>Authorization: Bearer ikh_&lt;public-id&gt;_&lt;secret&gt;</c>
/// requests against the database on every call (docs/09-api-contract.md "Service
/// account"). On success, issues a claims principal carrying the service account's ID
/// as the standard name identifier (so existing `CurrentUserId(principal)` endpoint
/// helpers keep working unmodified for audit attribution) plus one claim per granted
/// scope, checked by <see cref="ContentAccessRequirement"/>.
/// </summary>
public sealed class ServiceTokenAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
AuthenticateServiceTokenUseCase authenticate,
ServiceTokenFailureRateLimiter failureRateLimiter)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string ScopeClaimType = "scope";
public const string ServiceAccountClaimType = "service_account_id";
private const string RateLimitedItemKey = "ServiceToken.RateLimited";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("Authorization", out var authorizationHeader))
{
return AuthenticateResult.NoResult();
}
var headerValue = authorizationHeader.ToString();
if (!headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return AuthenticateResult.NoResult();
}
var presentedToken = headerValue["Bearer ".Length..].Trim();
// CancellationToken.None, not Context.RequestAborted: this call also writes
// (MarkUsed + SaveChangesAsync) — letting an aborted-request token cancel a
// write mid-flight risks a half-applied "last used" update, which is
// low-value to interrupt anyway.
var authenticated = await authenticate.ExecuteAsync(presentedToken, CancellationToken.None);
if (authenticated is null)
{
var clientIp = Context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (!failureRateLimiter.TryAcquireOnFailure(clientIp))
{
Context.Items[RateLimitedItemKey] = true;
}
return AuthenticateResult.Fail("Invalid or revoked service-account token.");
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, authenticated.Account.Id.ToString()),
new(ClaimTypes.Name, authenticated.Account.Name),
new(ServiceAccountClaimType, authenticated.Account.Id.ToString())
};
claims.AddRange(authenticated.Token.Scopes.Select(scope => new Claim(ScopeClaimType, scope)));
var identity = new ClaimsIdentity(claims, ServiceTokenAuthenticationDefaults.Scheme);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, ServiceTokenAuthenticationDefaults.Scheme);
return AuthenticateResult.Success(ticket);
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
if (Context.Items.ContainsKey(RateLimitedItemKey))
{
Response.StatusCode = StatusCodes.Status429TooManyRequests;
return Task.CompletedTask;
}
return base.HandleChallengeAsync(properties);
}
}
@@ -0,0 +1,44 @@
using ItWorx.KnowledgeHub.Application.Identity;
using Microsoft.AspNetCore.Authorization;
namespace ItWorx.KnowledgeHub.Api.Authorization;
/// <summary>
/// Resolves the caller's role from the database on every check — not from a role claim
/// baked into the sign-in cookie — so a role change or account disablement takes effect
/// immediately rather than only after the user's next sign-in.
/// </summary>
public sealed class WorkspaceRoleAuthorizationHandler(
IUserAccountManager userAccounts,
IWorkspaceRepository workspaces,
IWorkspaceMembershipRepository memberships)
: AuthorizationHandler<WorkspaceRoleRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
WorkspaceRoleRequirement requirement)
{
if (context.User.Identity?.IsAuthenticated != true)
{
return;
}
var account = await userAccounts.GetSignedInAccountAsync(CancellationToken.None);
if (account is null || !account.Enabled)
{
return;
}
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(CancellationToken.None);
if (workspaceId is null)
{
return;
}
var role = await memberships.GetRoleAsync(workspaceId.Value, account.Id, CancellationToken.None);
if (role is not null && role.Value >= requirement.MinimumRole)
{
context.Succeed(requirement);
}
}
}
@@ -0,0 +1,12 @@
using ItWorx.KnowledgeHub.Domain.Identity;
using Microsoft.AspNetCore.Authorization;
namespace ItWorx.KnowledgeHub.Api.Authorization;
/// <summary>Satisfied when the signed-in user's role in the default workspace is at
/// least <see cref="MinimumRole"/> (docs/12-auth-security.md — enforced server-side in
/// Application/API, never only by hiding UI).</summary>
public sealed class WorkspaceRoleRequirement(WorkspaceRole minimumRole) : IAuthorizationRequirement
{
public WorkspaceRole MinimumRole { get; } = minimumRole;
}
@@ -0,0 +1,109 @@
using ItWorx.KnowledgeHub.Application.Knowledge;
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
namespace ItWorx.KnowledgeHub.Api.BackgroundServices;
/// <summary>
/// Periodically reclaims expired, abandoned page-creation staging sessions
/// (docs/adr/ADR-029-v2-r5-final-hardening.md). A thin host-lifetime loop only — every
/// actual safety property (bounded batch, atomic per-session claim, lock-serialized
/// blob garbage collection, per-session error isolation) lives in
/// <see cref="CleanupExpiredPageCreationSessionsUseCase"/>, shared with the admin
/// "run now" action, so the two paths can never diverge in behavior.
/// </summary>
public sealed partial class StagingSessionCleanupBackgroundService(
IServiceScopeFactory scopeFactory,
StagingSessionCleanupOptions options,
StagingCleanupStatusTracker statusTracker,
TimeProvider timeProvider,
ILogger<StagingSessionCleanupBackgroundService> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!options.Enabled)
{
LogDisabled();
return;
}
// A short initial delay lets the host finish starting (migrations, health
// checks) before the first pass, rather than racing application startup.
try
{
await Task.Delay(TimeSpan.FromSeconds(10), timeProvider, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
while (!stoppingToken.IsCancellationRequested)
{
await RunOnceAsync("scheduled", stoppingToken);
try
{
await Task.Delay(options.Interval, timeProvider, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
}
}
private async Task RunOnceAsync(string trigger, CancellationToken cancellationToken)
{
var startedAt = timeProvider.GetUtcNow();
statusTracker.RecordStart(startedAt, trigger);
try
{
using var scope = scopeFactory.CreateScope();
var useCase = scope.ServiceProvider.GetRequiredService<CleanupExpiredPageCreationSessionsUseCase>();
var result = await useCase.ExecuteAsync(options.BatchSize, cancellationToken);
statusTracker.RecordCompletion(timeProvider.GetUtcNow(), result);
if (result.SessionsClaimed > 0 || result.Errors.Count > 0)
{
LogRunSummary(result.SessionsClaimed, result.AttachmentReferencesRemoved, result.BlobsRemoved, result.Errors.Count);
foreach (var error in result.Errors)
{
LogSessionError(error);
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
statusTracker.RecordCompletion(timeProvider.GetUtcNow(), StagingCleanupRunResult.Empty);
}
catch (Exception ex)
{
// A whole-run failure (e.g. the database is briefly unreachable) never
// crashes the host — it is logged and the next scheduled tick simply tries
// again; every session-level error is already caught and reported inside
// the use case itself.
statusTracker.RecordCompletion(
timeProvider.GetUtcNow(),
new StagingCleanupRunResult(0, 0, 0, 0, 0, [$"Volledige cleanup-run mislukt: {ex.Message}"]));
LogRunFailed(ex);
}
}
[LoggerMessage(Level = LogLevel.Information, Message = "Staging-cleanup-achtergrondtaak is uitgeschakeld via configuratie.")]
private partial void LogDisabled();
[LoggerMessage(
Level = LogLevel.Information,
Message = "Staging-cleanup: {Claimed} sessie(s) opgeruimd, {References} bijlage(n), {Blobs} blob(s), {Errors} fout(en).")]
private partial void LogRunSummary(int claimed, int references, int blobs, int errors);
[LoggerMessage(Level = LogLevel.Warning, Message = "Staging-cleanup fout: {Error}")]
private partial void LogSessionError(string error);
[LoggerMessage(Level = LogLevel.Error, Message = "Staging-cleanup-run mislukt.")]
private partial void LogRunFailed(Exception exception);
}
@@ -0,0 +1,30 @@
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Application.Audit;
using ItWorx.KnowledgeHub.Contracts.Activity;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Domain.Identity;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
public static class ActivityEndpoints
{
public static IEndpointRouteBuilder MapActivityEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/activity", ListAsync)
.WithTags("Activity")
.RequireContentAccess(WorkspaceRole.Reader, Scopes.AuditRead);
return app;
}
private static async Task<IResult> ListAsync(
IAuditEventReader reader,
[FromQuery] int? limit,
CancellationToken cancellationToken)
{
var items = await reader.ListRecentAsync(limit ?? 100, cancellationToken);
return Results.Ok(new ActivityListResponse(items.Select(item => new ActivityItemResponse(
item.Id, item.ActorKind, item.ActorId, item.Source, item.Action, item.TargetType,
item.TargetId, item.Summary, item.OccurredAt)).ToArray()));
}
}
@@ -0,0 +1,233 @@
using System.Security.Claims;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Application.Identity.UseCases;
using ItWorx.KnowledgeHub.Application.Knowledge;
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
public static class AdminEndpoints
{
public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/admin")
.WithTags("Admin")
.RequireAuthorization(AuthorizationPolicies.RequireAdministrator);
group.MapGet("/users", ListUsersAsync);
group.MapPost("/users", CreateUserAsync)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPatch("/users/{userId:guid}", UpdateUserAsync)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/users/{userId:guid}/recovery-tokens", IssueRecoveryTokenAsync)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/trash/purge", PurgeTrashAsync)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapGet("/staging-cleanup/status", GetStagingCleanupStatusAsync);
group.MapPost("/staging-cleanup/run", RunStagingCleanupAsync)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
return app;
}
private static async Task<Ok<StagingCleanupStatusResponse>> GetStagingCleanupStatusAsync(
IPageCreationSessionRepository sessions,
StagingCleanupStatusTracker statusTracker,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var activeCount = await sessions.CountOpenSessionsAsync(cancellationToken);
var expiredCount = await sessions.CountExpiredOpenSessionsAsync(now, cancellationToken);
var snapshot = statusTracker.GetSnapshot();
return TypedResults.Ok(new StagingCleanupStatusResponse(
activeCount, expiredCount, snapshot.RunInProgress, snapshot.LastRunStartedAt, snapshot.LastRunCompletedAt,
snapshot.LastRunTrigger, ToSummary(snapshot.LastResult)));
}
private static async Task<Results<Ok<RunStagingCleanupResponse>, ProblemHttpResult>> RunStagingCleanupAsync(
[FromBody] RunStagingCleanupRequest request,
CleanupExpiredPageCreationSessionsUseCase useCase,
StagingSessionCleanupOptions options,
StagingCleanupStatusTracker statusTracker,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (request.DryRun)
{
var preview = await useCase.PreviewAsync(options.BatchSize, cancellationToken);
return TypedResults.Ok(new RunStagingCleanupResponse(true, ToSummary(preview)!));
}
statusTracker.RecordStart(timeProvider.GetUtcNow(), "manual");
var result = await useCase.ExecuteAsync(options.BatchSize, cancellationToken);
statusTracker.RecordCompletion(timeProvider.GetUtcNow(), result);
return TypedResults.Ok(new RunStagingCleanupResponse(false, ToSummary(result)!));
}
private static StagingCleanupRunSummary? ToSummary(StagingCleanupRunResult? result) => result is null
? null
: new StagingCleanupRunSummary(
result.SessionsExpired, result.SessionsClaimed, result.SessionsLostRace,
result.AttachmentReferencesRemoved, result.BlobsRemoved, result.Errors);
private static async Task<Results<Ok<TrashPurgeResponse>, ProblemHttpResult>> PurgeTrashAsync(
IWorkspaceRepository workspaces,
PurgeTrashUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new PurgeTrashCommand(workspaceId.Value, CurrentUserId(principal), correlationId), cancellationToken);
return TypedResults.Ok(new TrashPurgeResponse(result.PurgedCount));
}
private static async Task<Ok<IReadOnlyList<UserSummaryResponse>>> ListUsersAsync(
ListWorkspaceUsersUseCase useCase,
CancellationToken cancellationToken)
{
var users = await useCase.ExecuteAsync(cancellationToken);
IReadOnlyList<UserSummaryResponse> response = users
.Select(user => new UserSummaryResponse(
user.Account.Id,
user.Account.DisplayName,
user.Account.Email,
user.Role.ToString(),
user.Account.Enabled,
user.Account.CreatedAt,
user.Account.LastLoginAt))
.ToList();
return TypedResults.Ok(response);
}
private static async Task<Results<Ok<UserSummaryResponse>, ProblemHttpResult>> CreateUserAsync(
[FromBody] CreateUserRequest request,
CreateWorkspaceUserUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var actorId = CurrentUserId(principal);
var result = await useCase.ExecuteAsync(
request.DisplayName,
request.Email,
request.Password,
request.Role,
actorId,
correlationId,
cancellationToken);
if (result.Status == CreateWorkspaceUserStatus.Success)
{
return TypedResults.Ok(new UserSummaryResponse(
result.Account!.Id,
result.Account.DisplayName,
result.Account.Email,
result.Role!.Value.ToString(),
result.Account.Enabled,
result.Account.CreatedAt,
result.Account.LastLoginAt));
}
return TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
type: ProblemTypes.Validation,
title: "Kan gebruiker niet aanmaken.",
detail: string.Join(" ", result.Errors));
}
private static async Task<Results<Ok, ProblemHttpResult>> UpdateUserAsync(
Guid userId,
[FromBody] UpdateUserRequest request,
UpdateWorkspaceUserUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var actorId = CurrentUserId(principal);
var result = await useCase.ExecuteAsync(
userId,
request.Role,
request.Enabled,
actorId,
correlationId,
cancellationToken);
return result.Status switch
{
UpdateWorkspaceUserStatus.Success => TypedResults.Ok(),
UpdateWorkspaceUserStatus.UserNotFound => TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound,
type: ProblemTypes.NotFound,
title: "Gebruiker niet gevonden."),
UpdateWorkspaceUserStatus.WouldRemoveLastAdministrator => TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict,
type: ProblemTypes.LastAdministrator,
title: "Kan laatste beheerder niet wijzigen.",
detail: "Er moet minstens één ingeschakelde beheerder overblijven."),
_ => TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
type: ProblemTypes.Validation,
title: "Ongeldige aanvraag.",
detail: string.Join(" ", result.Errors))
};
}
private static async Task<Results<Ok<IssueRecoveryTokenResponse>, ProblemHttpResult>> IssueRecoveryTokenAsync(
Guid userId,
IssueRecoveryTokenUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var actorId = CurrentUserId(principal);
var result = await useCase.ExecuteAsync(userId, actorId, correlationId, cancellationToken);
if (result.Status == IssueRecoveryTokenStatus.UserNotFound)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound,
type: ProblemTypes.NotFound,
title: "Gebruiker niet gevonden.");
}
return TypedResults.Ok(new IssueRecoveryTokenResponse(result.RawToken!, result.ExpiresAt!.Value));
}
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
}
@@ -0,0 +1,226 @@
using System.Net.Http.Headers;
using System.Security.Claims;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Attachments.UseCases;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Domain.Attachments;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Domain.Identity;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
public static class AttachmentsEndpoints
{
public static IEndpointRouteBuilder MapAttachmentsEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/attachments/{attachmentId:guid}/content", GetContentAsync)
.WithTags("Attachments")
.RequireContentAccess(WorkspaceRole.Reader, Scopes.AttachmentsRead);
app.MapGet("/api/v1/pages/{pageId:guid}/attachments", ListForPageAsync)
.WithTags("Attachments")
.RequireContentAccess(WorkspaceRole.Reader, Scopes.AttachmentsRead);
app.MapPost("/api/v1/pages/{pageId:guid}/attachments", UploadAsync)
.WithTags("Attachments")
.RequireContentAccess(WorkspaceRole.Editor, Scopes.AttachmentsWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.DisableAntiforgery(); // The double-submit CSRF check is our own
// AntiforgeryEndpointFilter (header-based, same as
// every other mutation) — ASP.NET Core's built-in
// form antiforgery targets a different mechanism
// and is not used anywhere in this codebase.
// Staged upload (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md): the
// same real, immediately-addressable attachment as the page-scoped upload
// above, just owned by an open page-creation session instead of an existing
// page until that session's commit re-points ownership atomically.
app.MapPost("/api/v1/pages/staged/{sessionId:guid}/attachments", UploadStagedAsync)
.WithTags("Attachments")
.RequireContentAccess(WorkspaceRole.Editor, Scopes.AttachmentsWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.DisableAntiforgery();
return app;
}
private static async Task<Results<EmptyHttpResult, ProblemHttpResult>> GetContentAsync(
Guid attachmentId,
GetAttachmentContentUseCase useCase,
IWorkspaceRepository workspaces,
IWorkspaceMembershipRepository memberships,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.AttachmentNotFound);
}
var requestor = await ResolveRequestorAsync(workspaceId.Value, memberships, principal, cancellationToken);
var result = await useCase.ExecuteAsync(workspaceId.Value, attachmentId, requestor, cancellationToken);
if (result is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.AttachmentNotFound);
}
await using var content = result.Content;
// Inline only for a known-safe raster image; every other type is a download —
// and the filename is always encoded via ContentDispositionHeaderValue, never
// raw string interpolation into the header (docs/13-attachments.md: "safe
// Content-Disposition", "no user-controlled response headers").
var disposition = new ContentDispositionHeaderValue(
result.Blob.DeliveryPolicy == AttachmentDeliveryPolicy.InlinePreviewEligible ? "inline" : "attachment")
{
FileNameStar = result.Reference.OriginalFilename
};
httpContext.Response.ContentType = result.Blob.DetectedMediaType;
httpContext.Response.Headers.ContentDisposition = disposition.ToString();
httpContext.Response.Headers.XContentTypeOptions = "nosniff";
// Private content: cacheable by the browser itself for a short window, never
// by a shared/intermediate cache.
httpContext.Response.Headers.CacheControl = "private, max-age=300";
await content.CopyToAsync(httpContext.Response.Body, cancellationToken);
return TypedResults.Empty;
}
private static async Task<Ok<AttachmentListResponse>> ListForPageAsync(
Guid pageId,
GetPageAttachmentsUseCase useCase,
CancellationToken cancellationToken)
{
var results = await useCase.ExecuteAsync(pageId, cancellationToken);
IReadOnlyList<AttachmentSummary> items = results
.Select(result => new AttachmentSummary(
result.Reference.Id,
result.Reference.OriginalFilename,
result.Blob?.DetectedMediaType ?? "application/octet-stream",
result.Blob?.DeliveryPolicy.ToString() ?? "Unknown",
result.Blob?.ByteLength ?? 0,
result.Reference.Description,
result.Reference.AltText,
result.Reference.CreatedAt))
.ToList();
return TypedResults.Ok(new AttachmentListResponse(items));
}
private static async Task<Results<Ok<AttachmentSummary>, ProblemHttpResult>> UploadAsync(
Guid pageId,
IFormFile file,
IWorkspaceRepository workspaces,
UploadAttachmentUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
await using var stream = file.OpenReadStream();
var result = await useCase.ExecuteAsync(
new UploadAttachmentCommand(
workspaceId.Value, AttachmentOwner.ForPage(pageId), file.FileName, file.ContentType, stream,
CurrentUserId(principal), correlationId),
cancellationToken);
return ToUploadResult(result);
}
private static async Task<Results<Ok<AttachmentSummary>, ProblemHttpResult>> UploadStagedAsync(
Guid sessionId,
IFormFile file,
IWorkspaceRepository workspaces,
UploadAttachmentUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
await using var stream = file.OpenReadStream();
var result = await useCase.ExecuteAsync(
new UploadAttachmentCommand(
workspaceId.Value, AttachmentOwner.ForStagingSession(sessionId), file.FileName, file.ContentType, stream,
CurrentUserId(principal), correlationId),
cancellationToken);
return ToUploadResult(result);
}
private static Results<Ok<AttachmentSummary>, ProblemHttpResult> ToUploadResult(UploadAttachmentResult result)
{
if (result.Status != UploadAttachmentStatus.Success)
{
return result.Status switch
{
UploadAttachmentStatus.PageNotFound => TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.PageNotFound),
UploadAttachmentStatus.StagingSessionNotFound => TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.StagingSessionNotFound,
detail: "De sessie bestaat niet (meer), is al gepubliceerd/verwijderd, of is verlopen."),
UploadAttachmentStatus.TooLarge => TypedResults.Problem(
statusCode: StatusCodes.Status413PayloadTooLarge, type: ProblemTypes.AttachmentTooLarge, detail: result.Detail),
UploadAttachmentStatus.Rejected => TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.AttachmentRejected, detail: result.Detail),
_ => TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: result.Detail)
};
}
return TypedResults.Ok(new AttachmentSummary(
result.ReferenceId!.Value,
result.StoredFilename!,
result.DetectedMediaType!,
result.DeliveryPolicy!.Value.ToString(),
result.ByteLength!.Value,
null,
null,
DateTimeOffset.UtcNow));
}
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
/// <summary>Distinguishes a human (cookie-authenticated) requestor, whose workspace
/// role is resolved live from the database, from a service-account (bearer-token)
/// requestor — recognized by <see cref="ServiceTokenAuthenticationHandler.ServiceAccountClaimType"/>,
/// since <see cref="ServiceTokenAuthenticationHandler"/> deliberately reuses the
/// standard name-identifier claim for the service account's own ID, not a human
/// user's ID.</summary>
private static async Task<AttachmentRequestor> ResolveRequestorAsync(
Guid workspaceId, IWorkspaceMembershipRepository memberships, ClaimsPrincipal principal, CancellationToken cancellationToken)
{
if (principal.HasClaim(claim => claim.Type == ServiceTokenAuthenticationHandler.ServiceAccountClaimType))
{
return AttachmentRequestor.ServiceAccount;
}
var userId = CurrentUserId(principal);
var role = await memberships.GetRoleAsync(workspaceId, userId, cancellationToken);
return new AttachmentRequestor(userId, role);
}
}
@@ -0,0 +1,136 @@
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Application.Identity.UseCases;
using ItWorx.KnowledgeHub.Application.Ingestion;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.Contracts.Errors;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
public static class AuthEndpoints
{
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/auth").WithTags("Auth");
group.MapGet("/csrf", GetCsrfTokenAsync);
group.MapGet("/session", GetSessionAsync);
group.MapPost("/login", LoginAsync)
.RequireRateLimiting(RateLimitPolicies.Login);
group.MapPost("/logout", LogoutAsync)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/recovery/redeem", RedeemRecoveryAsync)
.RequireRateLimiting(RateLimitPolicies.Recovery);
return app;
}
private static Ok<CsrfTokenResponse> GetCsrfTokenAsync(HttpContext httpContext, IAntiforgery antiforgery)
{
var tokens = antiforgery.GetAndStoreTokens(httpContext);
return TypedResults.Ok(new CsrfTokenResponse(tokens.RequestToken!, tokens.HeaderName!));
}
private static async Task<Ok<SessionResponse>> GetSessionAsync(
GetSessionUseCase useCase,
IOptions<IngestionProposalOptions> ingestionOptions,
CancellationToken cancellationToken)
{
var session = await useCase.ExecuteAsync(cancellationToken);
return TypedResults.Ok(ToSessionResponse(session, ingestionOptions.Value));
}
private static async Task<Results<Ok<SessionResponse>, ProblemHttpResult>> LoginAsync(
[FromBody] LoginRequest request,
LoginUseCase loginUseCase,
GetSessionUseCase sessionUseCase,
IOptions<IngestionProposalOptions> ingestionOptions,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var outcome = await loginUseCase.ExecuteAsync(
new LoginCommand(request.Email, request.Password, correlationId),
cancellationToken);
switch (outcome.Status)
{
case PasswordSignInStatus.Success:
var session = await sessionUseCase.ExecuteAsync(cancellationToken);
return TypedResults.Ok(ToSessionResponse(session, ingestionOptions.Value));
case PasswordSignInStatus.LockedOut:
return TypedResults.Problem(
statusCode: StatusCodes.Status429TooManyRequests,
type: ProblemTypes.AccountLockedOut,
title: "Account tijdelijk vergrendeld.",
detail: "Te veel mislukte aanmeldpogingen. Probeer het later opnieuw.");
// Deliberately identical to the default InvalidCredentials case: a disabled
// account must not be distinguishable from a wrong password.
case PasswordSignInStatus.AccountDisabled:
default:
return TypedResults.Problem(
statusCode: StatusCodes.Status401Unauthorized,
type: ProblemTypes.InvalidCredentials,
title: "Aanmelden mislukt.",
detail: "E-mailadres of wachtwoord is onjuist.");
}
}
private static async Task<Ok> LogoutAsync(
LogoutUseCase useCase,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
await useCase.ExecuteAsync(correlationId, cancellationToken);
return TypedResults.Ok();
}
private static async Task<Results<Ok, ProblemHttpResult>> RedeemRecoveryAsync(
[FromBody] RecoveryRedeemRequest request,
RedeemRecoveryTokenUseCase useCase,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(request.Token, request.NewPassword, correlationId, cancellationToken);
return result.Status switch
{
RedeemRecoveryTokenStatus.Success => TypedResults.Ok(),
RedeemRecoveryTokenStatus.InvalidOrExpiredToken => TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
type: ProblemTypes.InvalidRecoveryToken,
title: "Ongeldige of verlopen herstelcode.",
detail: "Vraag een nieuwe herstelcode aan bij een beheerder."),
_ => TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
type: ProblemTypes.Validation,
title: "Ongeldig wachtwoord.",
detail: string.Join(" ", result.Errors))
};
}
private static SessionResponse ToSessionResponse(SessionInfo session, IngestionProposalOptions ingestionOptions) => new(
session.Authenticated,
session.SetupRequired,
session.UserId,
session.DisplayName,
session.Email,
session.WorkspaceId,
session.Role?.ToString(),
session.Authenticated ? new FeatureFlagsResponse(ingestionOptions.Enabled) : null);
}
@@ -0,0 +1,52 @@
using System.Security.Claims;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Operations;
using ItWorx.KnowledgeHub.Application.Operations.UseCases;
using ItWorx.KnowledgeHub.Contracts.Operations;
using Microsoft.AspNetCore.Http.HttpResults;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
/// <summary>
/// Administrator-only, cookie-authenticated backup management (docs/18-backup-restore.md).
/// Deliberately create/list only — restore is never reachable through the live
/// application's own HTTP surface (see <c>RestoreModeRunner</c> and ADR-016 decision 4).
/// </summary>
public static class BackupsEndpoints
{
public static IEndpointRouteBuilder MapBackupsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/admin/backups")
.WithTags("Backups")
.RequireAuthorization(AuthorizationPolicies.RequireAdministrator);
group.MapGet("", ListAsync);
group.MapPost("", CreateAsync)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
return app;
}
private static async Task<Ok<BackupListResponse>> ListAsync(ListBackupsUseCase useCase, CancellationToken cancellationToken)
{
var backups = await useCase.ExecuteAsync(cancellationToken);
return TypedResults.Ok(new BackupListResponse(backups.Select(ToResponse).ToList()));
}
private static async Task<Ok<BackupSummaryResponse>> CreateAsync(
CreateBackupUseCase useCase, ClaimsPrincipal principal, HttpContext httpContext, CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var actorId = Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
var summary = await useCase.ExecuteAsync(new CreateBackupCommand(actorId, correlationId), cancellationToken);
return TypedResults.Ok(ToResponse(summary));
}
private static BackupSummaryResponse ToResponse(BackupSummary summary) =>
new(
summary.BackupId, summary.CreatedAt, summary.TotalSizeBytes, summary.TableCount,
summary.TotalRowCount, summary.AttachmentCount, summary.SchemaVersion, summary.ProductVersion);
}
@@ -0,0 +1,197 @@
using System.Security.Claims;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Api.Knowledge;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Common;
using ItWorx.KnowledgeHub.Application.Decisions.UseCases;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Application.Knowledge;
using ItWorx.KnowledgeHub.Contracts.Decisions;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Domain.Identity;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
public static class DecisionsEndpoints
{
public static IEndpointRouteBuilder MapDecisionsEndpoints(this IEndpointRouteBuilder app)
{
// See PagesEndpoints.cs's identical comment (ADR-015).
var group = app.MapGroup("/api/v1/decisions").WithTags("Decisions");
group.MapGet("", ListAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.DecisionsRead);
group.MapGet("/{decisionId:guid}", GetAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.DecisionsRead);
group.MapPost("", CreateAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.DecisionsWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.AddEndpointFilter<IdempotencyEndpointFilter>();
group.MapPost("/{decisionId:guid}/accept", AcceptAsync)
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/{decisionId:guid}/reject", RejectAsync)
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/{decisionId:guid}/withdraw", WithdrawAsync)
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
return app;
}
private static async Task<Ok<DecisionListResponse>> ListAsync(
IWorkspaceRepository workspaces,
IPageRepository pages,
ListDecisionsUseCase useCase,
[FromQuery] Guid? projectId,
[FromQuery] string? status,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Ok(new DecisionListResponse([], null));
}
var result = await useCase.ExecuteAsync(workspaceId.Value, projectId, status, cursor, limit ?? 50, cancellationToken);
var items = new List<DecisionSummary>(result.Items.Count);
foreach (var decision in result.Items)
{
var page = await pages.FindAsync(decision.PageId, cancellationToken);
items.Add(new DecisionSummary(
decision.Id, decision.ProjectId, decision.DecisionNumber, decision.Status.ToString(),
page?.TitleValue ?? string.Empty,
decision.DecisionDate.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
decision.Summary, decision.UpdatedAt));
}
return TypedResults.Ok(new DecisionListResponse(items, result.NextCursor));
}
private static async Task<Results<Ok<DecisionDetail>, ProblemHttpResult>> GetAsync(
Guid decisionId, GetDecisionUseCase useCase, HttpContext httpContext, CancellationToken cancellationToken)
{
var result = await useCase.ExecuteAsync(decisionId, cancellationToken);
if (result is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.DecisionNotFound);
}
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag);
return TypedResults.Ok(ToDecisionDetail(result));
}
private static async Task<Results<Ok<DecisionDetail>, ProblemHttpResult>> CreateAsync(
[FromBody] CreateDecisionRequest request,
IWorkspaceRepository workspaces,
CreateDecisionUseCase createUseCase,
GetDecisionUseCase getUseCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await createUseCase.ExecuteAsync(
new CreateDecisionCommand(
workspaceId.Value, request.ParentPageId, request.ProjectId, request.Title, request.Markdown,
request.Summary, request.SupersedesDecisionId, CurrentUserId(principal), correlationId),
cancellationToken);
if (result.Status != OperationStatus.Success)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: string.Join(" ", result.Errors));
}
var detail = await getUseCase.ExecuteAsync(result.Value, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail!.ETag);
return TypedResults.Ok(ToDecisionDetail(detail));
}
private static async Task<Results<Ok<DecisionDetail>, ProblemHttpResult>> AcceptAsync(
Guid decisionId, AcceptDecisionUseCase useCase, GetDecisionUseCase getUseCase, HttpContext httpContext, CancellationToken cancellationToken) =>
await ApplyTransitionAsync(
decisionId, httpContext,
(etag, actorId, correlationId) => useCase.ExecuteAsync(new AcceptDecisionCommand(decisionId, etag, actorId, correlationId), cancellationToken),
getUseCase, cancellationToken);
private static async Task<Results<Ok<DecisionDetail>, ProblemHttpResult>> RejectAsync(
Guid decisionId, RejectDecisionUseCase useCase, GetDecisionUseCase getUseCase, HttpContext httpContext, CancellationToken cancellationToken) =>
await ApplyTransitionAsync(
decisionId, httpContext,
(etag, actorId, correlationId) => useCase.ExecuteAsync(new RejectDecisionCommand(decisionId, etag, actorId, correlationId), cancellationToken),
getUseCase, cancellationToken);
private static async Task<Results<Ok<DecisionDetail>, ProblemHttpResult>> WithdrawAsync(
Guid decisionId, WithdrawDecisionUseCase useCase, GetDecisionUseCase getUseCase, HttpContext httpContext, CancellationToken cancellationToken) =>
await ApplyTransitionAsync(
decisionId, httpContext,
(etag, actorId, correlationId) => useCase.ExecuteAsync(new WithdrawDecisionCommand(decisionId, etag, actorId, correlationId), cancellationToken),
getUseCase, cancellationToken);
private static async Task<Results<Ok<DecisionDetail>, ProblemHttpResult>> ApplyTransitionAsync(
Guid decisionId,
HttpContext httpContext,
Func<string, Guid, string?, Task<OperationResult>> transition,
GetDecisionUseCase getUseCase,
CancellationToken cancellationToken)
{
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
if (ifMatch is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await transition(ifMatch, CurrentUserId(httpContext.User), correlationId);
if (result.Status != OperationStatus.Success)
{
return result.Status switch
{
OperationStatus.NotFound => TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.DecisionNotFound),
OperationStatus.ConcurrencyConflict => TypedResults.Problem(
statusCode: StatusCodes.Status412PreconditionFailed, type: ProblemTypes.ConcurrencyConflict, title: "Versieconflict."),
OperationStatus.InvalidTransition => TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict, type: ProblemTypes.DecisionInvalidTransition,
title: "Ongeldige statusovergang.", detail: string.Join(" ", result.Errors)),
_ => TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: string.Join(" ", result.Errors))
};
}
var detail = await getUseCase.ExecuteAsync(decisionId, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail!.ETag);
return TypedResults.Ok(ToDecisionDetail(detail));
}
private static DecisionDetail ToDecisionDetail(DecisionDetailResult result) =>
new(
result.Decision.Id, result.Decision.WorkspaceId, result.Decision.ProjectId, result.Decision.PageId,
result.Decision.DecisionNumber, result.Decision.Status.ToString(),
result.Decision.DecisionDate.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
result.Page.Page.TitleValue, result.Page.Page.Markdown, result.Page.RenderedHtml, result.Decision.Summary,
result.Supersedes is null ? null : new DecisionLineageEntry(
result.Supersedes.Id, result.Supersedes.DecisionNumber, result.Supersedes.Status.ToString(), result.Supersedes.Summary),
result.SupersededBy is null ? null : new DecisionLineageEntry(
result.SupersededBy.Id, result.SupersededBy.DecisionNumber, result.SupersededBy.Status.ToString(), result.SupersededBy.Summary),
result.Decision.CreatedAt, result.Decision.UpdatedAt, result.ETag);
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
}
@@ -0,0 +1,119 @@
using System.Security.Claims;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
/// <summary>Draft autosave — private per user, never silently overwrites a newer
/// published revision (docs/27-acceptance-matrix.md, M2-A04).</summary>
public static class DraftsEndpoints
{
public static IEndpointRouteBuilder MapDraftsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/drafts")
.WithTags("Drafts")
.RequireAuthorization(AuthorizationPolicies.RequireEditor);
group.MapGet("/{draftKey}", GetDraftAsync);
group.MapPut("/{draftKey}", SaveDraftAsync)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapDelete("/{draftKey}", DiscardDraftAsync)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
return app;
}
private static async Task<Results<Ok<DraftResponse>, ProblemHttpResult>> GetDraftAsync(
string draftKey,
IWorkspaceRepository workspaces,
GetDraftUseCase useCase,
ClaimsPrincipal principal,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var draft = await useCase.ExecuteAsync(workspaceId.Value, CurrentUserId(principal), draftKey, cancellationToken);
if (draft is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
return TypedResults.Ok(new DraftResponse(
draft.DraftKey, draft.PageId, draft.BaseRevisionNumber, draft.Title, draft.Markdown, draft.UpdatedAt, draft.ExpiresAt,
BaseRevisionIsStale: false, draft.Tags, draft.DocumentType, draft.DocumentStatus, draft.ChangeSummary));
}
private static async Task<Ok<DraftResponse>> SaveDraftAsync(
string draftKey,
[FromBody] SaveDraftRequest request,
IWorkspaceRepository workspaces,
SaveDraftUseCase useCase,
ClaimsPrincipal principal,
CancellationToken cancellationToken)
{
var workspaceId = (await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken))!.Value;
var result = await useCase.ExecuteAsync(
new SaveDraftCommand(
workspaceId,
CurrentUserId(principal),
draftKey,
request.PageId,
request.BaseRevisionNumber,
request.Title,
request.Markdown,
request.Tags,
request.DocumentType,
request.DocumentStatus,
request.ChangeSummary),
cancellationToken);
var draft = result.Draft;
return TypedResults.Ok(new DraftResponse(
draft.DraftKey,
draft.PageId,
draft.BaseRevisionNumber,
draft.Title,
draft.Markdown,
draft.UpdatedAt,
draft.ExpiresAt,
result.BaseRevisionIsStale,
draft.Tags,
draft.DocumentType,
draft.DocumentStatus,
draft.ChangeSummary));
}
private static async Task<Results<Ok, ProblemHttpResult>> DiscardDraftAsync(
string draftKey,
IWorkspaceRepository workspaces,
DiscardDraftUseCase useCase,
ClaimsPrincipal principal,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var removed = await useCase.ExecuteAsync(workspaceId.Value, CurrentUserId(principal), draftKey, cancellationToken);
return removed
? TypedResults.Ok()
: TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
}
@@ -0,0 +1,312 @@
using System.Security.Claims;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Application.Migration.UseCases;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Migration;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Domain.Identity;
using ItWorx.KnowledgeHub.Domain.Infrastructure;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
/// <summary>
/// LeafWiki import: Administrator-level, since preflight reads an operator-mounted
/// server-side directory (docs/17-leafwiki-migration.md) — but, like every other
/// content-mutating area of this API (Pages, Runbooks, Infrastructure), reachable by
/// either a cookie-authenticated Administrator or a scoped service-account bearer
/// token carrying the documented imports:read/imports:execute scopes (previously
/// defined in Scopes.cs but never actually consumed anywhere — found while running a
/// real migration). TryResolveSourcePath's own directory-boundary check (the
/// candidate path must resolve inside the configured imports root) already enforces
/// the real safety property regardless of which auth mechanism reached it, so
/// widening this does not weaken it.
/// </summary>
public static class ImportsEndpoints
{
public static IEndpointRouteBuilder MapImportsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/admin/imports").WithTags("Imports");
group.MapPost("/preflight", PreflightAsync)
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.ImportsExecute)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapGet("/{jobId:guid}", GetJobAsync)
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.ImportsRead);
group.MapPost("/{jobId:guid}/execute", ExecuteAsync)
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.ImportsExecute)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/{jobId:guid}/rollback", RollbackAsync)
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.ImportsExecute)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
return app;
}
private static async Task<Results<Ok<ImportPreflightResponse>, ProblemHttpResult>> PreflightAsync(
[FromBody] PreflightImportRequest request,
IWorkspaceRepository workspaces,
IConfiguration configuration,
PreflightImportUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
if (!TryResolveSourcePath(configuration, request.SourceRelativePath, out var sourceRootPath))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
type: ProblemTypes.ImportSourceInvalid,
title: "Ongeldig brondirectory-pad.",
detail: "Het pad moet binnen de geconfigureerde imports-map liggen.");
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new PreflightImportCommand(workspaceId.Value, sourceRootPath, request.IdempotencyKey, CurrentUserId(principal), correlationId),
cancellationToken);
if (result.Status == PreflightImportStatus.NoApplicableAdapter)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
type: ProblemTypes.ImportNoApplicableAdapter,
title: "Geen adapter kan deze bron interpreteren.",
detail: string.Join(" ", result.Blockers));
}
var job = result.Job!;
IReadOnlyList<ImportPlanItemResponse> items = result.Plan?.Items
.Select(item => new ImportPlanItemResponse(
item.SourceIdentity, item.Kind.ToString(), item.ProposedTitle, item.ProposedParentSourceIdentity,
item.Conflict.ToString(), item.ConflictDetail))
.ToList() ?? [];
return TypedResults.Ok(new ImportPreflightResponse(
result.Status.ToString(), job.Id, job.AdapterName, job.DiscoveredCount, job.PlannedCount, items, result.Warnings, result.Blockers));
}
private static async Task<Results<Ok<ImportJobSummaryResponse>, ProblemHttpResult>> GetJobAsync(
Guid jobId, GetImportJobUseCase useCase, CancellationToken cancellationToken)
{
var job = await useCase.ExecuteAsync(jobId, cancellationToken);
if (job is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ImportJobNotFound);
}
return TypedResults.Ok(new ImportJobSummaryResponse(
job.Id, job.AdapterName, job.Status.ToString(), job.DiscoveredCount, job.PlannedCount,
job.ImportedCount, job.SkippedCount, job.FailedCount, job.CreatedAt, job.StartedAt, job.CompletedAt));
}
private static async Task<Results<Ok<ExecuteImportResponse>, ProblemHttpResult>> ExecuteAsync(
Guid jobId,
IConfiguration configuration,
GetImportJobUseCase getJob,
ExecuteImportUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var job = await getJob.ExecuteAsync(jobId, cancellationToken);
if (job is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ImportJobNotFound);
}
// The job itself does not persist which relative path it was preflighted
// against beyond the fingerprint hash; the caller must supply the same
// relative path again so it can be re-resolved and re-verified against that
// recorded fingerprint (never trusted blindly).
var relativePath = httpContext.Request.Query["sourceRelativePath"].ToString();
if (!TryResolveSourcePath(configuration, relativePath, out var sourceRootPath))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.ImportSourceInvalid);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new ExecuteImportCommand(jobId, sourceRootPath, CurrentUserId(principal), correlationId), cancellationToken);
if (result.Status != ExecuteImportStatus.Success)
{
return result.Status switch
{
ExecuteImportStatus.JobNotFound => TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ImportJobNotFound),
ExecuteImportStatus.SourceChanged => TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict,
type: ProblemTypes.ImportSourceChanged,
title: "Bron is gewijzigd sinds de preflight.",
detail: "Voer preflight opnieuw uit."),
_ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, type: ProblemTypes.Validation)
};
}
return TypedResults.Ok(new ExecuteImportResponse(
result.Status.ToString(), result.Imported, result.Skipped, result.Failed, result.SourceUnchangedAfterExecution, result.Diagnostics));
}
private static async Task<Results<Ok<RollbackImportResponse>, ProblemHttpResult>> RollbackAsync(
Guid jobId,
RollbackImportUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(new RollbackImportCommand(jobId, CurrentUserId(principal), correlationId), cancellationToken);
if (result.Status != RollbackImportStatus.Success)
{
return result.Status switch
{
RollbackImportStatus.JobNotFound => TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ImportJobNotFound),
_ => TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict,
type: ProblemTypes.Validation,
title: "Er is niets om terug te draaien voor deze importtaak.")
};
}
return TypedResults.Ok(new RollbackImportResponse(
result.Status.ToString(), result.RemovedPages, result.PreservedPages, result.RemovedAttachments));
}
private static bool TryResolveSourcePath(IConfiguration configuration, string relativePath, out string resolvedPath)
{
resolvedPath = string.Empty;
try
{
var configuredImportsRoot = Path.GetFullPath(
configuration["KnowledgeHub:Paths:Imports"] ?? Path.Combine(AppContext.BaseDirectory, "imports-data"));
if (!TryResolveCanonicalDirectory(configuredImportsRoot, out var importsRoot))
{
return false;
}
if (string.IsNullOrWhiteSpace(relativePath))
{
resolvedPath = importsRoot;
return true;
}
var candidate = Path.GetFullPath(Path.Combine(configuredImportsRoot, relativePath));
if (!FileSystemPathBoundary.Contains(configuredImportsRoot, candidate))
{
return false;
}
var relativeCandidate = Path.GetRelativePath(configuredImportsRoot, candidate);
if (!TryResolveUnlinkedDirectory(importsRoot, relativeCandidate, out resolvedPath))
{
resolvedPath = string.Empty;
return false;
}
return true;
}
catch (Exception exception) when (exception is ArgumentException
or IOException
or UnauthorizedAccessException
or NotSupportedException
or System.Security.SecurityException)
{
resolvedPath = string.Empty;
return false;
}
}
private static bool TryResolveCanonicalDirectory(string path, out string canonicalPath)
{
var fullPath = Path.GetFullPath(path);
var volumeRoot = Path.GetPathRoot(fullPath);
if (string.IsNullOrEmpty(volumeRoot) || !Directory.Exists(volumeRoot))
{
canonicalPath = string.Empty;
return false;
}
var current = volumeRoot;
foreach (var segment in fullPath[volumeRoot.Length..].Split(
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
StringSplitOptions.RemoveEmptyEntries))
{
var next = Path.Combine(current, segment);
var info = new DirectoryInfo(next);
if (!info.Exists)
{
canonicalPath = string.Empty;
return false;
}
if ((info.Attributes & FileAttributes.ReparsePoint) != 0)
{
var target = info.ResolveLinkTarget(returnFinalTarget: true);
if (target is null || !Directory.Exists(target.FullName))
{
canonicalPath = string.Empty;
return false;
}
current = Path.GetFullPath(target.FullName);
}
else
{
current = Path.GetFullPath(next);
}
}
canonicalPath = current;
return Directory.Exists(canonicalPath);
}
private static bool TryResolveUnlinkedDirectory(string root, string relativePath, out string resolvedPath)
{
var candidate = Path.GetFullPath(Path.Combine(root, relativePath));
if (!FileSystemPathBoundary.Contains(root, candidate) || !Directory.Exists(candidate))
{
resolvedPath = string.Empty;
return false;
}
var relativeCandidate = Path.GetRelativePath(root, candidate);
var current = root;
if (relativeCandidate != ".")
{
foreach (var segment in relativeCandidate.Split(
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
StringSplitOptions.RemoveEmptyEntries))
{
current = Path.Combine(current, segment);
var info = new DirectoryInfo(current);
if (!info.Exists || (info.Attributes & FileAttributes.ReparsePoint) != 0)
{
resolvedPath = string.Empty;
return false;
}
}
}
resolvedPath = Path.GetFullPath(current);
return FileSystemPathBoundary.Contains(root, resolvedPath);
}
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
}
@@ -0,0 +1,565 @@
using System.Security.Claims;
using System.Text.Json;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Api.Knowledge;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Audit;
using ItWorx.KnowledgeHub.Application.Common;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Application.Infrastructure;
using ItWorx.KnowledgeHub.Application.Infrastructure.UseCases;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Domain.Identity;
using ItWorx.KnowledgeHub.Domain.Infrastructure;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
public static class InfrastructureEndpoints
{
public static IEndpointRouteBuilder MapInfrastructureEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/infrastructure").WithTags("Infrastructure");
group.MapGet("/overview", GetOverviewAsync)
.RequireContentAccess(WorkspaceRole.Reader, Scopes.InfrastructureRead);
group.MapGet("/services", ListServicesAsync)
.RequireContentAccess(WorkspaceRole.Reader, Scopes.InfrastructureRead);
group.MapGet("/storage", ListStorageAsync)
.RequireContentAccess(WorkspaceRole.Reader, Scopes.InfrastructureRead);
group.MapGet("/services/{serviceId:guid}", GetServiceAsync)
.RequireContentAccess(WorkspaceRole.Reader, Scopes.InfrastructureRead);
group.MapGet("/changes", ListChangesAsync)
.RequireContentAccess(WorkspaceRole.Reader, Scopes.InfrastructureRead);
group.MapGet("/overrides/catalog", GetManualOverrideCatalogAsync)
.RequireContentAccess(WorkspaceRole.Reader, Scopes.InfrastructureRead);
group.MapPut("/services/{serviceId:guid}/editorial-profile", UpdateEditorialProfileAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.InfrastructureWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/services/{serviceId:guid}/overrides", SetOverrideAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.InfrastructureWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
// Generic sibling of the route above (V2-R5, docs/adr/ADR-029-v2-r5-final-
// hardening.md): corrects a fact for any InfrastructureObjectKind (Host,
// Network, Volume, Database, Endpoint, Component, Deployment, Backup), not
// only a Service reached through its own nested route.
group.MapPost("/overrides", SetGenericOverrideAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.InfrastructureWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/services/{serviceId:guid}/review", ReviewServiceAsync)
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.InfrastructureWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapDelete("/overrides/{overrideId:guid}", RemoveOverrideAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.InfrastructureWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/inventory/snapshots", SynchronizeAsync)
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.InfrastructureWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.AddEndpointFilter<IdempotencyEndpointFilter>();
group.MapPost("/inventory/curated-package", ImportCuratedPackageAsync)
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.InfrastructureWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.AddEndpointFilter<IdempotencyEndpointFilter>();
return app;
}
private static async Task<IResult> GetOverviewAsync(
IWorkspaceRepository workspaces,
GetInfrastructureOverviewUseCase useCase,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return Results.Ok(new InfrastructureOverviewResponse(null, [], 0, 0, 0, 0, 0));
}
var overview = await useCase.ExecuteAsync(workspaceId.Value, cancellationToken);
return Results.Ok(new InfrastructureOverviewResponse(
overview.LatestSnapshot is null ? null : ToSnapshot(overview.LatestSnapshot),
overview.Hosts.Select(ToHost).ToArray(), overview.ServiceCount, overview.ProposedServiceCount,
overview.MissingServiceCount, overview.UnhealthyServiceCount, overview.UnprotectedServiceCount));
}
private static async Task<IResult> ListServicesAsync(
IWorkspaceRepository workspaces,
ListInfrastructureServiceSummariesUseCase useCase,
[FromQuery] bool? includeHidden,
[FromQuery] Guid? projectId,
[FromQuery] string? search,
[FromQuery] string? status,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return Results.Ok(new InfrastructureServiceListResponse([], null, 0));
}
var page = await useCase.ExecuteAsync(
workspaceId.Value, includeHidden ?? false, projectId, search, status, cursor, limit ?? 50, cancellationToken);
return Results.Ok(
new InfrastructureServiceListResponse(
page.Items.Select(ToSummary).ToArray(), page.NextCursor, page.TotalCount));
}
private static async Task<IResult> ListStorageAsync(
IWorkspaceRepository workspaces,
ListInfrastructureVolumesUseCase useCase,
[FromQuery] string? search,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return Results.Ok(new InfrastructureStorageListResponse([], null));
}
var page = await useCase.ExecuteAsync(workspaceId.Value, search, cursor, limit ?? 50, cancellationToken);
var items = page.Items.Select(row => new InfrastructureStorageRow(
row.Volume.Id, row.Service.Id, row.ServiceDisplayName, row.Volume.Name, row.Volume.MountPath,
row.Volume.StorageClass, row.Volume.ReadOnly, row.Volume.SizeBytes, Provenance(row.Volume))).ToArray();
return Results.Ok(new InfrastructureStorageListResponse(items, page.NextCursor));
}
private static async Task<IResult> GetServiceAsync(
Guid serviceId,
IWorkspaceRepository workspaces,
GetInfrastructureServiceUseCase useCase,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
var graph = workspaceId is null
? null
: await useCase.ExecuteAsync(workspaceId.Value, serviceId, cancellationToken);
if (graph is null)
{
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(graph.EditorialETag);
return Results.Ok(ToDetail(graph));
}
private static async Task<IResult> ListChangesAsync(
IWorkspaceRepository workspaces,
ListInfrastructureChangesUseCase useCase,
[FromQuery] int? limit,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return Results.Ok(Array.Empty<InventoryChangeResponse>());
}
var changes = await useCase.ExecuteAsync(workspaceId.Value, limit ?? 100, cancellationToken);
return Results.Ok(changes.Select(change => new InventoryChangeResponse(
change.Id, change.SnapshotId, change.TargetKind.ToString(), change.TargetId,
change.SourceKey, change.ChangeKind.ToString(), change.ChangedFields, change.RecordedAt)).ToArray());
}
private static IResult GetManualOverrideCatalogAsync([FromQuery] string? targetKind)
{
if (!Enum.TryParse<InfrastructureObjectKind>(targetKind ?? "Service", true, out var kind) || !Enum.IsDefined(kind))
{
return Results.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation,
detail: "Onbekend objecttype.");
}
var fields = ItWorx.KnowledgeHub.Application.Infrastructure.Validation.ManualOverrideCatalog.FieldsFor(kind)
.Select(field => new ManualOverrideFieldCatalogEntry(
field.FieldName, field.DisplayLabel, field.ValueKind.ToString(), field.MaxStringLength, field.AllowedEnumValues))
.ToArray();
return Results.Ok(new ManualOverrideFieldCatalogResponse(kind.ToString(), fields));
}
private static async Task<IResult> UpdateEditorialProfileAsync(
Guid serviceId,
[FromBody] UpdateEditorialProfileRequest request,
IWorkspaceRepository workspaces,
UpdateInfrastructureEditorialProfileUseCase updateUseCase,
GetInfrastructureServiceUseCase getUseCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
var expectedETag = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
if (workspaceId is null || expectedETag is null)
{
return Results.Problem(
statusCode: expectedETag is null ? StatusCodes.Status428PreconditionRequired : StatusCodes.Status404NotFound,
type: expectedETag is null ? ProblemTypes.Validation : ProblemTypes.NotFound);
}
var result = await updateUseCase.ExecuteAsync(
new UpdateInfrastructureEditorialProfileCommand(
workspaceId.Value, InfrastructureObjectKind.Service, serviceId,
request.DisplayName, request.Summary, request.Category, request.Purpose, request.OwnerId, request.ProjectId,
request.RepositoryUrl, request.LiveUrl, request.BackupStatus, request.MonitoringUrl,
request.MaintenanceNotes, request.KnownRisks, request.UpdateProcedure, request.RollbackProcedure,
request.Tags, request.IsFavorite, request.IsHidden, request.IsArchived, expectedETag,
CurrentUserId(principal), CorrelationId(httpContext)),
cancellationToken);
if (result.Status != OperationStatus.Success)
{
return ToProblem(result.Status, result.Errors);
}
var graph = await getUseCase.ExecuteAsync(workspaceId.Value, serviceId, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(graph!.EditorialETag);
return Results.Ok(ToDetail(graph));
}
private static async Task<IResult> SetOverrideAsync(
Guid serviceId,
[FromBody] SetManualOverrideRequest request,
IWorkspaceRepository workspaces,
SetManualOverrideUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var result = await useCase.ExecuteAsync(
new SetManualOverrideCommand(
workspaceId.Value, InfrastructureObjectKind.Service, serviceId, request.FieldName,
request.Value.GetRawText(), request.Reason, CurrentUserId(principal), CorrelationId(httpContext)),
cancellationToken);
return result.Status == OperationStatus.Success
? Results.Ok(new { id = result.Value })
: ToProblem(result.Status, result.Errors);
}
private static async Task<IResult> SetGenericOverrideAsync(
[FromBody] SetInfrastructureOverrideRequest request,
IWorkspaceRepository workspaces,
SetManualOverrideUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
if (!Enum.TryParse<InfrastructureObjectKind>(request.TargetKind, true, out var targetKind) || !Enum.IsDefined(targetKind))
{
return Results.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation,
detail: "Onbekend objecttype.");
}
var result = await useCase.ExecuteAsync(
new SetManualOverrideCommand(
workspaceId.Value, targetKind, request.TargetId, request.FieldName,
request.Value.GetRawText(), request.Reason, CurrentUserId(principal), CorrelationId(httpContext)),
cancellationToken);
return result.Status == OperationStatus.Success
? Results.Ok(new { id = result.Value })
: ToProblem(result.Status, result.Errors);
}
private static async Task<IResult> RemoveOverrideAsync(
Guid overrideId,
IWorkspaceRepository workspaces,
RemoveManualOverrideUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var result = await useCase.ExecuteAsync(
workspaceId.Value, overrideId, CurrentUserId(principal), CorrelationId(httpContext), cancellationToken);
return result.Status == OperationStatus.Success ? Results.NoContent() : ToProblem(result.Status, result.Errors);
}
private static async Task<IResult> ReviewServiceAsync(
Guid serviceId,
[FromBody] ReviewInfrastructureServiceRequest request,
IWorkspaceRepository workspaces,
ReviewInfrastructureServiceUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
if (!Enum.TryParse<InfrastructureServiceReviewDecision>(request.Decision, true, out var decision)
|| !Enum.IsDefined(decision))
{
return Results.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
type: ProblemTypes.Validation,
detail: "Beslissing moet 'Accept' of 'Reject' zijn.");
}
var result = await useCase.ExecuteAsync(
new ReviewInfrastructureServiceCommand(
workspaceId.Value, serviceId, decision, request.Reason,
CurrentUserId(principal), CorrelationId(httpContext)),
cancellationToken);
return result.Status == OperationStatus.Success
? Results.Ok(new ReviewInfrastructureServiceResponse(serviceId, result.Value.ToString()))
: ToProblem(result.Status, result.Errors);
}
private static async Task<IResult> SynchronizeAsync(
[FromBody] SynchronizeInventoryRequest request,
IWorkspaceRepository workspaces,
SynchronizeInfrastructureInventoryUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
InventorySnapshotInput snapshot;
try
{
snapshot = ToSnapshotInput(request);
}
catch (ArgumentException exception)
{
return Results.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: exception.Message);
}
var actorId = CurrentActorId(principal);
var result = await useCase.ExecuteAsync(
new SynchronizeInfrastructureInventoryCommand(
workspaceId.Value, snapshot, actorId,
principal.Identity?.AuthenticationType == ServiceTokenAuthenticationDefaults.Scheme
? AuditActorKind.ServiceAccount : AuditActorKind.User,
principal.Identity?.AuthenticationType == ServiceTokenAuthenticationDefaults.Scheme ? "service-token" : "web",
CorrelationId(httpContext)),
cancellationToken);
if (result.Status != OperationStatus.Success)
{
return ToProblem(result.Status, result.Errors);
}
var sync = result.Value!;
return Results.Ok(new InventorySyncResponse(
sync.SnapshotId, sync.AlreadyImported, sync.AddedServices, sync.UpdatedServices, sync.MissingServices,
sync.PreservedEditorialProfiles, sync.PreservedOverrides));
}
private static async Task<IResult> ImportCuratedPackageAsync(
[FromBody] ImportCuratedInventoryRequest request,
IWorkspaceRepository workspaces,
ImportCuratedInventoryPackageUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
InventorySnapshotInput snapshot;
try
{
snapshot = ToSnapshotInput(request.Inventory);
}
catch (ArgumentException exception)
{
return Results.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
type: ProblemTypes.Validation,
detail: exception.Message);
}
var profiles = (request.Services ?? []).Select(profile => new CuratedServiceProfileInput(
profile.SourceKey, profile.DisplayName, profile.Summary, profile.Category, profile.Purpose,
profile.RepositoryUrl, profile.LiveUrl, profile.Tags ?? [], profile.Reviewed, profile.Archived)).ToArray();
var result = await useCase.ExecuteAsync(
new ImportCuratedInventoryPackageCommand(
workspaceId.Value, snapshot, profiles, CurrentUserId(principal), CorrelationId(httpContext)),
cancellationToken);
if (result.Status != OperationStatus.Success)
{
return ToProblem(result.Status, result.Errors);
}
var imported = result.Value!;
var sync = imported.Inventory;
return Results.Ok(new ImportCuratedInventoryResponse(
new InventorySyncResponse(
sync.SnapshotId, sync.AlreadyImported, sync.AddedServices, sync.UpdatedServices,
sync.MissingServices, sync.PreservedEditorialProfiles, sync.PreservedOverrides),
imported.CreatedEditorialProfiles, imported.ConfirmedServices, imported.ArchivedServices));
}
private static InventorySnapshotInput ToSnapshotInput(SynchronizeInventoryRequest request) =>
new(
request.SourceKey, request.SourceHash, request.CapturedAt,
(request.Hosts ?? []).Select(host => new InventoryHostInput(
host.SourceKey, host.Hostname, host.OperatingSystem, host.KernelVersion,
host.Architecture, host.LogicalCpuCount, host.TotalMemoryBytes)).ToArray(),
(request.Services ?? []).Select(ToServiceInput).ToArray());
private static InventoryServiceInput ToServiceInput(InventoryServiceRequest service) =>
new(
service.SourceKey, service.HostSourceKey, service.DetectedName, service.RuntimeKind, service.RuntimeStatus,
ParseEnum<HealthState>(service.Health, "health"), service.ImageReference, service.ImageDigest, service.RuntimeUser,
service.ReadOnlyRootFilesystem, service.NoNewPrivileges, service.Privileged,
ParseEnum<FactConfidence>(service.Confidence, "confidence"),
(service.Components ?? []).Select(item => new InventoryComponentInput(
item.SourceKey, item.Name, item.Kind, item.RuntimeId, item.Status, item.ImageReference,
ParseEnum<FactConfidence>(item.Confidence, "component confidence"))).ToArray(),
(service.Endpoints ?? []).Select(item => new InventoryEndpointInput(
item.SourceKey, item.Scheme, item.Host, item.PublishedPort, item.TargetPort, item.Url, item.IsPublic,
ParseEnum<FactConfidence>(item.Confidence, "endpoint confidence"))).ToArray(),
(service.Networks ?? []).Select(item => new InventoryNetworkInput(
item.SourceKey, item.Name, item.Driver, item.Scope, item.Internal,
ParseEnum<FactConfidence>(item.Confidence, "network confidence"))).ToArray(),
(service.Volumes ?? []).Select(item => new InventoryVolumeInput(
item.SourceKey, item.Name, item.MountPath, item.StorageClass, item.ReadOnly, item.SizeBytes,
ParseEnum<FactConfidence>(item.Confidence, "volume confidence"))).ToArray(),
(service.Databases ?? []).Select(item => new InventoryDatabaseInput(
item.SourceKey, item.Name, item.Engine, item.Version, item.Host, item.Port, item.HostPortPublished,
ParseEnum<FactConfidence>(item.Confidence, "database confidence"))).ToArray(),
(service.Dependencies ?? []).Select(item => new InventoryDependencyInput(
item.SourceKey, item.DependsOnSourceKey, item.Kind,
ParseEnum<FactConfidence>(item.Confidence, "dependency confidence"))).ToArray(),
(service.HealthObservations ?? []).Select(item => new InventoryHealthInput(
item.SourceKey, ParseEnum<HealthState>(item.State, "health state"), item.CheckKind,
item.ResponseTimeMilliseconds, ParseEnum<FactConfidence>(item.Confidence, "health confidence"))).ToArray(),
(service.Deployments ?? []).Select(item => new InventoryDeploymentInput(
item.SourceKey, item.RepositoryUrl, item.Commit, item.ImageReference, item.ImageDigest, item.DeployedAt,
ParseEnum<FactConfidence>(item.Confidence, "deployment confidence"))).ToArray(),
(service.Backups ?? []).Select(item => new InventoryBackupInput(
item.SourceKey, item.Method, item.Schedule, item.LastSuccessfulAt, item.RestoreTested,
ParseEnum<FactConfidence>(item.Confidence, "backup confidence"))).ToArray());
private static T ParseEnum<T>(string value, string field) where T : struct, Enum
{
if (!Enum.TryParse<T>(value, true, out var parsed) || !Enum.IsDefined(parsed))
{
throw new ArgumentException($"Ongeldige waarde voor {field}.", field);
}
return parsed;
}
private static InventorySnapshotSummary ToSnapshot(InventorySnapshot snapshot) =>
new(snapshot.Id, snapshot.SourceKey, snapshot.SourceHash, snapshot.CapturedAt, snapshot.ImportedAt, snapshot.HostCount, snapshot.ServiceCount);
private static InfrastructureHostSummary ToHost(InfrastructureHost host) =>
new(host.Id, host.SourceKey, host.Hostname, host.OperatingSystem, host.KernelVersion, host.Architecture,
host.LogicalCpuCount, host.TotalMemoryBytes, host.Presence.ToString(), host.LastSeenAt);
private static InfrastructureServiceSummary ToSummary(InfrastructureServiceGraph graph) =>
ToSummary(
graph.Service, graph.EditorialProfile, graph.Components.Count, graph.Endpoints.Count,
graph.Volumes.Count, graph.Databases.Count);
private static InfrastructureServiceSummary ToSummary(InfrastructureServiceSummaryRow row) =>
ToSummary(row.Service, row.EditorialProfile, row.ProjectName, row.PrimaryEndpointUrl, row.ComponentCount, row.EndpointCount, row.VolumeCount, row.DatabaseCount);
private static InfrastructureServiceSummary ToSummary(
InfrastructureService service, EditorialProfile? profile,
int componentCount, int endpointCount, int volumeCount, int databaseCount) =>
ToSummary(service, profile, null, null, componentCount, endpointCount, volumeCount, databaseCount);
private static InfrastructureServiceSummary ToSummary(
InfrastructureService service, EditorialProfile? profile, string? projectName, string? primaryEndpointUrl,
int componentCount, int endpointCount, int volumeCount, int databaseCount) =>
new(
service.Id, service.SourceKey,
string.IsNullOrWhiteSpace(profile?.DisplayName) ? service.DetectedName : profile.DisplayName,
service.DetectedName, profile?.Category ?? string.Empty, profile?.Summary ?? string.Empty,
service.RuntimeKind, service.RuntimeStatus, service.Health.ToString(), service.Presence.ToString(),
service.Confidence.ToString(), service.ImageReference, profile?.ProjectId, projectName,
profile?.BackupStatus ?? string.Empty, primaryEndpointUrl, profile?.IsFavorite ?? false,
profile?.IsHidden ?? false, componentCount, endpointCount, volumeCount, databaseCount, service.LastSeenAt);
private static InfrastructureServiceDetail ToDetail(InfrastructureServiceGraph graph) =>
new(
ToSummary(graph), graph.EditorialProfile is null ? null : ToEditorial(graph.EditorialProfile, graph.EditorialETag),
graph.EditorialETag, graph.Overrides.Select(ToOverride).ToArray(),
graph.Components.Select(row => new ServiceComponentResponse(
row.Id, row.Name, row.Kind, row.RuntimeId, row.Status, row.ImageReference, Provenance(row))).ToArray(),
graph.Endpoints.Select(row => new InfrastructureEndpointResponse(
row.Id, row.Scheme, row.Host, row.PublishedPort, row.TargetPort, row.Url, row.IsPublic, Provenance(row))).ToArray(),
graph.Networks.Select(row => new InfrastructureNetworkResponse(
row.Id, row.Name, row.Driver, row.Scope, row.Internal, Provenance(row))).ToArray(),
graph.Volumes.Select(row => new InfrastructureVolumeResponse(
row.Id, row.Name, row.MountPath, row.StorageClass, row.ReadOnly, row.SizeBytes, Provenance(row))).ToArray(),
graph.Databases.Select(row => new InfrastructureDatabaseResponse(
row.Id, row.Name, row.Engine, row.Version, row.Host, row.Port, row.HostPortPublished, Provenance(row))).ToArray(),
graph.Dependencies.Select(row => new ServiceDependencyResponse(
row.Id, row.DependsOnServiceId, row.DependsOnSourceKey, row.Kind, Provenance(row))).ToArray(),
graph.HealthObservations.Select(row => new HealthObservationResponse(
row.Id, row.State.ToString(), row.CheckKind, row.ResponseTimeMilliseconds, Provenance(row))).ToArray(),
graph.Deployments.Select(row => new DeploymentReferenceResponse(
row.Id, row.RepositoryUrl, row.Commit, row.ImageReference, row.ImageDigest, row.DeployedAt, Provenance(row))).ToArray(),
graph.Backups.Select(row => new BackupCoverageResponse(
row.Id, row.Method, row.Schedule, row.LastSuccessfulAt, row.RestoreTested, Provenance(row))).ToArray());
private static EditorialProfileResponse ToEditorial(EditorialProfile profile, string etag) =>
new(profile.Id, profile.DisplayName, profile.Summary, profile.Category, profile.Purpose, profile.OwnerId, profile.ProjectId,
profile.RepositoryUrl, profile.LiveUrl, profile.BackupStatus, profile.MonitoringUrl, profile.MaintenanceNotes,
profile.KnownRisks, profile.UpdateProcedure, profile.RollbackProcedure, profile.Tags, profile.IsFavorite,
profile.IsHidden, profile.IsArchived, profile.UpdatedAt, etag);
private static ManualOverrideResponse ToOverride(ManualOverride manualOverride) =>
new(manualOverride.Id, manualOverride.FieldName, JsonSerializer.Deserialize<JsonElement>(manualOverride.ValueJson),
manualOverride.Reason, manualOverride.CreatedBy, manualOverride.UpdatedAt);
private static FactProvenance Provenance(ServiceFactEntity fact) =>
new(fact.SourceKey, fact.Confidence.ToString(), fact.LastSnapshotId, fact.ObservedAt);
private static IResult ToProblem(OperationStatus status, IReadOnlyList<string> errors) => status switch
{
OperationStatus.NotFound => Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound),
OperationStatus.ConcurrencyConflict => Results.Problem(
statusCode: StatusCodes.Status412PreconditionFailed, type: ProblemTypes.ConcurrencyConflict),
_ => Results.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation,
detail: string.Join(" ", errors))
};
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
CurrentActorId(principal) ?? throw new InvalidOperationException("Authenticated user has no valid subject identifier.");
private static Guid? CurrentActorId(ClaimsPrincipal principal) =>
Guid.TryParse(principal.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
private static string? CorrelationId(HttpContext context) =>
context.Items[CorrelationIdMiddleware.ItemKey] as string;
}
@@ -0,0 +1,318 @@
using System.Security.Claims;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Api.Knowledge;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Audit;
using ItWorx.KnowledgeHub.Application.Common;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Application.Ingestion;
using ItWorx.KnowledgeHub.Application.Ingestion.UseCases;
using ItWorx.KnowledgeHub.Contracts.Ingestion;
using ItWorx.KnowledgeHub.Domain.Identity;
using ItWorx.KnowledgeHub.Domain.Ingestion;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
public static class IngestionProposalsEndpoints
{
public static IEndpointRouteBuilder MapIngestionProposalsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/ingestion-proposals").WithTags("Ingestion proposals");
group.MapPost("", CreateAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>().AddEndpointFilter<IdempotencyEndpointFilter>();
group.MapPost("/{proposalId:guid}/sources", UploadSourceAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite)
.RequireRateLimiting(RateLimitPolicies.SourceUpload)
.AddEndpointFilter<AntiforgeryEndpointFilter>().AddEndpointFilter<IdempotencyEndpointFilter>().DisableAntiforgery();
group.MapPost("/{proposalId:guid}/submit", SubmitManualAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapGet("", ListAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview);
// Detail/PATCH are intentionally broader than the review queue for trusted
// intake submitters; private lifecycle states are still restricted to their
// submitter and workspace administrators.
group.MapGet("/{proposalId:guid}", GetAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite);
group.MapPatch("/{proposalId:guid}", PatchAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/{proposalId:guid}/request-changes", RequestChangesAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/{proposalId:guid}/approve", ApproveAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/{proposalId:guid}/reject", RejectAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/{proposalId:guid}/retry", RetryAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>().AddEndpointFilter<IdempotencyEndpointFilter>();
group.MapPost("/{proposalId:guid}/publish", PublishAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview)
.AddEndpointFilter<AntiforgeryEndpointFilter>().AddEndpointFilter<IdempotencyEndpointFilter>();
return app;
}
private static async Task<IResult> CreateAsync([FromBody] CreateIngestionProposalRequest request, IWorkspaceRepository workspaces,
CreateIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IngestionMetrics metrics, IOptions<IngestionProposalOptions> options,
ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
{
if (!options.Value.Enabled) return Disabled();
if (string.IsNullOrWhiteSpace(context.Request.Headers["Idempotency-Key"].ToString()))
return Results.Problem(statusCode: 400, detail: "Idempotency-Key-header is vereist.");
var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct);
if (workspace is null) return NotFound();
IngestionPublication? publication = null;
if (request.Publication is not null)
{
if (!Enum.TryParse<IngestionMutationKind>(request.Publication.MutationKind, true, out var mutationKind))
return Results.Problem(statusCode: 422, detail: "Ongeldige publicatiemutatie.");
try
{
publication = IngestionPublication.Create(mutationKind, request.Publication.Title, request.Publication.Markdown,
request.Publication.DocumentType, request.Publication.DocumentStatus, request.Publication.ParentPageId,
request.Publication.TargetPageId, request.Publication.ExpectedTargetETag);
}
catch (DomainException ex)
{
return Results.Problem(statusCode: 422, detail: ex.Message);
}
}
var result = await useCase.ExecuteAsync(new(workspace.Value, Actor(principal), request.Manifest,
request.CorrelationId ?? Correlation(context), context.Request.Headers["Idempotency-Key"].ToString(), publication), ct);
metrics.RecordProposal(result.Status == OperationStatus.Success ? "success" : "failure",
result.Status == OperationStatus.Success ? "none" : "validation");
if (result.Status != OperationStatus.Success) return Results.Problem(statusCode: 422, detail: string.Join(' ', result.Errors));
var detail = await DetailAsync(proposals, workspace.Value, result.Value!, principal, ct);
context.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail!.ETag);
return Results.Created($"/api/v1/ingestion-proposals/{detail.Id}", detail);
}
private static async Task<IResult> ListAsync(IWorkspaceRepository workspaces, IWorkspaceMembershipRepository memberships,
IIngestionProposalRepository proposals,
IOptions<IngestionProposalOptions> options, HttpContext context, [FromQuery] string? status, [FromQuery] Guid? cursor,
[FromQuery] int? limit, [FromQuery] int? page, [FromQuery] int? pageSize, [FromQuery] Guid? projectId,
[FromQuery] Guid? submitterId, [FromQuery] int? minAgeDays, [FromQuery] string? risk, [FromQuery] string? documentType, CancellationToken ct)
{
if (!options.Value.Enabled) return Disabled();
var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct);
if (workspace is null) return Results.Ok(new IngestionProposalListResponse([], null, 1, 50, 0, Correlation(context)));
if (!TryStatus(status, out var parsed)) return Results.Problem(statusCode: 400, detail: "Ongeldige statusfilter.");
if (!TryRisk(risk, out var parsedRisk)) return Results.Problem(statusCode: 400, detail: "Ongeldige risicofilter.");
var actorId = Actor(context.User);
Guid? privateStatusSubmitterId = await memberships.GetRoleAsync(workspace.Value, actorId, ct) == WorkspaceRole.Administrator
? null
: actorId;
var queuePage = await proposals.ListAsync(new(
workspace.Value,
parsed,
cursor,
limit ?? 50,
Math.Clamp(page ?? 1, 1, 10_000),
pageSize is null ? null : Math.Clamp(pageSize.Value, 1, 100),
projectId,
submitterId,
minAgeDays is null ? null : Math.Clamp(minAgeDays.Value, 0, 3650),
parsedRisk,
string.IsNullOrWhiteSpace(documentType) ? null : documentType.Trim(),
privateStatusSubmitterId), ct);
var items = queuePage.Items.Select(ToSummary).ToArray();
return Results.Ok(new IngestionProposalListResponse(items, queuePage.NextCursor, queuePage.Page, queuePage.PageSize, queuePage.Total, Correlation(context)));
}
private static async Task<IResult> GetAsync(Guid proposalId, IWorkspaceRepository workspaces, IWorkspaceMembershipRepository memberships,
IIngestionProposalRepository proposals,
IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
{
if (!options.Value.Enabled) return Disabled();
var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct);
if (workspace is null) return NotFound();
var proposal = await proposals.FindReadOnlyAsync(workspace.Value, proposalId, ct);
if (proposal is null || (IsServiceAccount(principal) && proposal.IntegrationActorId != Actor(principal))) return NotFound();
if (!IsServiceAccount(principal) && IsSubmitterPrivateStatus(proposal.Status)
&& !await IsSubmitterOrAdministratorAsync(proposal, workspace.Value, principal, memberships, ct)) return NotFound();
var detail = ToDetail(proposal, await proposals.GetETagAsync(workspace.Value, proposalId, ct) ?? string.Empty, Permissions(principal));
context.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail.ETag);
return Results.Ok(detail);
}
private static async Task<IResult> PatchAsync(Guid proposalId, [FromBody] PatchIngestionProposalRequest request, IWorkspaceRepository workspaces,
PatchIngestionProposalUseCase useCase, ApplyIngestionProposalAnalysisUseCase analysisUseCase,
IWorkspaceMembershipRepository memberships, IIngestionProposalRepository proposals, IOptions<IngestionProposalOptions> options,
ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
{
if (!options.Value.Enabled) return Disabled();
var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound();
var proposal = await proposals.FindReadOnlyAsync(workspace.Value, proposalId, ct);
if (proposal is null || (IsServiceAccount(principal) && proposal.IntegrationActorId != Actor(principal))) return NotFound();
if (!IsServiceAccount(principal) && IsSubmitterPrivateStatus(proposal.Status)
&& !await IsSubmitterOrAdministratorAsync(proposal, workspace.Value, principal, memberships, ct)) return NotFound();
if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null)
return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist.");
OperationResult result;
if (IsServiceAccount(principal))
{
if (request.Manifest is not null || request.Rationale is not null)
return Results.Problem(statusCode: 403, detail: "Serviceaccounts mogen alleen analyse vastleggen.");
result = await analysisUseCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), ETag(context), request.Analysis), ct);
}
else
{
if (request.Analysis is not null)
return Results.Problem(statusCode: 403, detail: "Analyse is voorbehouden aan het indienende serviceaccount.");
result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), ETag(context), request.Manifest, request.Rationale), ct);
}
return await ToMutationResult(result, proposals, workspace.Value, proposalId, context, ct);
}
private static Task<IResult> RequestChangesAsync(Guid proposalId, [FromBody] IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces,
DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) =>
DecideAsync(IngestionDecision.RequestChanges, proposalId, request, workspaces, useCase, proposals, options, principal, context, ct);
private static Task<IResult> ApproveAsync(Guid proposalId, [FromBody] IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces,
DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) =>
DecideAsync(IngestionDecision.Approve, proposalId, request, workspaces, useCase, proposals, options, principal, context, ct);
private static Task<IResult> RejectAsync(Guid proposalId, [FromBody] IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces,
DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) =>
DecideAsync(IngestionDecision.Reject, proposalId, request, workspaces, useCase, proposals, options, principal, context, ct);
private static async Task<IResult> DecideAsync(IngestionDecision decision, Guid proposalId, IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces,
DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
{
if (!options.Value.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound();
if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null)
return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist.");
var started = System.Diagnostics.Stopwatch.GetTimestamp();
var result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), ETag(context), decision, request.Comment), ct);
context.RequestServices.GetRequiredService<IngestionMetrics>().RecordReviewDuration(result.Status == OperationStatus.Success ? "success" : "failure",
System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalSeconds);
return await ToMutationResult(result, proposals, workspace.Value, proposalId, context, ct);
}
private static async Task<IResult> RetryAsync(Guid proposalId, IWorkspaceRepository workspaces, RetryIngestionProposalUseCase useCase,
IIngestionProposalRepository proposals, IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
{
if (!options.Value.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound();
if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null)
return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist.");
var result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), ETag(context)), ct);
return await ToMutationResult(result, proposals, workspace.Value, proposalId, context, ct);
}
private static async Task<IResult> PublishAsync(Guid proposalId, IWorkspaceRepository workspaces,
PublishIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IngestionMetrics metrics,
IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
{
if (!options.Value.Enabled) return Disabled();
if (IsServiceAccount(principal)) return Results.Problem(statusCode: 403, detail: "Serviceaccounts mogen geen voorstellen publiceren.");
var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound();
if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null)
return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist.");
var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrWhiteSpace(idempotencyKey))
return Results.Problem(statusCode: 400, detail: "Idempotency-Key-header is vereist.");
var started = System.Diagnostics.Stopwatch.GetTimestamp();
var result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, ETag(context), idempotencyKey,
Actor(principal), Correlation(context)), ct);
metrics.RecordStageDuration("publication", result.Status == IngestionProposalResultStatus.Success ? "success" : "failure",
System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalSeconds);
metrics.RecordPublication(result.Status == IngestionProposalResultStatus.Success ? "success" : "failure",
result.Status == IngestionProposalResultStatus.Success ? "none" : "rejected");
if (result.Status != IngestionProposalResultStatus.Success) return result.Status switch
{
IngestionProposalResultStatus.NotFound => NotFound(),
IngestionProposalResultStatus.ConcurrencyConflict => Results.Problem(statusCode: 412, type: IngestionProblemTypes.ConcurrencyConflict, title: "Versieconflict."),
IngestionProposalResultStatus.InvalidTransition => Results.Problem(statusCode: 409, type: IngestionProblemTypes.InvalidTransition, detail: string.Join(' ', result.Errors ?? [])),
IngestionProposalResultStatus.StaleTarget => Results.Problem(statusCode: 409, type: IngestionProblemTypes.StaleTarget,
title: "Publicatiedoel is gewijzigd.", detail: "Het voorstel is teruggezet naar de reviewwachtrij."),
_ => Results.Problem(statusCode: 422, detail: string.Join(' ', result.Errors ?? []))
};
var detail = await DetailAsync(proposals, workspace.Value, proposalId, principal, ct);
if (detail is null) return NotFound();
context.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail.ETag);
return Results.Ok(detail);
}
private static async Task<IResult> UploadSourceAsync(Guid proposalId, IFormFile file, IWorkspaceRepository workspaces,
StageIngestionProposalSourceUseCase useCase, IIngestionProposalRepository proposals, IngestionMetrics metrics,
IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
{
if (!options.Value.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound();
if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null)
return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist.");
if (file.Length > options.Value.MaximumSourceBytes) return Results.Problem(statusCode: 413, detail: "Bron is groter dan de toegestane limiet.");
await using var source = file.OpenReadStream();
var started = System.Diagnostics.Stopwatch.GetTimestamp();
var result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), file.FileName, file.ContentType,
source, Correlation(context), options.Value.MaximumSourceCount, ETag(context), ActorKind(principal), AuditSource(principal)), ct);
metrics.RecordStageDuration("source_staging", result.Status == OperationStatus.Success ? "success" : "failure",
System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalSeconds);
if (result.Status != OperationStatus.Success) return await ToMutationResult(result, proposals, workspace.Value, proposalId, context, ct);
var detail = await DetailAsync(proposals, workspace.Value, proposalId, principal, ct);
if (detail is null) return NotFound();
context.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail.ETag);
return Results.Ok(detail);
}
private static async Task<IResult> SubmitManualAsync(Guid proposalId, IWorkspaceRepository workspaces,
SubmitManualIngestionProposalUseCase useCase, IIngestionProposalRepository proposals,
IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
{
if (!options.Value.Enabled) return Disabled();
if (IsServiceAccount(principal)) return Results.Forbid();
var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct);
if (workspace is null) return NotFound();
if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null)
return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist.");
var result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), ETag(context)), ct);
return await ToMutationResult(result, proposals, workspace.Value, proposalId, context, ct);
}
private static async Task<IResult> ToMutationResult(OperationResult result, IIngestionProposalRepository proposals, Guid workspaceId, Guid proposalId, HttpContext context, CancellationToken ct)
{
if (result.Status != OperationStatus.Success) return result.Status switch
{
OperationStatus.NotFound => NotFound(),
OperationStatus.ConcurrencyConflict => Results.Problem(statusCode: 412, type: IngestionProblemTypes.ConcurrencyConflict, title: "Versieconflict."),
OperationStatus.InvalidTransition => Results.Problem(statusCode: 409, type: IngestionProblemTypes.InvalidTransition, detail: string.Join(' ', result.Errors)),
_ => Validation(result)
};
var detail = await DetailAsync(proposals, workspaceId, proposalId, context.User, ct);
if (detail is null) return NotFound();
context.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail.ETag);
return TypedResults.Ok(detail);
}
private static async Task<IngestionProposalDetail?> DetailAsync(IIngestionProposalRepository proposals, Guid workspaceId, Guid id, ClaimsPrincipal principal, CancellationToken ct)
{ var proposal = await proposals.FindAsync(workspaceId, id, ct); return proposal is null ? null : ToDetail(proposal, await proposals.GetETagAsync(workspaceId, id, ct) ?? string.Empty, Permissions(principal)); }
private static IngestionProposalSummary ToSummary(IngestionProposal p) => new(p.Id, p.CorrelationId, p.Status.ToString(), p.RevisionNumber, p.Sources.Count,
p.SubmittedByUserId, CreatedAt(p), Destination(p), Metadata(p), p.Publication?.MutationKind.ToString());
private static IngestionProposalDetail ToDetail(IngestionProposal p, string etag, IngestionProposalPermissions permissions) => new(p.Id, p.CorrelationId, p.Status.ToString(), p.RevisionNumber,
p.Manifest, p.Analysis, p.ReviewerComment, p.RetryCount, p.MaxPublishRetries, p.IsRetryEligible,
p.FailedLifecycleStatus?.ToString(), p.FailedProcessingStage?.ToString(), p.FailureErrorType, p.FailureMessage,
p.Sources.Select(s => new IngestionProposalSourceDetail(s.Id, s.FileName, s.MimeType, s.ByteSize, s.DigestSha256, s.ScanResult, s.AttachmentReferenceId)).ToArray(),
p.Revisions.Select(r => new IngestionProposalRevisionDetail(r.RevisionNumber, r.Status.ToString(), r.Operation, r.ActorId, r.OccurredAt)).ToArray(), etag,
p.PublishedPageId, p.PublishedPageRevisionNumber, p.SubmittedByUserId, CreatedAt(p), Destination(p), Metadata(p), p.Publication?.MutationKind.ToString(),
p.Sources.Select(s => new IngestionProposalSourceDetail(s.Id, s.FileName, s.MimeType, s.ByteSize, s.DigestSha256, s.ScanResult, s.AttachmentReferenceId)).ToArray(),
p.Publication?.ExpectedTargetETag, [], [], [], [],
p.Publication is null ? null : new IngestionProposalDiff(null, p.Publication.Markdown, null), permissions);
private static DateTimeOffset CreatedAt(IngestionProposal proposal) => proposal.Revisions.OrderBy(revision => revision.RevisionNumber).Select(revision => revision.OccurredAt).First();
private static IngestionProposalDestination? Destination(IngestionProposal proposal) => proposal.Publication is null ? null : new(proposal.Publication.TargetPageId, proposal.Publication.ParentPageId, proposal.Publication.Title);
private static IngestionProposalMetadata Metadata(IngestionProposal proposal) => new(proposal.Publication?.DocumentType, proposal.Publication?.DocumentStatus, null, null);
private static IngestionProposalPermissions Permissions(ClaimsPrincipal principal) => IsServiceAccount(principal) ? new(false, false, false) : new(true, true, true);
private static bool IsSubmitterPrivateStatus(IngestionProposalStatus status) =>
status is IngestionProposalStatus.Received or IngestionProposalStatus.ChangesRequested;
private static async Task<bool> IsSubmitterOrAdministratorAsync(IngestionProposal proposal, Guid workspaceId,
ClaimsPrincipal principal, IWorkspaceMembershipRepository memberships, CancellationToken cancellationToken)
{
var actorId = Actor(principal);
if (proposal.SubmittedByUserId == actorId) return true;
return await memberships.GetRoleAsync(workspaceId, actorId, cancellationToken) == WorkspaceRole.Administrator;
}
private static IResult Disabled() => Results.Problem(statusCode: 404, type: IngestionProblemTypes.FeatureDisabled);
private static IResult NotFound() => Results.Problem(statusCode: 404, type: IngestionProblemTypes.NotFound);
private static IResult Validation(OperationResult result) => Results.Problem(statusCode: 422, detail: string.Join(' ', result.Errors));
private static bool TryStatus(string? raw, out IngestionProposalStatus? status) { status = null; if (string.IsNullOrWhiteSpace(raw)) return true; if (!Enum.TryParse<IngestionProposalStatus>(raw, true, out var value)) return false; status = value; return true; }
private static bool TryRisk(string? raw, out string? risk) { risk = null; if (string.IsNullOrWhiteSpace(raw)) return true; if (raw is "low" or "medium" or "high") { risk = raw; return true; } return false; }
private static Guid Actor(ClaimsPrincipal principal) => Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
private static bool IsServiceAccount(ClaimsPrincipal principal) => principal.Identity?.AuthenticationType == ServiceTokenAuthenticationDefaults.Scheme;
private static AuditActorKind ActorKind(ClaimsPrincipal principal) => IsServiceAccount(principal) ? AuditActorKind.ServiceAccount : AuditActorKind.User;
private static string AuditSource(ClaimsPrincipal principal) => IsServiceAccount(principal) ? "api" : "web";
private static string ETag(HttpContext context) => ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) ?? string.Empty;
private static string Correlation(HttpContext context) => context.Items[CorrelationIdMiddleware.ItemKey] as string ?? "ingestion";
}
@@ -0,0 +1,582 @@
using System.Security.Claims;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Api.Knowledge;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Application.Knowledge;
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Domain.Identity;
using ItWorx.KnowledgeHub.Domain.Knowledge;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
public static class PagesEndpoints
{
public static IEndpointRouteBuilder MapPagesEndpoints(this IEndpointRouteBuilder app)
{
// No group-level blanket policy: GetPageAsync/CreatePageAsync/UpdatePageAsync
// below accept either a cookie session or a scoped service-account bearer
// token (docs/09-api-contract.md, M6), which requires each of those routes to
// carry its own explicit authorization metadata rather than inheriting one
// fixed, cookie-only group default — stacking a cookie-only group policy on
// top of a dual-scheme route policy would silently block every bearer-only
// caller (ADR-015). Every other route below keeps exactly its prior
// cookie-only behavior, now stated explicitly per route instead of inherited.
var group = app.MapGroup("/api/v1/pages").WithTags("Pages");
group.MapGet("/roots", GetRootsAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
group.MapGet("/{pageId:guid}", GetPageAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
group.MapGet("/{pageId:guid}/children", GetChildrenAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
group.MapGet("/{pageId:guid}/revisions", GetRevisionsAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
group.MapGet("/{pageId:guid}/revisions/{revisionNumber:long}", GetRevisionAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
group.MapGet("/{pageId:guid}/backlinks", GetBacklinksAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
group.MapPost("/preview", PreviewMarkdownAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
group.MapPost("", CreatePageAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.AddEndpointFilter<IdempotencyEndpointFilter>();
// Staged creation (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md): the
// atomic alternative to CreatePageAsync when attachments must be inserted
// before the page is ever visible to a reader. Idempotency-Key is honored on
// both begin and commit so a lost response never risks a duplicate session or
// a duplicate page on client retry.
group.MapPost("/staged", BeginPageCreationSessionAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.AddEndpointFilter<IdempotencyEndpointFilter>();
group.MapPost("/staged/{sessionId:guid}/commit", CommitPageCreationSessionAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.AddEndpointFilter<IdempotencyEndpointFilter>();
group.MapPost("/staged/{sessionId:guid}/discard", DiscardPageCreationSessionAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.AddEndpointFilter<IdempotencyEndpointFilter>();
group.MapPut("/{pageId:guid}", UpdatePageAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.AddEndpointFilter<IdempotencyEndpointFilter>();
group.MapPost("/{pageId:guid}/move", MovePageAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.AddEndpointFilter<IdempotencyEndpointFilter>();
group.MapPost("/{pageId:guid}/trash", TrashPageAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/{pageId:guid}/restore", RestorePageAsync)
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/{pageId:guid}/revisions/{revisionNumber:long}/restore", RestoreRevisionAsync)
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
return app;
}
private static async Task<Ok<PreviewMarkdownResponse>> PreviewMarkdownAsync(
[FromBody] PreviewMarkdownRequest request,
IWorkspaceRepository workspaces,
LinkResolutionService links,
IMarkdownRenderer renderer,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Ok(new PreviewMarkdownResponse(renderer.RenderToSafeHtml(request.Markdown ?? string.Empty, new Dictionary<string, ResolvedLinkTarget>())));
}
var resolution = await links.ResolveAsync(
workspaceId.Value, Guid.NewGuid(), 0, request.Markdown ?? string.Empty, cancellationToken);
return TypedResults.Ok(new PreviewMarkdownResponse(
renderer.RenderToSafeHtml(request.Markdown ?? string.Empty, resolution.ResolvedTargets)));
}
private static async Task<Results<Ok<IReadOnlyList<PageRevisionSummary>>, ProblemHttpResult>> GetRevisionsAsync(
Guid pageId,
GetPageRevisionsUseCase useCase,
CancellationToken cancellationToken)
{
var revisions = await useCase.ExecuteAsync(pageId, cancellationToken);
if (revisions is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.PageNotFound);
}
IReadOnlyList<PageRevisionSummary> response = revisions
.Select(revision => new PageRevisionSummary(
revision.RevisionNumber,
revision.Title,
revision.DocumentType.ToString(),
revision.DocumentStatus.ToString(),
revision.AuthoredBy,
revision.ChangeSummary,
revision.CreatedAt))
.ToList();
return TypedResults.Ok(response);
}
private static async Task<Results<Ok<PageRevisionDetailResponse>, ProblemHttpResult>> GetRevisionAsync(
Guid pageId,
long revisionNumber,
GetPageRevisionUseCase useCase,
CancellationToken cancellationToken)
{
var result = await useCase.ExecuteAsync(pageId, revisionNumber, cancellationToken);
if (result is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
return TypedResults.Ok(new PageRevisionDetailResponse(
result.Revision.RevisionNumber,
result.Revision.Title,
result.Revision.Markdown,
result.RenderedHtml,
result.Revision.DocumentType.ToString(),
result.Revision.DocumentStatus.ToString(),
result.Revision.AuthoredBy,
result.Revision.ChangeSummary,
result.Revision.CreatedAt));
}
private static async Task<Results<Ok<PageDetail>, ProblemHttpResult>> RestoreRevisionAsync(
Guid pageId,
long revisionNumber,
RestorePageRevisionUseCase useCase,
GetPageUseCase getUseCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
if (ifMatch is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new RestorePageRevisionCommand(pageId, revisionNumber, ifMatch, CurrentUserId(principal), correlationId),
cancellationToken);
if (result.Status != PageOperationStatus.Success)
{
return ToProblem<PageDetail>(result.Status, result.Errors);
}
var detail = await getUseCase.ExecuteAsync(pageId, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
return TypedResults.Ok(ToPageDetail(detail!));
}
private static async Task<Results<Ok<BacklinksResponse>, ProblemHttpResult>> GetBacklinksAsync(
Guid pageId,
GetPageBacklinksUseCase useCase,
CancellationToken cancellationToken)
{
var sources = await useCase.ExecuteAsync(pageId, cancellationToken);
if (sources is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.PageNotFound);
}
IReadOnlyList<BacklinkEntry> response = sources
.Select(page => new BacklinkEntry(page.Id, page.TitleValue, page.SlugValue, page.DocumentType.ToString()))
.ToList();
return TypedResults.Ok(new BacklinksResponse(response));
}
private static async Task<Results<Ok<PageChildrenResponse>, ProblemHttpResult>> GetRootsAsync(
IWorkspaceRepository workspaces,
GetPageChildrenUseCase useCase,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var result = await useCase.ExecuteAsync(workspaceId.Value, null, cursor, limit ?? 50, cancellationToken);
return TypedResults.Ok(ToChildrenResponse(result));
}
private static async Task<Results<Ok<PageDetail>, ProblemHttpResult>> GetPageAsync(
Guid pageId,
GetPageUseCase useCase,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var result = await useCase.ExecuteAsync(pageId, cancellationToken);
if (result is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.PageNotFound);
}
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag);
return TypedResults.Ok(ToPageDetail(result));
}
private static async Task<Results<Ok<PageChildrenResponse>, ProblemHttpResult>> GetChildrenAsync(
Guid pageId,
IWorkspaceRepository workspaces,
GetPageChildrenUseCase useCase,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var result = await useCase.ExecuteAsync(workspaceId.Value, pageId, cursor, limit ?? 50, cancellationToken);
return TypedResults.Ok(ToChildrenResponse(result));
}
private static async Task<Results<Ok<PageDetail>, ProblemHttpResult>> CreatePageAsync(
[FromBody] CreatePageRequest request,
IWorkspaceRepository workspaces,
CreatePageUseCase createUseCase,
GetPageUseCase getUseCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await createUseCase.ExecuteAsync(
new CreatePageCommand(
workspaceId.Value,
request.ParentPageId,
request.Title,
request.DocumentType,
request.Markdown,
request.TemplateKey,
request.Tags,
CurrentUserId(principal),
correlationId),
cancellationToken);
if (result.Status != PageOperationStatus.Success)
{
return ToProblem<PageDetail>(result.Status, result.Errors);
}
var detail = await getUseCase.ExecuteAsync(result.PageId!.Value, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
return TypedResults.Ok(ToPageDetail(detail!));
}
private static async Task<Results<Ok<PageCreationSessionResponse>, ProblemHttpResult>> BeginPageCreationSessionAsync(
[FromBody] BeginPageCreationSessionRequest request,
IWorkspaceRepository workspaces,
BeginPageCreationSessionUseCase useCase,
ClaimsPrincipal principal,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var result = await useCase.ExecuteAsync(
new BeginPageCreationSessionCommand(workspaceId.Value, request.ParentPageId, CurrentUserId(principal)),
cancellationToken);
if (result.Status != BeginPageCreationSessionStatus.Success)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
type: ProblemTypes.Validation,
detail: "Bovenliggende pagina bestaat niet.");
}
return TypedResults.Ok(new PageCreationSessionResponse(result.SessionId!.Value, result.ExpiresAt!.Value));
}
private static async Task<Results<Ok<PageDetail>, ProblemHttpResult>> CommitPageCreationSessionAsync(
Guid sessionId,
[FromBody] CommitPageCreationSessionRequest request,
IWorkspaceRepository workspaces,
CommitPageCreationSessionUseCase commitUseCase,
GetPageUseCase getUseCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await commitUseCase.ExecuteAsync(
new CommitPageCreationSessionCommand(
workspaceId.Value, sessionId, request.Title, request.DocumentType, request.Markdown, request.Tags,
CurrentUserId(principal), correlationId),
cancellationToken);
if (result.Status != PageOperationStatus.Success)
{
if (result.Status == PageOperationStatus.NotFound)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.StagingSessionNotFound);
}
if (result.Status == PageOperationStatus.ConcurrencyConflict)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict,
type: ProblemTypes.StagingSessionClosed,
title: "Sessie niet meer open.",
detail: string.Join(" ", result.Errors));
}
return ToProblem<PageDetail>(result.Status, result.Errors);
}
var detail = await getUseCase.ExecuteAsync(result.PageId!.Value, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
return TypedResults.Ok(ToPageDetail(detail!));
}
private static async Task<Results<Ok<DiscardPageCreationSessionResponse>, ProblemHttpResult>> DiscardPageCreationSessionAsync(
Guid sessionId,
IWorkspaceRepository workspaces,
DiscardPageCreationSessionUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(workspaceId.Value, sessionId, CurrentUserId(principal), correlationId, cancellationToken);
return result.Status switch
{
DiscardPageCreationSessionStatus.Success => TypedResults.Ok(new DiscardPageCreationSessionResponse(result.AttachmentsRemoved)),
DiscardPageCreationSessionStatus.NotFound => TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.StagingSessionNotFound),
_ => TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict,
type: ProblemTypes.StagingSessionClosed,
title: "Sessie is al gepubliceerd en kan niet meer worden verwijderd.")
};
}
private static async Task<Results<Ok<PageDetail>, ProblemHttpResult>> UpdatePageAsync(
Guid pageId,
[FromBody] UpdatePageRequest request,
UpdatePageUseCase updateUseCase,
GetPageUseCase getUseCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
if (ifMatch is null)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status428PreconditionRequired,
type: ProblemTypes.Validation,
title: "If-Match vereist.",
detail: "Deze bewerking vereist een If-Match-header met de huidige ETag.");
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await updateUseCase.ExecuteAsync(
new UpdatePageCommand(
pageId,
request.Title,
request.Markdown,
request.DocumentType,
request.DocumentStatus,
request.ChangeSummary,
request.Tags,
ifMatch,
CurrentUserId(principal),
correlationId),
cancellationToken);
if (result.Status != PageOperationStatus.Success)
{
return ToProblem<PageDetail>(result.Status, result.Errors);
}
var detail = await getUseCase.ExecuteAsync(pageId, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
return TypedResults.Ok(ToPageDetail(detail!));
}
private static async Task<Results<Ok<PageDetail>, ProblemHttpResult>> MovePageAsync(
Guid pageId,
[FromBody] MovePageRequest request,
MovePageUseCase moveUseCase,
GetPageUseCase getUseCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
if (ifMatch is null)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status428PreconditionRequired,
type: ProblemTypes.Validation,
title: "If-Match vereist.");
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await moveUseCase.ExecuteAsync(
new MovePageCommand(pageId, request.NewParentPageId, request.AfterSiblingId, ifMatch, CurrentUserId(principal), correlationId),
cancellationToken);
if (result.Status != PageOperationStatus.Success)
{
return ToProblem<PageDetail>(result.Status, result.Errors);
}
var detail = await getUseCase.ExecuteAsync(pageId, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
return TypedResults.Ok(ToPageDetail(detail!));
}
private static async Task<Results<Ok, ProblemHttpResult>> TrashPageAsync(
Guid pageId,
TrashPageUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
if (ifMatch is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new TrashPageCommand(pageId, ifMatch, CurrentUserId(principal), correlationId), cancellationToken);
return result.Status == PageOperationStatus.Success
? TypedResults.Ok()
: ToProblem<Ok>(result.Status, result.Errors);
}
private static async Task<Results<Ok, ProblemHttpResult>> RestorePageAsync(
Guid pageId,
RestorePageUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
if (ifMatch is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new RestorePageCommand(pageId, ifMatch, CurrentUserId(principal), correlationId), cancellationToken);
return result.Status == PageOperationStatus.Success
? TypedResults.Ok()
: ToProblem<Ok>(result.Status, result.Errors);
}
private static ProblemHttpResult ToProblem<T>(PageOperationStatus status, IReadOnlyList<string> errors) =>
status switch
{
PageOperationStatus.NotFound => TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.PageNotFound, title: "Pagina niet gevonden."),
PageOperationStatus.Trashed => TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict, type: ProblemTypes.PageTrashed, title: "Pagina staat in de prullenbak."),
PageOperationStatus.ConcurrencyConflict => TypedResults.Problem(
statusCode: StatusCodes.Status412PreconditionFailed,
type: ProblemTypes.ConcurrencyConflict,
title: "Versieconflict.",
detail: "Een andere gebruiker heeft deze pagina intussen gewijzigd."),
PageOperationStatus.Cycle => TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict, type: ProblemTypes.PageCycle, title: "Ongeldige verplaatsing.", detail: string.Join(" ", errors)),
_ => TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
type: ProblemTypes.Validation,
title: "Ongeldige aanvraag.",
detail: string.Join(" ", errors))
};
private static PageChildrenResponse ToChildrenResponse(PageChildrenResult result) =>
new(
result.Items.Select(item => ToPageSummary(item.Page, item.HasChildren)).ToList(),
result.NextCursor);
private static PageSummary ToPageSummary(Page page, bool hasChildren) =>
new(
page.Id,
page.ParentPageId,
page.TitleValue,
page.SlugValue,
page.DocumentType.ToString(),
page.DocumentStatus.ToString(),
hasChildren,
page.UpdatedAt);
private static PageDetail ToPageDetail(PageDetailResult result) =>
new(
result.Page.Id,
result.Page.WorkspaceId,
result.Page.ParentPageId,
result.Page.TitleValue,
result.Page.SlugValue,
result.Page.Markdown,
result.RenderedHtml,
result.Page.DocumentType.ToString(),
result.Page.DocumentStatus.ToString(),
result.Page.CurrentRevisionNumber,
result.Page.Tags,
result.Breadcrumb.Select(page => new BreadcrumbEntry(page.Id, page.TitleValue)).ToList(),
result.Page.CreatedBy,
result.Page.UpdatedBy,
result.Page.CreatedAt,
result.Page.UpdatedAt,
result.Page.PublishedAt,
result.Page.TrashedAt,
result.ETag);
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
}
@@ -0,0 +1,548 @@
using System.Security.Claims;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Api.Knowledge;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Attachments.UseCases;
using ItWorx.KnowledgeHub.Application.Common;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Application.Projects;
using ItWorx.KnowledgeHub.Application.Projects.UseCases;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Projects;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Domain.Identity;
using ItWorx.KnowledgeHub.Domain.Projects;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
public static class ProjectsEndpoints
{
public static IEndpointRouteBuilder MapProjectsEndpoints(this IEndpointRouteBuilder app)
{
// See PagesEndpoints.cs's identical comment: no group-level blanket policy
// because some routes below accept either a cookie session or a scoped
// service-account bearer token (ADR-015).
var group = app.MapGroup("/api/v1/projects").WithTags("Projects");
group.MapGet("", ListAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.ProjectsRead);
group.MapGet("/totals", GetTotalsAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.ProjectsRead);
group.MapGet("/{projectId:guid}", GetAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.ProjectsRead);
group.MapGet("/{projectId:guid}/context", GetContextAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.ProjectsRead);
group.MapGet("/{projectId:guid}/updates", GetUpdatesAsync).RequireAuthorization(AuthorizationPolicies.RequireReader);
group.MapGet("/{projectId:guid}/milestones", GetMilestonesAsync).RequireAuthorization(AuthorizationPolicies.RequireReader);
// Unlike every other content-creation/update endpoint in this API (Pages,
// Decisions, Runbooks all use RequireContentAccess + IdempotencyEndpointFilter
// for create, and Pages' own UpdatePageAsync uses the same for update), Create
// and Update here were both still cookie-only — meaning the documented,
// selectable `projects:write` scope (docs/09-api-contract.md, docs/guides/
// service-accounts-en-automatisering.md) was entirely unusable by any service
// account, for either action. Found while trying to publish real project
// dossiers via a scoped automation token (docs/v2/server-enrichment/): Create
// alone was not enough, since CreateProjectRequest only carries the four core
// fields (name/summary/priority/currentStateMarkdown) — repository/live URLs,
// dates and technologies only exist on UpdateProjectRequest. Fixed both to
// match the established pattern exactly. Secondary lifecycle endpoints below
// (status/documents/milestones) deliberately keep the narrower cookie-only
// policy for now, matching DecisionsEndpoints' own accept/reject/withdraw —
// not touched by this fix, no proven need yet.
group.MapPost("", CreateAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.ProjectsWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.AddEndpointFilter<IdempotencyEndpointFilter>();
group.MapPut("/{projectId:guid}", UpdateAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.ProjectsWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.AddEndpointFilter<IdempotencyEndpointFilter>();
group.MapPost("/{projectId:guid}/icon", SetIconAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.ProjectsWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.DisableAntiforgery()
.WithMetadata(new RequestSizeLimitAttribute(SetProjectIconUseCase.MaximumIconBytes + 64 * 1024));
group.MapPost("/{projectId:guid}/status", ChangeStatusAsync)
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/{projectId:guid}/documents", LinkDocumentAsync)
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapDelete("/documents/{documentId:guid}", UnlinkDocumentAsync)
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/{projectId:guid}/updates", CreateUpdateAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.ProjectUpdatesAppend)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.AddEndpointFilter<IdempotencyEndpointFilter>();
group.MapPost("/{projectId:guid}/milestones", CreateMilestoneAsync)
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPut("/milestones/{milestoneId:guid}", UpdateMilestoneAsync)
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
return app;
}
private static async Task<Ok<ProjectListResponse>> ListAsync(
IWorkspaceRepository workspaces,
ListProjectsUseCase useCase,
[FromQuery] string? status,
[FromQuery] string? priority,
[FromQuery] Guid? ownerId,
[FromQuery] string? search,
[FromQuery] DateTimeOffset? updatedAfter,
[FromQuery] string? sort,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Ok(new ProjectListResponse([], null, 0));
}
var result = await useCase.ExecuteAsync(
workspaceId.Value, status, priority, ownerId, search, updatedAfter, sort, cursor, limit ?? 50, cancellationToken);
return TypedResults.Ok(new ProjectListResponse(
result.Items.Select(ToProjectSummary).ToList(), result.NextCursor, result.TotalCount));
}
private static async Task<Ok<ProjectCatalogTotalsResponse>> GetTotalsAsync(
IWorkspaceRepository workspaces,
GetProjectCatalogTotalsUseCase useCase,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Ok(new ProjectCatalogTotalsResponse(0, 0));
}
var totals = await useCase.ExecuteAsync(workspaceId.Value, cancellationToken);
return TypedResults.Ok(new ProjectCatalogTotalsResponse(totals.TotalCount, totals.ActiveCount));
}
private static async Task<Results<Ok<ProjectDetail>, ProblemHttpResult>> GetAsync(
Guid projectId, GetProjectOverviewUseCase useCase, HttpContext httpContext, CancellationToken cancellationToken)
{
var result = await useCase.ExecuteAsync(projectId, cancellationToken);
if (result is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ProjectNotFound);
}
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag);
return TypedResults.Ok(ToProjectDetail(result));
}
private static async Task<Results<Ok<ProjectContextResponse>, ProblemHttpResult>> GetContextAsync(
Guid projectId,
GetProjectContextUseCase useCase,
[FromQuery] int? updates,
[FromQuery] int? decisions,
[FromQuery] int? documents,
CancellationToken cancellationToken)
{
var result = await useCase.ExecuteAsync(projectId, updates ?? 10, decisions ?? 10, documents ?? 10, cancellationToken);
if (result is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ProjectNotFound);
}
return TypedResults.Ok(new ProjectContextResponse(
new ProjectContextProjectSummary(
result.Project.Id, result.Project.Name, result.Project.Status.ToString(), result.Project.Summary,
result.Project.CurrentStateMarkdown, result.Project.UpdatedAt),
result.RecentUpdates.Select(update => new ProjectUpdateSummary(
update.Id, update.Category.ToString(), update.Title, update.SummaryMarkdown, update.VerificationStatus.ToString(),
update.ActorId, update.OccurredAt, update.CommitSha, update.ReleaseTag, update.IssueIdentifier, update.Url,
update.PageId, update.DecisionRecordId, update.MilestoneId, update.AttachmentReferenceId, update.SupersedesUpdateId))
.ToList(),
result.ActiveMilestones.Select(milestone => new ProjectMilestoneSummary(
milestone.Id, milestone.Name, milestone.State.ToString(),
milestone.TargetDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
milestone.CompletionDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
milestone.Summary, milestone.SuccessCriteria, string.Empty))
.ToList(),
result.Decisions.Select(decision => new ProjectDecisionSummary(
decision.Id, decision.DecisionNumber, decision.Status.ToString(), decision.Summary))
.ToList(),
result.Runbooks.Select(runbook => new ProjectDocumentSummary(
runbook.Document.Id, runbook.Document.PageId, runbook.PageTitle, runbook.PageSlug, runbook.PageDocumentType,
runbook.Document.Role.ToString()))
.ToList(),
result.Documents.Select(document => new ProjectDocumentSummary(
document.Document.Id, document.Document.PageId, document.PageTitle, document.PageSlug, document.PageDocumentType,
document.Document.Role.ToString()))
.ToList(),
result.Citations.Select(citation => new CitationResponse(
citation.Kind, citation.Id, citation.Revision, citation.Title, citation.CanonicalPath, citation.UpdatedAt))
.ToList(),
new ProjectContextTruncation(result.Truncated, result.OmittedCounts)));
}
private static async Task<Results<Ok<ProjectDetail>, ProblemHttpResult>> CreateAsync(
[FromBody] CreateProjectRequest request,
IWorkspaceRepository workspaces,
CreateProjectUseCase createUseCase,
GetProjectOverviewUseCase getUseCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await createUseCase.ExecuteAsync(
new CreateProjectCommand(
workspaceId.Value, request.Name, request.Summary, request.Priority, request.CurrentStateMarkdown,
request.OwnerId, CurrentUserId(principal), correlationId),
cancellationToken);
if (result.Status != OperationStatus.Success)
{
return ToProblem<ProjectDetail>(result.Status, result.Errors);
}
var detail = await getUseCase.ExecuteAsync(result.Value, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
return TypedResults.Ok(ToProjectDetail(detail!));
}
private static async Task<Results<Ok<ProjectIconResponse>, ProblemHttpResult>> SetIconAsync(
Guid projectId,
IFormFile file,
IWorkspaceRepository workspaces,
SetProjectIconUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
if (ifMatch is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
}
if (file.Length > SetProjectIconUseCase.MaximumIconBytes)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status413PayloadTooLarge,
type: ProblemTypes.AttachmentTooLarge,
detail: "Een projecticoon mag maximaal 2 MiB groot zijn.");
}
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
await using var stream = file.OpenReadStream();
var result = await useCase.ExecuteAsync(
new SetProjectIconCommand(
workspaceId.Value,
projectId,
CurrentUserId(principal),
file.FileName,
file.ContentType,
stream,
ifMatch,
httpContext.Items[CorrelationIdMiddleware.ItemKey] as string),
cancellationToken);
if (result.Status != SetProjectIconStatus.Success)
{
return result.Status switch
{
SetProjectIconStatus.NotFound => TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ProjectNotFound, title: "Project niet gevonden."),
SetProjectIconStatus.ConcurrencyConflict => TypedResults.Problem(
statusCode: StatusCodes.Status412PreconditionFailed, type: ProblemTypes.ConcurrencyConflict,
title: "Versieconflict.", detail: "Het project is intussen gewijzigd. Vernieuw de pagina en probeer opnieuw."),
SetProjectIconStatus.TooLarge => TypedResults.Problem(
statusCode: StatusCodes.Status413PayloadTooLarge, type: ProblemTypes.AttachmentTooLarge,
detail: "Een projecticoon mag maximaal 2 MiB groot zijn."),
SetProjectIconStatus.EmptyFile => TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: "Kies een afbeelding."),
_ => TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.AttachmentRejected,
detail: result.Detail ?? "Kies een geldige PNG-, JPEG-, GIF- of WebP-afbeelding.")
};
}
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
return TypedResults.Ok(new ProjectIconResponse(result.IconAttachmentId!.Value, result.ETag!));
}
private static async Task<Results<Ok<ProjectDetail>, ProblemHttpResult>> UpdateAsync(
Guid projectId,
[FromBody] UpdateProjectRequest request,
UpdateProjectUseCase updateUseCase,
GetProjectOverviewUseCase getUseCase,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
if (ifMatch is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await updateUseCase.ExecuteAsync(
new UpdateProjectCommand(
projectId, request.Name, request.Summary, request.Priority, request.CurrentStateMarkdown, request.OwnerId,
request.StartDate, request.TargetDate, request.CompletionDate, request.RepositoryUrl, request.LiveUrl,
request.DocumentationUrl, request.IssueTrackerUrl, request.Technologies, ifMatch,
CurrentUserId(httpContext.User), correlationId),
cancellationToken);
if (result.Status != OperationStatus.Success)
{
return ToProblem<ProjectDetail>(result.Status, result.Errors);
}
var detail = await getUseCase.ExecuteAsync(projectId, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
return TypedResults.Ok(ToProjectDetail(detail!));
}
private static async Task<Results<Ok<ProjectDetail>, ProblemHttpResult>> ChangeStatusAsync(
Guid projectId,
[FromBody] ChangeProjectStatusRequest request,
ChangeProjectStatusUseCase useCase,
GetProjectOverviewUseCase getUseCase,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
if (ifMatch is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new ChangeProjectStatusCommand(projectId, request.Status, ifMatch, CurrentUserId(httpContext.User), correlationId),
cancellationToken);
if (result.Status != OperationStatus.Success)
{
return ToProblem<ProjectDetail>(result.Status, result.Errors);
}
var detail = await getUseCase.ExecuteAsync(projectId, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
return TypedResults.Ok(ToProjectDetail(detail!));
}
private static async Task<Results<Ok<ProjectDocumentSummary>, ProblemHttpResult>> LinkDocumentAsync(
Guid projectId,
[FromBody] LinkProjectDocumentRequest request,
LinkProjectDocumentUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new LinkProjectDocumentCommand(projectId, request.PageId, request.Role, CurrentUserId(principal), correlationId),
cancellationToken);
if (result.Status != OperationStatus.Success)
{
return ToProblem<ProjectDocumentSummary>(result.Status, result.Errors);
}
return TypedResults.Ok(new ProjectDocumentSummary(result.Value, request.PageId, string.Empty, string.Empty, string.Empty, request.Role));
}
private static async Task<Results<Ok, ProblemHttpResult>> UnlinkDocumentAsync(
Guid documentId, UnlinkProjectDocumentUseCase useCase, ClaimsPrincipal principal, HttpContext httpContext, CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new UnlinkProjectDocumentCommand(documentId, CurrentUserId(principal), correlationId), cancellationToken);
return result.Status == OperationStatus.Success ? TypedResults.Ok() : ToProblem<Ok>(result.Status, result.Errors);
}
private static async Task<Ok<ProjectUpdateSummary>> CreateUpdateAsync(
Guid projectId,
[FromBody] CreateProjectUpdateRequest request,
CreateProjectUpdateUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new CreateProjectUpdateCommand(
projectId, request.Category, request.Title, request.SummaryMarkdown, request.VerificationStatus,
request.OccurredAt, request.CommitSha, request.ReleaseTag, request.IssueIdentifier, request.Url,
request.PageId, request.DecisionRecordId, request.MilestoneId, request.AttachmentReferenceId,
request.SupersedesUpdateId, CurrentUserId(principal), correlationId),
cancellationToken);
return TypedResults.Ok(new ProjectUpdateSummary(
result.Value, request.Category, request.Title, request.SummaryMarkdown, request.VerificationStatus,
CurrentUserId(principal), DateTimeOffset.UtcNow, request.CommitSha, request.ReleaseTag, request.IssueIdentifier,
request.Url, request.PageId, request.DecisionRecordId, request.MilestoneId, request.AttachmentReferenceId,
request.SupersedesUpdateId));
}
private static async Task<Ok<ProjectUpdatesResponse>> GetUpdatesAsync(
Guid projectId, GetProjectUpdatesUseCase useCase, [FromQuery] string? cursor, [FromQuery] int? limit,
CancellationToken cancellationToken)
{
var result = await useCase.ExecuteAsync(projectId, cursor, limit ?? 25, cancellationToken);
return TypedResults.Ok(new ProjectUpdatesResponse(
result.Items.Select(update => new ProjectUpdateSummary(
update.Id, update.Category.ToString(), update.Title, update.SummaryMarkdown, update.VerificationStatus.ToString(),
update.ActorId, update.OccurredAt, update.CommitSha, update.ReleaseTag, update.IssueIdentifier, update.Url,
update.PageId, update.DecisionRecordId, update.MilestoneId, update.AttachmentReferenceId, update.SupersedesUpdateId))
.ToList(),
result.NextCursor));
}
private static async Task<Ok<ProjectMilestoneSummary>> CreateMilestoneAsync(
Guid projectId,
[FromBody] CreateProjectMilestoneRequest request,
CreateProjectMilestoneUseCase createUseCase,
IProjectRepository projects,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await createUseCase.ExecuteAsync(
new CreateProjectMilestoneCommand(
projectId, request.Name, request.TargetDate, request.Summary, request.SuccessCriteria,
CurrentUserId(principal), correlationId),
cancellationToken);
var created = await projects.FindMilestoneAsync(result.Value, cancellationToken);
var etag = await projects.GetMilestoneETagAsync(result.Value, cancellationToken) ?? string.Empty;
return TypedResults.Ok(ToMilestoneSummary(created!, etag));
}
private static async Task<Ok<IReadOnlyList<ProjectMilestoneSummary>>> GetMilestonesAsync(
Guid projectId, GetProjectMilestonesUseCase useCase, IProjectRepository projects, CancellationToken cancellationToken)
{
var milestones = await useCase.ExecuteAsync(projectId, cancellationToken);
var summaries = new List<ProjectMilestoneSummary>(milestones.Count);
foreach (var milestone in milestones)
{
var etag = await projects.GetMilestoneETagAsync(milestone.Id, cancellationToken) ?? string.Empty;
summaries.Add(ToMilestoneSummary(milestone, etag));
}
return TypedResults.Ok<IReadOnlyList<ProjectMilestoneSummary>>(summaries);
}
private static ProjectMilestoneSummary ToMilestoneSummary(ProjectMilestone milestone, string etag) =>
new(
milestone.Id, milestone.Name, milestone.State.ToString(),
milestone.TargetDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
milestone.CompletionDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
milestone.Summary, milestone.SuccessCriteria, etag);
private static async Task<Results<Ok<ProjectMilestoneSummary>, ProblemHttpResult>> UpdateMilestoneAsync(
Guid milestoneId,
[FromBody] UpdateProjectMilestoneRequest request,
UpdateProjectMilestoneUseCase useCase,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
if (ifMatch is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new UpdateProjectMilestoneCommand(
milestoneId, request.Name, request.TargetDate, request.Summary, request.SuccessCriteria, request.State,
ifMatch, CurrentUserId(httpContext.User), correlationId),
cancellationToken);
if (result.Status != OperationStatus.Success)
{
return ToProblem<ProjectMilestoneSummary>(result.Status, result.Errors);
}
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
return TypedResults.Ok(new ProjectMilestoneSummary(
milestoneId, request.Name, request.State, request.TargetDate, null, request.Summary, request.SuccessCriteria, result.ETag!));
}
private static ProblemHttpResult ToProblem<T>(OperationStatus status, IReadOnlyList<string> errors) =>
status switch
{
OperationStatus.NotFound => TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ProjectNotFound, title: "Project niet gevonden."),
OperationStatus.ConcurrencyConflict => TypedResults.Problem(
statusCode: StatusCodes.Status412PreconditionFailed, type: ProblemTypes.ConcurrencyConflict,
title: "Versieconflict.", detail: "Een andere gebruiker heeft dit intussen gewijzigd."),
OperationStatus.InvalidTransition => TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict, type: ProblemTypes.ProjectInvalidTransition,
title: "Ongeldige statusovergang.", detail: string.Join(" ", errors)),
_ => TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation,
title: "Ongeldige aanvraag.", detail: string.Join(" ", errors))
};
private static ProjectSummary ToProjectSummary(Domain.Projects.Project project) =>
new(project.Id, project.Name, project.Slug, project.Summary, project.Status.ToString(), project.Priority.ToString(),
project.OwnerId, project.RepositoryUrl, project.LiveUrl, project.UpdatedAt, project.IconAttachmentReferenceId);
private static ProjectDetail ToProjectDetail(ProjectOverviewResult result) =>
new(
result.Project.Id, result.Project.Name, result.Project.Slug, result.Project.Summary,
result.Project.Status.ToString(), result.Project.Priority.ToString(), result.Project.CurrentStateMarkdown,
result.Project.OwnerId,
result.Project.StartDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
result.Project.TargetDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
result.Project.CompletionDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
result.Project.RepositoryUrl, result.Project.LiveUrl, result.Project.DocumentationUrl, result.Project.IssueTrackerUrl,
result.Project.Technologies,
result.Documents.Select(document => new ProjectDocumentSummary(
document.Document.Id, document.Document.PageId, document.PageTitle, document.PageSlug, document.PageDocumentType,
document.Document.Role.ToString())).ToList(),
result.RecentUpdates.Select(update => new ProjectUpdateSummary(
update.Id, update.Category.ToString(), update.Title, update.SummaryMarkdown, update.VerificationStatus.ToString(),
update.ActorId, update.OccurredAt, update.CommitSha, update.ReleaseTag, update.IssueIdentifier, update.Url,
update.PageId, update.DecisionRecordId, update.MilestoneId, update.AttachmentReferenceId, update.SupersedesUpdateId))
.ToList(),
result.Milestones.Select(milestone => new ProjectMilestoneSummary(
milestone.Id, milestone.Name, milestone.State.ToString(),
milestone.TargetDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
milestone.CompletionDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
milestone.Summary, milestone.SuccessCriteria, string.Empty))
.ToList(),
result.Decisions.Select(decision => new ProjectDecisionSummary(
decision.Id, decision.DecisionNumber, decision.Status.ToString(), decision.Summary))
.ToList(),
result.Project.CreatedAt, result.Project.UpdatedAt, result.ETag, result.Project.IconAttachmentReferenceId);
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
}
public sealed record ProjectUpdatesResponse(IReadOnlyList<ItWorx.KnowledgeHub.Contracts.Projects.ProjectUpdateSummary> Items, string? NextCursor);
@@ -0,0 +1,81 @@
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Domain.Identity;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
/// <summary>
/// A single, deliberately narrow read-only endpoint for RAGcore's n8n sync
/// (docs/32-rag-publication-convention.md) — reuses the existing Reader/
/// <c>pages:read</c> content-access policy, no new scope. Not a general
/// <c>GET /pages</c> enumeration: only pages explicitly opted in via the
/// <c>rag:publish</c> tag are ever returned.
/// </summary>
public static class RagPublicationEndpoints
{
public static IEndpointRouteBuilder MapRagPublicationEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/rag/publication-manifest", GetPublicationManifestAsync)
.WithTags("Rag")
.RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
return app;
}
private static async Task<Results<Ok<RagPublicationManifestResponse>, ProblemHttpResult>> GetPublicationManifestAsync(
[FromQuery] string? space,
[FromQuery] string? cursor,
[FromQuery] int? limit,
IWorkspaceRepository workspaces,
GetRagPublicationManifestUseCase useCase,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var result = await useCase.ExecuteAsync(
new RagPublicationManifestQuery(workspaceId.Value, space, cursor, limit ?? 100), cancellationToken);
if (result.Status == RagPublicationManifestStatus.InvalidSpace)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
type: ProblemTypes.Validation,
title: "Ongeldige space-parameter.",
detail: "space moet een korte, kleine-letters slug zijn (a-z, 0-9, '-', '_', '.').");
}
IReadOnlyList<RagPublicationManifestItem> items = result.Items
.Select(item => new RagPublicationManifestItem(
// Application's RagPublicationManifestEntry -> Contracts'
// RagPublicationManifestItem (never returning the domain/application
// record directly from an endpoint).
item.ContentId,
item.DocumentType,
item.DocumentStatus,
item.Title,
item.Slug,
item.CanonicalUrl,
item.Markdown,
item.Revision,
item.Checksum,
item.Locale,
item.Space,
item.UpdatedAt,
item.PublishedAt,
item.Retired,
item.RetiredReason))
.ToList();
return TypedResults.Ok(new RagPublicationManifestResponse(items, result.NextCursor));
}
}
@@ -0,0 +1,204 @@
using System.Security.Claims;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Api.Knowledge;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Common;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Application.Runbooks.UseCases;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Runbooks;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Domain.Identity;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
public static class RunbooksEndpoints
{
public static IEndpointRouteBuilder MapRunbooksEndpoints(this IEndpointRouteBuilder app)
{
// See PagesEndpoints.cs's identical comment (ADR-015).
var group = app.MapGroup("/api/v1/runbooks").WithTags("Runbooks");
group.MapGet("", ListAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.RunbooksRead);
group.MapGet("/{runbookId:guid}", GetAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.RunbooksRead);
group.MapPost("", CreateAsync)
.RequireContentAccess(WorkspaceRole.Editor, Scopes.RunbooksWrite)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.AddEndpointFilter<IdempotencyEndpointFilter>();
group.MapPost("/{runbookId:guid}/validate", ValidateAsync)
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPut("/{runbookId:guid}/profile", UpdateProfileAsync)
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
return app;
}
private static async Task<Ok<RunbookListResponse>> ListAsync(
IWorkspaceRepository workspaces,
ListRunbooksUseCase useCase,
GetRunbookUseCase getUseCase,
[FromQuery] string? riskLevel,
[FromQuery] string? status,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Ok(new RunbookListResponse([], null));
}
var result = await useCase.ExecuteAsync(workspaceId.Value, riskLevel, status, cursor, limit ?? 50, cancellationToken);
var items = new List<RunbookSummary>(result.Items.Count);
foreach (var runbook in result.Items)
{
var detail = await getUseCase.ExecuteAsync(runbook.Id, cancellationToken);
if (detail is not null)
{
items.Add(new RunbookSummary(
runbook.Id, runbook.PageId, detail.Page.Page.TitleValue, runbook.RiskLevel.ToString(),
runbook.Status.ToString(), detail.IsStale, runbook.LastValidatedAt, runbook.UpdatedAt));
}
}
return TypedResults.Ok(new RunbookListResponse(items, result.NextCursor));
}
private static async Task<Results<Ok<RunbookDetail>, ProblemHttpResult>> GetAsync(
Guid runbookId, GetRunbookUseCase useCase, HttpContext httpContext, CancellationToken cancellationToken)
{
var result = await useCase.ExecuteAsync(runbookId, cancellationToken);
if (result is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.RunbookNotFound);
}
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag);
return TypedResults.Ok(ToRunbookDetail(result));
}
private static async Task<Results<Ok<RunbookDetail>, ProblemHttpResult>> CreateAsync(
[FromBody] CreateRunbookRequest request,
IWorkspaceRepository workspaces,
CreateRunbookUseCase createUseCase,
GetRunbookUseCase getUseCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await createUseCase.ExecuteAsync(
new CreateRunbookCommand(
workspaceId.Value, request.ParentPageId, request.Title, request.Markdown, request.TemplateKey,
request.RiskLevel, request.RequiresMaintenanceWindow, request.EstimatedDurationMinutes,
CurrentUserId(principal), correlationId),
cancellationToken);
if (result.Status != OperationStatus.Success)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: string.Join(" ", result.Errors));
}
var detail = await getUseCase.ExecuteAsync(result.Value, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail!.ETag);
return TypedResults.Ok(ToRunbookDetail(detail));
}
private static async Task<Results<Ok<RunbookDetail>, ProblemHttpResult>> ValidateAsync(
Guid runbookId,
ValidateRunbookUseCase useCase,
GetRunbookUseCase getUseCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
if (ifMatch is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new ValidateRunbookCommand(runbookId, ifMatch, CurrentUserId(principal), correlationId), cancellationToken);
if (result.Status != OperationStatus.Success)
{
return ToProblem(result.Status, result.Errors);
}
var detail = await getUseCase.ExecuteAsync(runbookId, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail!.ETag);
return TypedResults.Ok(ToRunbookDetail(detail));
}
private static async Task<Results<Ok<RunbookDetail>, ProblemHttpResult>> UpdateProfileAsync(
Guid runbookId,
[FromBody] UpdateRunbookProfileRequest request,
UpdateRunbookProfileUseCase useCase,
GetRunbookUseCase getUseCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
if (ifMatch is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new UpdateRunbookProfileCommand(
runbookId, request.Owner, request.RiskLevel, request.Status, request.RequiresMaintenanceWindow,
request.EstimatedDurationMinutes, request.ReviewDueAt, ifMatch, CurrentUserId(principal), correlationId),
cancellationToken);
if (result.Status != OperationStatus.Success)
{
return ToProblem(result.Status, result.Errors);
}
var detail = await getUseCase.ExecuteAsync(runbookId, cancellationToken);
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail!.ETag);
return TypedResults.Ok(ToRunbookDetail(detail));
}
private static ProblemHttpResult ToProblem(OperationStatus status, IReadOnlyList<string> errors) =>
status switch
{
OperationStatus.NotFound => TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.RunbookNotFound),
OperationStatus.ConcurrencyConflict => TypedResults.Problem(
statusCode: StatusCodes.Status412PreconditionFailed, type: ProblemTypes.ConcurrencyConflict, title: "Versieconflict."),
_ => TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: string.Join(" ", errors))
};
private static RunbookDetail ToRunbookDetail(RunbookDetailResult result) =>
new(
result.Runbook.Id, result.Runbook.PageId, result.Runbook.Owner, result.Runbook.RiskLevel.ToString(),
result.Runbook.Status.ToString(), result.Runbook.RequiresMaintenanceWindow, result.Runbook.EstimatedDurationMinutes,
result.Runbook.ReviewDueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
result.Runbook.LastValidatedAt, result.Runbook.ValidatedBy, result.IsStale,
result.Page.Page.TitleValue, result.Page.Page.Markdown, result.Page.RenderedHtml, result.Page.Page.CurrentRevisionNumber,
result.Runbook.CreatedAt, result.Runbook.UpdatedAt, result.ETag);
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
}
@@ -0,0 +1,51 @@
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Domain.Identity;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
public static class SearchEndpoints
{
public static IEndpointRouteBuilder MapSearchEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/search", SearchAsync)
.WithTags("Search")
.RequireContentAccess(WorkspaceRole.Reader, Scopes.KnowledgeSearch);
return app;
}
private static async Task<Results<Ok<SearchResponse>, ProblemHttpResult>> SearchAsync(
[FromQuery] string? q,
[FromQuery] string? tag,
[FromQuery] string? type,
[FromQuery] string? cursor,
[FromQuery] int? limit,
IWorkspaceRepository workspaces,
SearchPagesUseCase useCase,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var result = await useCase.ExecuteAsync(
new SearchPagesQuery(workspaceId.Value, q ?? string.Empty, tag, type, cursor, limit ?? 25),
cancellationToken);
IReadOnlyList<SearchResultItem> items = result.Items
.Select(hit => new SearchResultItem(
hit.Id, hit.ParentPageId, hit.Title, hit.Slug, hit.DocumentType, hit.DocumentStatus, hit.Tags, hit.UpdatedAt, hit.Snippet))
.ToList();
return TypedResults.Ok(new SearchResponse(items, result.NextCursor));
}
}
@@ -0,0 +1,135 @@
using System.Security.Claims;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Automation.UseCases;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Contracts.Automation;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Domain.Automation;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
/// <summary>
/// Administrative, cookie-authenticated-only endpoints for creating, listing,
/// rotating and revoking service accounts/tokens (docs/09-api-contract.md "Service
/// accounts": "Administrative browser/API endpoints create, list, rotate and revoke
/// accounts/tokens."). Deliberately not reachable by a service token itself — a
/// service account can never mint or escalate its own credentials.
/// </summary>
public static class ServiceAccountsEndpoints
{
public static IEndpointRouteBuilder MapServiceAccountsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/service-accounts")
.WithTags("Service accounts")
.RequireAuthorization(AuthorizationPolicies.RequireAdministrator);
group.MapGet("", ListAsync);
group.MapPost("", CreateAsync)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/{serviceAccountId:guid}/tokens", IssueTokenAsync)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/tokens/{tokenId:guid}/revoke", RevokeTokenAsync)
.AddEndpointFilter<AntiforgeryEndpointFilter>();
return app;
}
private static async Task<Ok<ServiceAccountListResponse>> ListAsync(
IWorkspaceRepository workspaces, ListServiceAccountsUseCase useCase, CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Ok(new ServiceAccountListResponse([]));
}
var accounts = await useCase.ExecuteAsync(workspaceId.Value, cancellationToken);
return TypedResults.Ok(new ServiceAccountListResponse(accounts.Select(ToSummary).ToList()));
}
private static async Task<Results<Ok<CreatedServiceAccountToken>, ProblemHttpResult>> CreateAsync(
[FromBody] CreateServiceAccountRequest request,
IWorkspaceRepository workspaces,
CreateServiceAccountUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
if (workspaceId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
}
var invalidScopes = request.Scopes.Where(scope => !Scopes.IsValid(scope)).ToList();
if (invalidScopes.Count > 0)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation,
detail: $"Onbekende scope(s): {string.Join(", ", invalidScopes)}.");
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new CreateServiceAccountCommand(workspaceId.Value, request.Name, request.Description, request.Scopes, CurrentUserId(principal), correlationId),
cancellationToken);
return TypedResults.Ok(new CreatedServiceAccountToken(result.ServiceAccountId, result.TokenId, result.RawToken));
}
private static async Task<Results<Ok<CreatedServiceAccountToken>, ProblemHttpResult>> IssueTokenAsync(
Guid serviceAccountId,
[FromBody] IssueServiceAccountTokenRequest request,
IssueServiceAccountTokenUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var invalidScopes = request.Scopes.Where(scope => !Scopes.IsValid(scope)).ToList();
if (invalidScopes.Count > 0)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation,
detail: $"Onbekende scope(s): {string.Join(", ", invalidScopes)}.");
}
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new IssueServiceAccountTokenCommand(serviceAccountId, request.Scopes, CurrentUserId(principal), correlationId),
cancellationToken);
if (result.TokenId is null)
{
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ServiceAccountNotFound);
}
return TypedResults.Ok(new CreatedServiceAccountToken(serviceAccountId, result.TokenId.Value, result.RawToken!));
}
private static async Task<Results<Ok, ProblemHttpResult>> RevokeTokenAsync(
Guid tokenId, RevokeServiceAccountTokenUseCase useCase, ClaimsPrincipal principal, HttpContext httpContext, CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var revoked = await useCase.ExecuteAsync(new RevokeServiceAccountTokenCommand(tokenId, CurrentUserId(principal), correlationId), cancellationToken);
return revoked
? TypedResults.Ok()
: TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ServiceAccountTokenNotFound);
}
private static ServiceAccountSummary ToSummary(ServiceAccountView view) =>
new(
view.Account.Id, view.Account.Name, view.Account.Description, view.Account.Enabled, view.Account.CreatedAt,
view.Tokens.Select(token => new ServiceAccountTokenSummary(
token.Id, token.PublicId, token.Scopes, token.CreatedAt, token.LastUsedAt, token.ExpiresAt, token.RevokedAt))
.ToList());
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
}
@@ -0,0 +1,88 @@
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Identity;
using ItWorx.KnowledgeHub.Application.Identity.UseCases;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Setup;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
public static class SetupEndpoints
{
public static IEndpointRouteBuilder MapSetupEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/setup").WithTags("Setup");
group.MapGet("/status", GetStatusAsync);
group.MapPost("/complete", CompleteAsync)
.AddEndpointFilter<AntiforgeryEndpointFilter>()
.RequireRateLimiting(RateLimitPolicies.Setup);
return app;
}
private static async Task<Ok<SetupStatusResponse>> GetStatusAsync(
IInstallationRepository installations,
CancellationToken cancellationToken)
{
var complete = await installations.IsSetupCompleteAsync(cancellationToken);
return TypedResults.Ok(new SetupStatusResponse(!complete));
}
private static async Task<Results<Ok<CompleteSetupResponse>, ProblemHttpResult>> CompleteAsync(
[FromBody] CompleteSetupRequest request,
CompleteFirstRunSetupUseCase useCase,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
CompleteFirstRunSetupResult result;
try
{
result = await useCase.ExecuteAsync(
new CompleteFirstRunSetupCommand(
request.WorkspaceName,
request.DisplayName,
request.Email,
request.Password,
correlationId),
cancellationToken);
}
catch (ArgumentException ex)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
type: ProblemTypes.Validation,
title: "Ongeldige invoer.",
detail: ex.Message);
}
return result.Status switch
{
CompleteFirstRunSetupStatus.Success => TypedResults.Ok(
new CompleteSetupResponse(result.WorkspaceId!.Value, result.UserId!.Value)),
CompleteFirstRunSetupStatus.AlreadyCompleted => TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict,
type: ProblemTypes.SetupAlreadyCompleted,
title: "Installatie is al voltooid.",
detail: "De eerste installatie is al eerder afgerond."),
CompleteFirstRunSetupStatus.EmailAlreadyInUse => TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
type: ProblemTypes.Validation,
title: "Ongeldige invoer.",
detail: "Dit e-mailadres is al in gebruik."),
_ => TypedResults.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
type: ProblemTypes.Validation,
title: "Ongeldige invoer.",
detail: string.Join(" ", result.Errors))
};
}
}
@@ -0,0 +1,77 @@
using System.Security.Claims;
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Api.Security;
using ItWorx.KnowledgeHub.Application.Migration;
using ItWorx.KnowledgeHub.Application.Migration.UseCases;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Migration;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
/// <summary>
/// Explicit administrative purge for an externally published source batch. It accepts
/// only a reviewed list of target page identifiers and refuses ambiguous scope before
/// it can mutate production data.
/// </summary>
public static class SourceBatchPurgeEndpoints
{
public static IEndpointRouteBuilder MapSourceBatchPurgeEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/admin/source-batches/purge")
.WithTags("Source batch purge")
.RequireAuthorization(AuthorizationPolicies.RequireAdministrator);
group.MapPost("/dry-run", DryRunAsync).AddEndpointFilter<AntiforgeryEndpointFilter>();
group.MapPost("/execute", ExecuteAsync).AddEndpointFilter<AntiforgeryEndpointFilter>();
return app;
}
private static async Task<Ok<SourceBatchPurgeDryRunResponse>> DryRunAsync(
[FromBody] SourceBatchPurgeRequest request,
PurgeSourceBatchUseCase useCase,
CancellationToken cancellationToken) =>
TypedResults.Ok(ToResponse(await useCase.DryRunAsync(request.BatchId, request.Source, request.Pages, cancellationToken)));
private static async Task<Results<Ok<SourceBatchPurgeResponse>, ProblemHttpResult>> ExecuteAsync(
[FromBody] ExecuteSourceBatchPurgeRequest request,
PurgeSourceBatchUseCase useCase,
ClaimsPrincipal principal,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
var result = await useCase.ExecuteAsync(
new PurgeSourceBatchCommand(
request.BatchId, request.Source, request.Pages, request.ConfirmationHash,
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!), correlationId),
cancellationToken);
if (!result.Succeeded)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict,
type: ProblemTypes.Validation,
title: result.StaleDryRun ? "Dry-run is niet langer actueel." : "Bronbatch voldoet niet aan de veiligheidscontroles.",
detail: result.StaleDryRun
? "Voer opnieuw een dry-run uit; de geselecteerde gegevens zijn intussen gewijzigd."
: string.Join(" ", result.Inspection.Blockers));
}
return TypedResults.Ok(new SourceBatchPurgeResponse(
"purged", ToResponse(result.Inspection), result.RemovedPages, result.RemovedRevisions,
result.RemovedDrafts, result.RemovedLinks, result.RemovedAttachmentUsages,
result.RemovedAttachmentReferences, result.RemovedImportJobs, result.RemovedImportItems,
result.RemovedProvenanceRecords, result.RemovedBlobs));
}
private static SourceBatchPurgeDryRunResponse ToResponse(SourceBatchPurgeInspection value) =>
new(
value.IsSafe, value.ConfirmationHash, value.PageCount, value.DescendantPageCount,
value.RevisionCount, value.DraftCount, value.LinkCount, value.AttachmentUsageCount,
value.AttachmentReferenceCount, value.OrphanedBlobCount, value.SharedBlobCount,
value.ImportJobCount, value.ImportItemCount, value.ProvenanceCount,
value.SourceTagAssignmentCount, value.SearchIndexRecordCount,
value.GeneratedSourceDerivedPageCount, value.RetainedPageCount, value.Blockers);
}
@@ -0,0 +1,28 @@
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Application.Knowledge;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using Microsoft.AspNetCore.Http.HttpResults;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
public static class TemplatesEndpoints
{
public static IEndpointRouteBuilder MapTemplatesEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/templates", ListTemplates)
.WithTags("Templates")
.RequireAuthorization(AuthorizationPolicies.RequireEditor);
return app;
}
private static Ok<IReadOnlyList<TemplateSummary>> ListTemplates(ITemplateProvider templates)
{
IReadOnlyList<TemplateSummary> response = templates.ListTemplates()
.Select(template => new TemplateSummary(
template.Key, template.Name, template.DocumentType.ToString(), template.Description))
.ToList();
return TypedResults.Ok(response);
}
}
@@ -0,0 +1,33 @@
using ItWorx.KnowledgeHub.Api.Authorization;
using ItWorx.KnowledgeHub.Application.Identity.UseCases;
using ItWorx.KnowledgeHub.Contracts.Identity;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ItWorx.KnowledgeHub.Api.Endpoints;
/// <summary>Interactive-only, workspace-scoped support endpoints for the web UI (not
/// reachable by a service-account token — no automation use case has been named for
/// these yet, and keeping them cookie-only is the narrower default per
/// docs/adr/ADR-029-v2-r5-final-hardening.md).</summary>
public static class WorkspaceEndpoints
{
public static IEndpointRouteBuilder MapWorkspaceEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/workspace/members", SearchMembersAsync)
.WithTags("Workspace")
.RequireAuthorization(AuthorizationPolicies.RequireReader);
return app;
}
private static async Task<Ok<WorkspaceMemberListResponse>> SearchMembersAsync(
SearchWorkspaceMembersUseCase useCase,
[FromQuery] string? q,
CancellationToken cancellationToken)
{
var results = await useCase.ExecuteAsync(q, cancellationToken);
return TypedResults.Ok(new WorkspaceMemberListResponse(
results.Select(result => new WorkspaceMemberSummary(result.Id, result.DisplayName, result.Email)).ToArray()));
}
}
Loaded 100 of 825 files, more files were not shown because too many files have changed in this diff. Show more