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