Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"dotnet-ef": {
|
||||||
|
"version": "10.0.10",
|
||||||
|
"commands": [
|
||||||
|
"dotnet-ef"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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:
|
||||||
@@ -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`
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.302",
|
||||||
|
"rollForward": "latestFeature",
|
||||||
|
"allowPrerelease": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3903
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
|||||||
|
packages:
|
||||||
|
- "src/frontend"
|
||||||
|
- "tests/e2e"
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- '@playwright/test@1.62.1'
|
||||||
|
- playwright-core@1.62.1
|
||||||
|
- playwright@1.62.1
|
||||||
@@ -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.`);
|
||||||
@@ -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.");
|
||||||
@@ -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));
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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.`);
|
||||||
@@ -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).`);
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
+58
@@ -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;
|
||||||
|
}
|
||||||
+89
@@ -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_<public-id>_<secret></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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -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;
|
||||||
|
}
|
||||||
+109
@@ -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
Reference in new issue
Block a user