commit ad8ad252a9323e9999055ca67838dcd0057d8312 Author: Public Source Publisher Date: Thu Oct 1 01:37:37 2026 +0200 Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817 diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..cce89d8 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.10", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b1ba248 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4f225d4 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1ab77f8 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9bd3978 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b4779c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..149d519 --- /dev/null +++ b/CONTRIBUTING.md @@ -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`. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..2ea0b86 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + true + latest-recommended + true + true + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..9e163cc --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..11aadbc --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,35 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ItWorx.KnowledgeHub.slnx b/ItWorx.KnowledgeHub.slnx new file mode 100644 index 0000000..933f14e --- /dev/null +++ b/ItWorx.KnowledgeHub.slnx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f9ac2b --- /dev/null +++ b/LICENSE @@ -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. diff --git a/MANIFEST.json b/MANIFEST.json new file mode 100644 index 0000000..05d7571 --- /dev/null +++ b/MANIFEST.json @@ -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 +} diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..f7e7ea8 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md new file mode 100644 index 0000000..afdfa5f --- /dev/null +++ b/PUBLIC_SOURCE_EXPORT.md @@ -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. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json new file mode 100644 index 0000000..2605ec6 --- /dev/null +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -0,0 +1,4121 @@ +{ + "schemaVersion": 1, + "sourceRevision": "3b1751d0d9dee551aa6994eee0093704dc334817", + "files": [ + { + "path": ".config/dotnet-tools.json", + "sha256": "0d705a52e2d0a8c18776b41cd09b69955c6d265848f2fc7c913ae17e72924e07", + "bytes": 183 + }, + { + "path": ".dockerignore", + "sha256": "c2ea27f6d9d98d326aa1bd05493e30641f7f33eab2c508f7a65a09b1dcb3291e", + "bytes": 186 + }, + { + "path": ".editorconfig", + "sha256": "2ab44261f0604203d49d28099ae623e7430562434dbf08d98c078b3b38c59a09", + "bytes": 989 + }, + { + "path": ".env.example", + "sha256": "7ee366af82c106c53da2aa2eda7b1da9a71681b2c800159161c0addc84f4dd23", + "bytes": 861 + }, + { + "path": ".gitattributes", + "sha256": "d3241577d687fda1ad4c3a313d9453af1d501ab6232c8ae925d5da1bcbc76a22", + "bytes": 152 + }, + { + "path": ".gitignore", + "sha256": "063f777aadb4464b25b0a7e7490d23941191dc5bc1fa63a7219cd0825fd7bc90", + "bytes": 2564 + }, + { + "path": "CONTRIBUTING.md", + "sha256": "555837ab788bf1ab8c826f928a3bfd813be1e26383bbf7ba3b14bd26074cf629", + "bytes": 1422 + }, + { + "path": "Directory.Build.props", + "sha256": "1b9316a55c61a41e2bea70fe860dc4cd6dc225b7506d8b25b9b764df97609e6e", + "bytes": 427 + }, + { + "path": "Directory.Build.targets", + "sha256": "f2a1cc660c5c59233273cbc070145eb5892877c5de2881be6c0a2bed32e624b1", + "bytes": 391 + }, + { + "path": "Directory.Packages.props", + "sha256": "4de6b529b4e61eed6b3204d0f148f325f8c432f549cde58c961cbb986ba9ccdf", + "bytes": 2270 + }, + { + "path": "ItWorx.KnowledgeHub.slnx", + "sha256": "e33988c92c1615bc225920cb466069a8e6d118ab6501d564de0766304a173d27", + "bytes": 1057 + }, + { + "path": "LICENSE", + "sha256": "3cb8980a332769231620c69ab23929cafc5fbb98664f19c3c623b53ded5136d4", + "bytes": 377 + }, + { + "path": "MANIFEST.json", + "sha256": "9e5bcade00174e302dd665220aa911c0f23d2d377c7fb5a8e0ee3cfdaec6a425", + "bytes": 901 + }, + { + "path": "NuGet.Config", + "sha256": "90571d3c6c59fed0ceb64eca618a17c24c0d2eb05d67fa742f696eb9cf0c54d1", + "bytes": 275 + }, + { + "path": "README.md", + "sha256": "4fe9cff0252194b559e170cf4434b4baf984a0bfcf937aaa736d927625377529", + "bytes": 5283 + }, + { + "path": "SECURITY.md", + "sha256": "fdfe1a8563115cff5bc83c898b6afc436494b4927690884425fc0b1372ab088d", + "bytes": 2165 + }, + { + "path": "deploy/docker/Dockerfile", + "sha256": "844bdabab7f7d7f96867c98a20a4018e88ed54116dc26c643c4dcd8505e866c8", + "bytes": 3525 + }, + { + "path": "docker-compose.yml", + "sha256": "86ae75c4cb9cc9374a888c42a9e99912614f127fd738873a51ae5eea72071495", + "bytes": 4298 + }, + { + "path": "docs/00-product-vision.md", + "sha256": "e6a4cac238971be1a501cbbe115c530b8e7623071566ce7a47ecfab08bf0516d", + "bytes": 3029 + }, + { + "path": "docs/01-scope-and-non-goals.md", + "sha256": "4cb84268970b866251c7e3c9cd947b4dd2aebfd099e77cf7753715be8b29a999", + "bytes": 2976 + }, + { + "path": "docs/05-non-functional-requirements.md", + "sha256": "ae191ed4ebade9b90bcd45b5cbd4df6b261ef861f4eb53756956235d0cec9f3b", + "bytes": 4195 + }, + { + "path": "docs/06-system-architecture.md", + "sha256": "99ca9fec4eb3262e17c7f63341bd2b3e8cb15b46e0b60ce69196bbdfc5c3b3ab", + "bytes": 5193 + }, + { + "path": "docs/07-domain-model.md", + "sha256": "1429e4f543db02a325d2415eec1a7c50d3caca1afa0b670581aa4352356a0331", + "bytes": 6054 + }, + { + "path": "docs/08-database-schema.md", + "sha256": "d507da527686acfe4f7bcbf0b575169c701c0c08570153466171553c3b3eb8fb", + "bytes": 6443 + }, + { + "path": "docs/09-api-contract.md", + "sha256": "9ca68276ea38bb48c3dfc18a365d01beeec7f2c2d6f3de71cf7056b1a7acc64b", + "bytes": 6415 + }, + { + "path": "docs/10-search-and-indexing.md", + "sha256": "1fda26e0adea0bf4ded57150d99f41c09bfbfc8f42ba071ad525ec98d4a76e8b", + "bytes": 3733 + }, + { + "path": "docs/11-markdown-and-linking.md", + "sha256": "35aeb470602585faf2fe8f218c4a9dca4291bd7f7f1d8160bfa7dfbdf25ca5da", + "bytes": 4779 + }, + { + "path": "docs/12-auth-security.md", + "sha256": "8c1eb011da918e17dc363facbe191233d4c4bdedc4b755c58895eaa1d0ebbf29", + "bytes": 6738 + }, + { + "path": "docs/13-attachments.md", + "sha256": "aa0ad35374ec3ac984a7e14a154cf0f256c956128d8b26e8e55670735d24ae91", + "bytes": 4262 + }, + { + "path": "docs/14-revisions-and-audit.md", + "sha256": "ae53e26efc9c3c19d7b47d477f2e6f0c9a0d429e2a089c59e8b603140cc05e12", + "bytes": 3462 + }, + { + "path": "docs/15-project-hub.md", + "sha256": "f94b371aba66207d7cf5a7f82d7592745530a244a4f7b811e667efc99aacaa1c", + "bytes": 3561 + }, + { + "path": "docs/16-runbooks-and-adrs.md", + "sha256": "6d075534795ca26e02d3bd5167c2e085411e6a13934203267e6929297755f90a", + "bytes": 2455 + }, + { + "path": "docs/17-leafwiki-migration.md", + "sha256": "2d812dddf87a09bbd5494fb23109999ea199fee885877b441b1fb4776e2d22da", + "bytes": 9220 + }, + { + "path": "docs/18-backup-restore.md", + "sha256": "644a777f5d4e595f1af7e79807581a06248c182baa79117189eeeab7a227c676", + "bytes": 4762 + }, + { + "path": "docs/19-observability.md", + "sha256": "62410475a7ac1c4eaea3bdc56041f9075323deb31a0e0458d3e3d0879d27876a", + "bytes": 3925 + }, + { + "path": "docs/20-ui-ux-design-system.md", + "sha256": "95d30a48c4e873ec0eba70c312a7a547a31b8319ae5cd66e579db81167a3c1f0", + "bytes": 5117 + }, + { + "path": "docs/21-accessibility.md", + "sha256": "d97b7cb4bc04c8392e78ce93a5d2a10b6c6b27f2ea89febfe8889a17991c516a", + "bytes": 3668 + }, + { + "path": "docs/22-testing-strategy.md", + "sha256": "3640e34604bb230378f210ebdfa8993218640f73034cc1a98c0abd20a36cc7ca", + "bytes": 5038 + }, + { + "path": "docs/24-mcp-readiness.md", + "sha256": "016ece2dfa1b812443d01554b9887eebf8251d9608d872677fdf49c120ac5862", + "bytes": 5511 + }, + { + "path": "docs/26-threat-model.md", + "sha256": "6a4e3d1fa28a4e74afa5f202bc41fe40ed9be7ecfe4e64a03b18d4d43d0d64d9", + "bytes": 4989 + }, + { + "path": "docs/api/openapi.yaml", + "sha256": "ed9a4cc30e22bd08b106c3580be8321e693e1acce55f9948983265d88fee8aa3", + "bytes": 209363 + }, + { + "path": "docs/assets/screenshots/02-dashboard.png", + "sha256": "4f77968a98c1c617c99e652919c6855328a91e0293951011348560170cd0c88b", + "bytes": 120018 + }, + { + "path": "docs/assets/screenshots/03-knowledge.png", + "sha256": "a84f7fe07708d67479c660775ea0bb6193d4c37ff9d16594b98326478337dd73", + "bytes": 190279 + }, + { + "path": "docs/guides/back-ups-herstel-en-unraid-beheer.md", + "sha256": "c9144dbc479e0bc97285f8bd75ced327643450dc19ea08b59c708b91d5027781", + "bytes": 5641 + }, + { + "path": "docs/guides/bijlagen-en-leafwiki-import.md", + "sha256": "c6a2cf6903cdf499c5a02d34459b00d13f426c08ab915dfb422cf8e376b72dce", + "bytes": 3591 + }, + { + "path": "docs/guides/documenten-toevoegen-en-intake.md", + "sha256": "2352f8ef10155ddd4143c9d2525cae80a9d64676dd93cca8c07c13716716b932", + "bytes": 1650 + }, + { + "path": "docs/guides/eerste-installatie-en-aanmelden.md", + "sha256": "73038719d7f1f38b5f65748387fe018e64c3877a1a1052c2b02133b529480ab7", + "bytes": 3783 + }, + { + "path": "docs/guides/operationele-runbooks-overzicht.md", + "sha256": "572d982a90ac4ee02f6755bbaaafe20fae5a897365a1c6496ac1e216c5389dfc", + "bytes": 5067 + }, + { + "path": "docs/guides/pagina-s-schrijven-en-beheren.md", + "sha256": "b16c8d3a553b7c1eb6a3ce9a0157eddf2cf6583fd5ff816cfc8a2310feca6ee0", + "bytes": 5103 + }, + { + "path": "docs/guides/projecten-beslissingen-en-runbooks.md", + "sha256": "81ff1b257dd388b5128e4231c9374da54f7faaca32f86564d8e4887bfcb89311", + "bytes": 4428 + }, + { + "path": "docs/guides/service-accounts-en-automatisering.md", + "sha256": "4656bea026e6adeffffb1dbdcdb0572a2964290c9fd122794093a241f9f4d19c", + "bytes": 7449 + }, + { + "path": "docs/guides/source-batch-removal.md", + "sha256": "04d3c6ddebd650fa3a5159c7dcaf8f8c1fca83c1fbaefb0ab6375b3d9e029536", + "bytes": 2105 + }, + { + "path": "docs/guides/zoeken-geschiedenis-en-labels.md", + "sha256": "0abaade24d0d88e2c05f94263a1e213680ba3f371b6ffc566abcc3c8a855c3d2", + "bytes": 2909 + }, + { + "path": "fixtures/leafwiki/README.md", + "sha256": "83d8c74ad07e21f79745348d9c7b4853561cf15e3910861bc0ef4c6d410cf29f", + "bytes": 621 + }, + { + "path": "fixtures/markdown-import/Projecten/Voorbeeldproject.md", + "sha256": "8073c9e060711df344c89992ae1d164c29cccd2362658f0281802bd873b7c3be", + "bytes": 162 + }, + { + "path": "fixtures/markdown-import/School/Linux/Bestandsrechten.md", + "sha256": "102d22fe7096f0249200f9a492741bfbfc3940d8f488a7ffd766f9f85b4cf22f", + "bytes": 126 + }, + { + "path": "fixtures/markdown-import/School/Linux/Shell-operators.md", + "sha256": "40ad436d12a4a5b89855a9fce472bb269f8b72cb57f4f2207a5c1e3b8f4d9573", + "bytes": 202 + }, + { + "path": "fixtures/markdown-import/assets/architecture.svg", + "sha256": "172e951e869735f4ca68f3aa68945bdff7e7868561292266908c77bfc61efc77", + "bytes": 575 + }, + { + "path": "fixtures/markdown-import/source-manifest.json", + "sha256": "7028b1284fd7e2910dcbc08cf5e50515ab405eab66b006decb7556736800abdc", + "bytes": 140 + }, + { + "path": "global.json", + "sha256": "6cd80ed6f7a93e76c20e47164e3bfedfddc1b42b33519b2cd2dee6febff4e836", + "bytes": 111 + }, + { + "path": "package.json", + "sha256": "54f467b65e7c45cd7e16d65e36dadef42c8d14f6b9c0a105a57703caa6d8c64b", + "bytes": 1042 + }, + { + "path": "pnpm-lock.yaml", + "sha256": "128ec7f17a519778986a9d7890ec2da74fd6362fbeaae241df6ff0589be42b22", + "bytes": 139775 + }, + { + "path": "pnpm-workspace.yaml", + "sha256": "974c5c67f9c2429b38eeea3cc1a7c8d78e5c24833c647c5c69e6a7b369d53931", + "bytes": 150 + }, + { + "path": "scripts/check-openapi-coverage.mjs", + "sha256": "064f649140e7c72234680881bf59051053d4df8fb36a2eb3d84712a31b328508", + "bytes": 4122 + }, + { + "path": "scripts/doctor.mjs", + "sha256": "32f7f374e277ef82e1cf66c4f05d029d6c60bb7ac9170ad71f7bc4886f5ec48c", + "bytes": 3401 + }, + { + "path": "scripts/generate-release-manifest.mjs", + "sha256": "5d801b61d2a805c5d4b20af8520193b90821296f9e5cc7851f12767a91113a52", + "bytes": 5138 + }, + { + "path": "scripts/lib/exec.mjs", + "sha256": "17b303d2fec7c6b99eb687036fac2658b2fdfa63cc3f60517b3cd96786210c0c", + "bytes": 3493 + }, + { + "path": "scripts/validate-openapi.mjs", + "sha256": "d59e7aa97e5c3bf17438804453306900d986f07dab811ee59e03af62a11383ba", + "bytes": 2209 + }, + { + "path": "scripts/validate-public-source.mjs", + "sha256": "c6610b35e3938330ae4deda6bfd5b7f4c97ce547b4a94cf9a39faecc8da6c053", + "bytes": 1238 + }, + { + "path": "scripts/verify.mjs", + "sha256": "3e47ed409ecb43b8a1e6237d60a80d8b3d261a5e5657cd8c1dbd215fd855ef1a", + "bytes": 8548 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/AuthorizationPolicies.cs", + "sha256": "3db009c090e34b8386c9d9e2169f0546b4e545e4b35c46d8b50e868ff126da1f", + "bytes": 401 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessAuthorizationHandler.cs", + "sha256": "4e29ba7d31db43611a201c3a5332ab2a5746e983303fa25c33ec56d489377916", + "bytes": 2212 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessEndpointExtensions.cs", + "sha256": "60f2165830bfe08f13cdc13e1c5d26a414665d16f7bc97b488216851c29b8335", + "bytes": 1517 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessRequirement.cs", + "sha256": "e55e3427da9661f13c333c885df4757548ff96f0ce58de9d47babbb8d9075592", + "bytes": 938 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/ServiceTokenAuthenticationHandler.cs", + "sha256": "95e281204f0c2f621f9f919000464073e3a7f318029fced25e7ed12cc57c5701", + "bytes": 3810 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/WorkspaceRoleAuthorizationHandler.cs", + "sha256": "265e80c23e957b90828f9c144a1541ea67c6639bbbf2626910618e36b805127a", + "bytes": 1500 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Authorization/WorkspaceRoleRequirement.cs", + "sha256": "a69cc4bb1d2a982e7dbdf352f5a65b43eeccd0185d4a56739746635750b79b7e", + "bytes": 533 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/StagingSessionCleanupBackgroundService.cs", + "sha256": "a9627543eeb86d8e75e2c926fb0624efc99ba9679649184df2ad33c72480372b", + "bytes": 4372 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ActivityEndpoints.cs", + "sha256": "df0965011d7d97a6d03dbaf24a6c0bf8c05ed6be616a6bfbf78efdd090d04bfd", + "bytes": 1150 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AdminEndpoints.cs", + "sha256": "9ae490206424d5e4646bcef9a15df7938a082757ac39f1eb02d6dec9d325eeb0", + "bytes": 9494 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AttachmentsEndpoints.cs", + "sha256": "7e4eef5e2d14040b4dfb602580794e20a72ca3d643b90054beabe68aefa1c8c8", + "bytes": 10712 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AuthEndpoints.cs", + "sha256": "cf9ff5fdb2cf00b815e3e042259a6dafc761c3439ffbe4d257df1aa6dcf223db", + "bytes": 5598 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/BackupsEndpoints.cs", + "sha256": "f2fc4717f4376f10b51a16092b70fad1698a476b84d3fa84ff8e1aaf426c31d2", + "bytes": 2288 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DecisionsEndpoints.cs", + "sha256": "eed41b036ef80a94909f30ecc748d0cd8c8b5410909faacdd93917d7290ef67d", + "bytes": 10299 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DraftsEndpoints.cs", + "sha256": "c814c4bf4724ba1eaa8671bdf2f12eed0498933abb985b37c4cdce2b1fcfc917", + "bytes": 4641 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ImportsEndpoints.cs", + "sha256": "7e5e95248d3fcb690e9675b8dfe2e2cfb97810c513a47ca39171f2711644f230", + "bytes": 13588 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/InfrastructureEndpoints.cs", + "sha256": "de8923f90ef62278f439bc6d7084133901b2b0dbb59984ae726430a829bd2b0e", + "bytes": 30756 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/IngestionProposalsEndpoints.cs", + "sha256": "5fe075a816488b48149563041728724ffa22a9bb0bf1aa7261172d2dd11ffc8b", + "bytes": 26519 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/PagesEndpoints.cs", + "sha256": "a043f77d8b7449bdb853f6326c021ffd358e5f3bd1a7af10643457d25db5488e", + "bytes": 26245 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ProjectsEndpoints.cs", + "sha256": "bb715300847727e2e4905ccb652d28da4b167822d35827d611523bc4abf23d51", + "bytes": 30000 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/RagPublicationEndpoints.cs", + "sha256": "07dc72cab1fec0b8a8cf3915f741d5a21d73eb52d9e3b198e684499e92fcb7d0", + "bytes": 3338 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/RunbooksEndpoints.cs", + "sha256": "c39f45548034c21e2fcc7a9bf2cca469201c4382a0516419216413fa1a7da0be", + "bytes": 9571 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SearchEndpoints.cs", + "sha256": "e2b81a8d0245ee93e92c35bed5fcd9a9f14bb471e7829410e8006ba38326a45a", + "bytes": 1968 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ServiceAccountsEndpoints.cs", + "sha256": "ac42ebec42c9cc8ddf5e55af651e1789084c514705157974bdcc7c0ebc3da452", + "bytes": 6356 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SetupEndpoints.cs", + "sha256": "a2936b5f2be938e7564e6a6ce7001e9c3f7d600a320c6ce34bdf0535ed958150", + "bytes": 3324 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SourceBatchPurgeEndpoints.cs", + "sha256": "0c0be832f9bc5eaf0352c40b396c97f56ff4d0ec1560409f85e68dc69802207e", + "bytes": 3933 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/TemplatesEndpoints.cs", + "sha256": "786f88c698d0c4e6544e196eb28d4bbdf68ebc46575bf8170e86467ec36ce606", + "bytes": 972 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Endpoints/WorkspaceEndpoints.cs", + "sha256": "f32d69e9783cb3f7dd4dd1c238e7ea2d20af117820ba9c5c3626f41024cc02b8", + "bytes": 1398 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj", + "sha256": "7402928d3cde210690a5bd89bea05a432642346948a7c4b2ac2bcc424ea8a414", + "bytes": 619 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Knowledge/ETagHelper.cs", + "sha256": "f8068b81563a5035edcc674ed7489b4aedb5fec69fac2665121c80cb0af6d75d", + "bytes": 593 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Operations/RestoreModeRunner.cs", + "sha256": "ae0c3bbec69d2b667e8576f3b6384601fd003816e3cf133e3f05fde1ae5fbedb", + "bytes": 3035 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Program.cs", + "sha256": "d3a6283ed7f30fe1a98d84fd934a28a0d2758b42eb4170bfb62d22778f74f6da", + "bytes": 29884 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Properties/launchSettings.json", + "sha256": "ba7320d62133875f99cd089fc4f9f67c24c0d4d116c1df173f9d04261723b8f1", + "bytes": 383 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Security/AntiforgeryEndpointFilter.cs", + "sha256": "6a307bcf239614f2d7fdcee0cd1928d17a64cbc666e45d9d9d44f7a593a261fd", + "bytes": 1365 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Security/CorrelationIdMiddleware.cs", + "sha256": "d93726d49e9cf09e92c41628c6edd772c0e9d35726f043ee1570dfe9d74d90f9", + "bytes": 1232 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Security/IdempotencyEndpointFilter.cs", + "sha256": "d2836a29097f6fd895e3713ea2d4ce68bcd4708133a3d2128a36cd626e6a4da0", + "bytes": 8448 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Security/RateLimitPolicies.cs", + "sha256": "2dcceb1f6a0c5e6193135c93d9f34269c32cffb3c5d7fecf31a2c7f8a05ebaf2", + "bytes": 406 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Security/SecurityHeadersMiddleware.cs", + "sha256": "e4392e92f0f6ed46b470580e7f9db6d3f80d80852cb21bcf63dbeaf8f569b99e", + "bytes": 1153 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/Security/ServiceTokenFailureRateLimiter.cs", + "sha256": "558bf8257f101c35bdde678dd10dea3419ccc956456d878270b0866f643f7e6a", + "bytes": 1120 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/appsettings.Development.json", + "sha256": "4c9be8a8257311c69a2649815b0fb934e150a432a207232830b1978ffb552599", + "bytes": 146 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/appsettings.json", + "sha256": "428f07dbd12b22ef80df16777488b2400dd7704fe8602985e53ef4903498f784", + "bytes": 341 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Api/wwwroot/index.html", + "sha256": "a43bdd8b4f68310aaa40b88f311f139f8e957d31131c6994ddae5186c9ba5da1", + "bytes": 1100 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/AssemblyMarker.cs", + "sha256": "3ef21317427731204ba6383e07e26c34cabec72408aceba7213a00d7ce5e7d21", + "bytes": 83 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/AttachmentContentLock.cs", + "sha256": "5f052641b844097a85939b65db9b4b1619c24ae857b9ac367f34026683fe616f", + "bytes": 2953 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/IAttachmentBlobStore.cs", + "sha256": "169751656a77b6263c739a62c48053f46d847f2f8636a43b426b582553682025", + "bytes": 2251 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/IAttachmentRepository.cs", + "sha256": "4451d0e709937e60402d1e1eb0d28e8f1444e6f471f050b9770a875f357d1cec", + "bytes": 6662 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/IContentSignatureDetector.cs", + "sha256": "627c43eafa0b8ee5b7d26eef87f247e1bf15cdff98d5829739b394ae7f4fd7a1", + "bytes": 706 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/OrphanedBlobGarbageCollector.cs", + "sha256": "aa969b9f390d2c89663bfd7a2f4014a35146f5ac0ef98c80f37420266cbbf7fe", + "bytes": 3023 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/GetAttachmentContentUseCase.cs", + "sha256": "bdde195b4b3cf8565a63a66b5699f7415be2403217cb0db4b386d837eced5ed8", + "bytes": 6321 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/GetPageAttachmentsUseCase.cs", + "sha256": "ce94b1ff8f776a305dfca75d10a981252b402250e8a04b07511f03370dceac9f", + "bytes": 852 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/UploadAttachmentUseCase.cs", + "sha256": "f7ec6391e001dc256cb2f3cec4ee1bbb04975c6f5aaa951c160938c3eb2160a7", + "bytes": 10766 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Audit/AuditEventRecord.cs", + "sha256": "5c992ef4d82463c7aa230d9514acf5ef542feabfc7057510beba78eaaf7855e0", + "bytes": 589 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventReader.cs", + "sha256": "a5569e83b8ce6304699a40ae661a91f42ac05f4bb06681c9709764d290b729c1", + "bytes": 394 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventRecorder.cs", + "sha256": "78a58a0357e1cb466dd315e6277d549999657638fb7cc54a36ef4deb307f50bb", + "bytes": 444 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/IIdempotencyStore.cs", + "sha256": "0cde1245c250d21bc794a2fb5268ffe722680392b8ff36292ac13447496c2c1e", + "bytes": 328 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/IOutboxRepository.cs", + "sha256": "79c6588e2eb89d2cac68f49da398db19dc2689848894c3e9d3c3332b10ff9ded", + "bytes": 868 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/IServiceAccountRepository.cs", + "sha256": "e244f7f8c7a6c1e914c22a82b66b853d631fd050be0e91cc8b175753db629ce9", + "bytes": 1210 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/IServiceAccountTokenIssuer.cs", + "sha256": "c7bbdfbadc891447c23470922740cd4a0eef6135139aa11be863b60ee37bd241", + "bytes": 783 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/AuthenticateServiceTokenUseCase.cs", + "sha256": "f171e19da253eef77b8309866ea4fa6ea02cd3d6dcd5833675360bf89f16ed5e", + "bytes": 1957 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/CreateServiceAccountUseCase.cs", + "sha256": "14aa3f4a5b6c8267fa3bf85a1a2d8b51f976f773e69f2b82807aa86a3085264a", + "bytes": 2086 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/IssueServiceAccountTokenUseCase.cs", + "sha256": "2e65d12054e16fdb94603141a6816590c9bed8a18bc240b5fc56ea1789fe1ec9", + "bytes": 2102 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/ListServiceAccountsUseCase.cs", + "sha256": "7e81c9212e31b1a789862b6a0a2ebed98b75dfaec97b25f12a651ca2ad32a57e", + "bytes": 847 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/RevokeServiceAccountTokenUseCase.cs", + "sha256": "e9c1e46770cdee378c5c53d15918ff5b6be14979843c582897a1bd9963f953ca", + "bytes": 1496 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Common/OperationResult.cs", + "sha256": "40a9d990d5abf09548777fdebc83a7b7cb051ec0a3cb611b6406248c3cf4b588", + "bytes": 2157 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Common/SafeUrlValidator.cs", + "sha256": "7598bbd83634d0adef18905057e7b7d090a4383d93f8277569be5e73b1704d57", + "bytes": 1877 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/IDecisionRepository.cs", + "sha256": "31846cb8dc01a99eb6b1e1e365d0e53815c753ddd86ec74bf6df24ca86bc0528", + "bytes": 1461 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/AcceptDecisionUseCase.cs", + "sha256": "380d185bdcb8f75b19357381b00a614af4c6dd8bc8c61fb848c633bf24e62c18", + "bytes": 2676 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/CreateDecisionUseCase.cs", + "sha256": "2af5f9d1ef695fe55b7e868a22e4becb10be9fe5a18d584a01432c631a7314b2", + "bytes": 4739 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/GetDecisionUseCase.cs", + "sha256": "a2b6838be3852fcd569b4ebad889ee1ff19f1cb10aab9125595834fc3da6c310", + "bytes": 1733 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/ListDecisionsUseCase.cs", + "sha256": "d1d010447371772234b4b5614de785c20496f769fba848f0fd64fa45bae501d0", + "bytes": 408 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/RejectDecisionUseCase.cs", + "sha256": "6d6ba3ccd487075294d7e6d7e236a36c5494f4e76e8dc8fc0e4b8d1be626825b", + "bytes": 1823 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/WithdrawDecisionUseCase.cs", + "sha256": "053009f9ceed5c3ed37f498c76ad41c3aba8b01e05af29c00e157784621e429a", + "bytes": 1834 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/IInstallationRepository.cs", + "sha256": "4c26debcd4a65fb6b51c0c00546db301ffa987ff480e16c52dcd74ce30ebca0c", + "bytes": 606 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/IRecoveryTokenService.cs", + "sha256": "110bdbd3e32d9dcaf621f8052e46c138d8c8802343d1fa5a9398583e2bef9ab0", + "bytes": 869 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/IUserAccountManager.cs", + "sha256": "bc5a2df56e72356bcc927e0eaa475f610e02e83d1be761e9a304882b9ee9fbdf", + "bytes": 2360 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/IWorkspaceMembershipRepository.cs", + "sha256": "d79749756802ba301e594004a186b09913e3968379a6b4ec13b8b769d9f8a0a5", + "bytes": 797 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/IWorkspaceRepository.cs", + "sha256": "63063de6138b02fe8da2ee91952dabdeb56d20e8b5254a0b9277a1a2dc41463c", + "bytes": 332 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/PasswordSignInOutcome.cs", + "sha256": "b9bce7ac37bc066b40442596779dda20dc5a425020600957bfb02e9f977aa6bd", + "bytes": 754 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/CompleteFirstRunSetupUseCase.cs", + "sha256": "0ada0ffd1ed5d1bba526d838e03700097d93cb61f6e964a8680f9718d92104f6", + "bytes": 4002 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/CreateWorkspaceUserUseCase.cs", + "sha256": "67e4ace3828f54705bef1a11e8606260df337eae3d28af5511d0e22246c9f007", + "bytes": 3926 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/GetSessionUseCase.cs", + "sha256": "4b37fb46bdc5f4ae882386765c6227078c9ef26fcf1d59614137f0f2ad8bba86", + "bytes": 1382 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/IssueRecoveryTokenUseCase.cs", + "sha256": "058c0591cdcae1dab22b94a829df0b4e145bca0875d57f0a3dce7bae8a9c6e30", + "bytes": 1998 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/ListWorkspaceUsersUseCase.cs", + "sha256": "9e55c003c5cc3af1fce51873e765ba8c31b321530f0f4ea577e2fc265bb1c435", + "bytes": 1358 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/LoginUseCase.cs", + "sha256": "4572268de71f0ccfdd4d163c30dc7dafaeab03ba9cb93f3c33d61d7154881a96", + "bytes": 2413 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/LogoutUseCase.cs", + "sha256": "33a815b571dcd78ecac9e7150227efb3b06a110243c644fc631193ecfa469ad1", + "bytes": 1044 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/RedeemRecoveryTokenUseCase.cs", + "sha256": "00e1c5844497d977201a8756a92feecfab6d13703268dd921fb954a99b41ed0c", + "bytes": 2508 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/SearchWorkspaceMembersUseCase.cs", + "sha256": "3ac25d40f39b1d8cee9cb91327931286e0ef092721604ce0a181b1c28cecf103", + "bytes": 2378 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/UpdateWorkspaceUserUseCase.cs", + "sha256": "2318386d4ccdefe2defeab18f74300eba7ab75c3528dfaf3a37852b35feb4113", + "bytes": 4992 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UserAccount.cs", + "sha256": "32d9da06a848de668d529b30f673fa63b6d11575c0cfc3cf734d5a3f143a3dcd", + "bytes": 391 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Identity/UserAccountCreationResult.cs", + "sha256": "16cf1937eeeaad9ba00c103603b53fa01ab0d455067033ca04a31c19c4d3800b", + "bytes": 777 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/IInfrastructureRepository.cs", + "sha256": "76a63ad8237342f288d4fa044d1690eee2c28e322bff0896272426a42b19a15a", + "bytes": 10201 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ImportCuratedInventoryPackageUseCase.cs", + "sha256": "0ffdb581e76d059119540c4086d02f92b65b4d1f8d46cdaabce5561d9da1277c", + "bytes": 4729 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/InfrastructureQueryUseCases.cs", + "sha256": "77cc129af02331fa02d35807e6b02a093d2984eeb746205a4c9c4b72b2ee2479", + "bytes": 3006 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ManageManualOverrideUseCases.cs", + "sha256": "476c9e83c81ff5a3633bea2dfe5279a771b25350c2713cdf191cd96d2c9517ef", + "bytes": 4275 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ReviewInfrastructureServiceUseCase.cs", + "sha256": "d62b15b0e0a23e7ec65b31db2ceaa4f129decb097c836ab71f1ad3dbfb3d82a5", + "bytes": 3156 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/SynchronizeInfrastructureInventoryUseCase.cs", + "sha256": "55812fe5d58a27c07b60099f908c311eee649c74a3d157010c01c7459c5fdbf8", + "bytes": 2562 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/UpdateInfrastructureEditorialProfileUseCase.cs", + "sha256": "9c647c6e0968ef15df4dd5c700b97810acc26b8805d431e9ccd58afe6eb9875f", + "bytes": 4143 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/Validation/EditorialProfileValidator.cs", + "sha256": "aa9ed54576f518b99b2480dd1391fe67ac1f649dd1fb6e22dde4246921144e8a", + "bytes": 6018 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/Validation/ManualOverrideCatalog.cs", + "sha256": "8410ac1c9291cd046b5d3696d0bdc15b8f1a30a70e94a49c2e4b985c703e3746", + "bytes": 9952 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/IIngestionProposalRepository.cs", + "sha256": "e64d13b80e62517db336eaff31a4fbfd6405af7b1388c5b2b8c44e49ba16446a", + "bytes": 2043 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/IIngestionSourceRetentionRepository.cs", + "sha256": "96643e0f3b0fb52dbef38de02be7e83072e4ba88284bbd725e3b61ca617fda45", + "bytes": 810 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/IngestionMetrics.cs", + "sha256": "965723358264952bde89f5f3bf8c2e681c7f91dcfb1597d21bbf2055316ac171", + "bytes": 2450 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/IngestionProposalOptions.cs", + "sha256": "54067a87bfd56997cad12bd8c4c546ed50f1c5457f8081c9bf2a4d437709433b", + "bytes": 466 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/ApplyIngestionProposalAnalysisUseCase.cs", + "sha256": "c73fe271e1577e21a1a9c51280740a26a53387e6280f19b02bfc08d64889c700", + "bytes": 2265 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/CleanupExpiredIngestionSourcesUseCase.cs", + "sha256": "4c65635361dd334535fff1d9da4988dcd787937550f035d07d7978f1f021d523", + "bytes": 2950 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/CreateIngestionProposalUseCase.cs", + "sha256": "dca9aa6442bf72a0fa0edc4a6bb39f05fed72cccc225ce1c62192782762de4e0", + "bytes": 1922 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/DecideIngestionProposalUseCase.cs", + "sha256": "f41e850b331cd3d7a651d29901c859cb0f8088598317f36831c3460f800203b9", + "bytes": 2191 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/PatchIngestionProposalUseCase.cs", + "sha256": "dff564af0d629db55a6391a821811ce3f8baa4b17b4f8b76de5df0e5a9a299cf", + "bytes": 1743 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/PublishIngestionProposalUseCase.cs", + "sha256": "8a4db702b0ec89e892b529d90140d8a420c2676ded8570588ac8640a7ee76fea", + "bytes": 9843 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/RetryIngestionProposalUseCase.cs", + "sha256": "25a7f13f19dde4cba8acdf979e01bf5364ad8e097704814dfb7bb714e72da38b", + "bytes": 1619 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/StageIngestionProposalSourceUseCase.cs", + "sha256": "89834979bb9e99e5925f1e00340e7ffa60bb4bc45558ec63e8c4b28a71b7c280", + "bytes": 6435 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/SubmitManualIngestionProposalUseCase.cs", + "sha256": "407ed475aa9acb2624f13bad2882bba956918ff55bdb87cd97d63d42eae06247", + "bytes": 2219 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/ItWorx.KnowledgeHub.Application.csproj", + "sha256": "6fcf2e217eceaca7f4fe66e4ff9fad4492a20436eeb929fab36981597788aa18", + "bytes": 447 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/IDraftRepository.cs", + "sha256": "d5c38dae5d89d09136ca02d1ece1a0493a4ad8592fe5ad420c572abf91f0d098", + "bytes": 377 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/IMarkdownRenderer.cs", + "sha256": "d9e331a4f1b280a79d51e68bf9da02908825d3ed5c980025c2fec82f5d0aac8e", + "bytes": 1084 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/IPageCreationSessionRepository.cs", + "sha256": "a82f935e8343e1db94f246c06e6f001b582d30203403bd372a3ca53cc7a78919", + "bytes": 3106 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/IPageRepository.cs", + "sha256": "2fc8d89ac9628d24c1753806f9f747ec2aeb7d45e5ac7efde078f6eb3cf15eb3", + "bytes": 6486 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/ISearchRepository.cs", + "sha256": "095d2892449217568233006abe22d7602b1be3e554bb932fa16b08f3efab2a4f", + "bytes": 1318 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/ITemplateProvider.cs", + "sha256": "1ba878b702d1ea4642396e6e47a44ec29627d5ddaa66c4b87ee23595eda50b6d", + "bytes": 517 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/InternalLinkExtractor.cs", + "sha256": "74258e29a94fd1309082996ec1e8d10424f8a88b9d98319a43f59c92e99d3351", + "bytes": 1661 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/LinkResolutionService.cs", + "sha256": "d32c39156f3eda7eaf038c73cf063b0203dfb98f1ee079a56659f72ea3142235", + "bytes": 2533 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/PageCreationSessionOptions.cs", + "sha256": "ea7f80be0cd1674f169e760abc5f7cb177bf63274b7f29a68bc64a17d7892f32", + "bytes": 1274 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/RagPublicationOptions.cs", + "sha256": "c9f125d8493ed26bbea1dc7192e3d8aa70d85f9e95b103efc7fe91a605473e68", + "bytes": 976 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/BeginPageCreationSessionUseCase.cs", + "sha256": "8e96da26afb281a9efa053998774d01e1826d59eeb8e61ab0b386ac6c4a6b2f2", + "bytes": 2033 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/CleanupExpiredPageCreationSessionsUseCase.cs", + "sha256": "1cce2f7eda3098b8c0c27c0e1e220805a900232f0a00c5193aefceafc8bece7b", + "bytes": 5871 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/CommitPageCreationSessionUseCase.cs", + "sha256": "287357ace9fb69e7fb7ad00ec4396d8be4a0e91be355c6d7704ef75ca906b20d", + "bytes": 6728 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/CreatePageUseCase.cs", + "sha256": "61ff5e39043ea5c479ff23af297f266f14b80082c5c686298097bf51cd67fc89", + "bytes": 4904 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/DiscardDraftUseCase.cs", + "sha256": "6c8aef55d862add0530606e0dce38d4ff73ff15497edf02f3652732935ceacc1", + "bytes": 547 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/DiscardPageCreationSessionUseCase.cs", + "sha256": "dee3bfc35b34b29a2d84178c6a35c1d0410d32f3648e845604c7475b9aaa8c25", + "bytes": 3554 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetDraftUseCase.cs", + "sha256": "85cf8209151c0f9557c743cfc05446452ba2c46954f8c189d44445dde1978704", + "bytes": 374 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageBacklinksUseCase.cs", + "sha256": "c4d762c7f642448378a38e97167cc94b259f98c43bf1c9068fe06820cc09f103", + "bytes": 518 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageChildrenUseCase.cs", + "sha256": "5fe86dc39e9230cd1adbf51026a7f33a421c3e599304982837c6d5f876bf37b5", + "bytes": 1210 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageRevisionUseCase.cs", + "sha256": "eeda2fab0ec8e0fe6b787255b18222c24a3499f4de7b852dc89e1086d2a04dd3", + "bytes": 1151 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageRevisionsUseCase.cs", + "sha256": "db0765c8b84980553fd4b9de2c811dd62d9349c5cac781ec7a136216d7a545c4", + "bytes": 520 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageUseCase.cs", + "sha256": "9d0366f4f71129de6af0c942bc9efa72ea0531d028ab2de5f2a97db5f4257b56", + "bytes": 2473 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetRagPublicationManifestUseCase.cs", + "sha256": "dd3589364fbc315264167e1e9df70119ae82ea6dc4d4e1211cfa3fc09dec1bfb", + "bytes": 4655 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/MovePageUseCase.cs", + "sha256": "2685636fccb94a4337e66f640ab6eaa37b91f12266f757d247748bf94fb9cc21", + "bytes": 3887 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/PageOperationResult.cs", + "sha256": "579fbfaba1f53de86d92d49ebbb3680c9b956609055bc01cc9028fdf8dd62102", + "bytes": 585 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/PurgeTrashUseCase.cs", + "sha256": "aabe86a5d7e2e2efee14bd464bd1a5eca48090b5bff55459f7acc20a970d602c", + "bytes": 1764 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/RestorePageRevisionUseCase.cs", + "sha256": "4cefc5d99ea4bf1a1e10605e6db864269cc5f8c5b9ad2c5119e414aebd0826c7", + "bytes": 3565 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/RestorePageUseCase.cs", + "sha256": "f7325338cd0ad757642e2e0bb1a0aa287612b030cb03120b296de7172eace5c7", + "bytes": 1626 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/SaveDraftUseCase.cs", + "sha256": "eabbac408ee7d47a4c56db8adb330e9ab98c67125175efa980af168b7288ac46", + "bytes": 2651 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/SearchPagesUseCase.cs", + "sha256": "ce2309db46b1e3316c219b9db203644681036013a273bc22d631c0d568bd0912", + "bytes": 1222 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/StagingCleanupStatusTracker.cs", + "sha256": "acbbb2dc8ebf08d3ac8896238c3e03c515bad9629a8e05637e20bef2e025d771", + "bytes": 1823 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/TrashPageUseCase.cs", + "sha256": "85e74e58ac34c28014b4bae9ee7f848d1fd20f0e6c8078db7ce85d67574db3e0", + "bytes": 2253 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/UpdatePageUseCase.cs", + "sha256": "02aab6d8f94f1012a1687ff9de0fc77ddc3850b63f74d6d37335721649901aed", + "bytes": 3837 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/IImportRepository.cs", + "sha256": "83b956d65957c4fa2c71eb0f059bc82629f4e32c3c752a3b50ea87cc1eed4bad", + "bytes": 1226 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/ILeafWikiAdapter.cs", + "sha256": "a1835af08a327d02b4f31c1c763b24723badc55f19412159b85e2a68afefcfd0", + "bytes": 907 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/ILeafWikiAdapterCatalog.cs", + "sha256": "e34952eab50fb35bdf03c9b42412dc00fcb88819949a051062f4d56f58da5d58", + "bytes": 738 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/ILeafWikiSourceReader.cs", + "sha256": "f731e0dbbe01fb698ba66cbc91019caacb0fd10889bf5235bdc01b9c2e2c731e", + "bytes": 770 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/IPageRollbackRepository.cs", + "sha256": "90c8b8d348237ae017690079a0b7b356de7c43c1dd6203f03e0dc9c7b27ee0f4", + "bytes": 1379 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/ISourceBatchPurgeRepository.cs", + "sha256": "35645610dd085dcf62b3eed3c525f1aa489bb30841f8d088cbe2451cec1c3ba3", + "bytes": 1891 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/ImportPlan.cs", + "sha256": "c8f28923504b4b37088be90cdf30b9569dd6baf7b192901d8960e2cdc867bcfa", + "bytes": 1326 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/SourceFingerprint.cs", + "sha256": "8427511ae1133a325897e6ed32adbe08343564ffbaeee0e9318eed59a95e32fb", + "bytes": 1092 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/ExecuteImportUseCase.cs", + "sha256": "0694273f21367c96db08de251b66e94f4bcc6a5a0d836d69933a789f348310d4", + "bytes": 16963 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/GetImportJobUseCase.cs", + "sha256": "cf7b7abc17c7cea7b6133471c54c734d549c40f4b0d3b9cfe91eb961c295fe07", + "bytes": 328 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/PreflightImportUseCase.cs", + "sha256": "2039c5825aed65800f05cad7551574b7941d50595e369248d05c530271aaffe5", + "bytes": 3950 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/PurgeSourceBatchUseCase.cs", + "sha256": "280f07754ce7e95d1e48f8087240a6cd05273fac6f20f543135d960f454d8443", + "bytes": 3712 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/RollbackImportUseCase.cs", + "sha256": "db4135809d0987c75ef96080d2f0f0059b102a4244e2cc266a2253741b1b3c44", + "bytes": 3524 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Operations/BackupManifest.cs", + "sha256": "f476252932d2087793fda9732070df1fce6eeb223d0721f0a5be6839b9788339", + "bytes": 1912 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Operations/IBackupService.cs", + "sha256": "b2adaca6da7c2ab5eb13f2e896bb4781eb4ef4b8eff201e676f7f7049e5d15c2", + "bytes": 861 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Operations/IRestoreService.cs", + "sha256": "6f6117c1d9446cd040b2d2ec9972d0cc685a46acc9e0cb24fadcba73764fda64", + "bytes": 1428 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Operations/UseCases/CreateBackupUseCase.cs", + "sha256": "d886a29d1f2cdd1a4d18325b9fb5939ba246134acba1988defaa1603665c6af5", + "bytes": 2448 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Operations/UseCases/ListBackupsUseCase.cs", + "sha256": "1eb784e06252b3972feff812182af9d4522811d687e968797480a7d46e0a609f", + "bytes": 283 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/IProjectRepository.cs", + "sha256": "3f9d37422e3731da61e322e90e8b4445a3ab8af631c4fed2570101b2e1ad4057", + "bytes": 3712 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/ChangeProjectStatusUseCase.cs", + "sha256": "9a0772a2218ac888756db4e91121c8a45b71893c93a61aa3ef6f29dca3c93182", + "bytes": 2663 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/CreateProjectMilestoneUseCase.cs", + "sha256": "0c09cc7ef1c26c54eb2aa1ccf4eeaaaea736b7d97d98f0fd1bf25c0091d8e5c3", + "bytes": 2450 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/CreateProjectUpdateUseCase.cs", + "sha256": "4e8ce6e9cba700d21a65363a31e5d700ee26b9de4a0f20ecf695d8a14b9af518", + "bytes": 4478 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/CreateProjectUseCase.cs", + "sha256": "69e53bc155147aeaa21a4d159bf8ed4b44ec613e9f405acde8e5eb1dc65b00bd", + "bytes": 2213 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectCatalogTotalsUseCase.cs", + "sha256": "88abc2cdabbd8ab52ac6515be07e5728149643edfbc1c999fd552901156a0b64", + "bytes": 327 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectContextUseCase.cs", + "sha256": "bc905fd81a6cb60fd06e2a9fa3386b8f33e67dce940ca6afbe5b94f7a8566df3", + "bytes": 5133 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectMilestonesUseCase.cs", + "sha256": "09833d12ba6bd56dc9c57a9e37dcd1e59e2a4d3618a31143b17ac5a2ba3bb2c7", + "bytes": 372 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectOverviewUseCase.cs", + "sha256": "9cea4423d980307ffe13bb8b611570c58589ac0eea9e0945cf769b31e99d0e9a", + "bytes": 2560 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectUpdatesUseCase.cs", + "sha256": "f51f1b0ddd26fb25e12241b811918beb5a7e112035e77c3a0582258d43d2b398", + "bytes": 351 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/LinkProjectDocumentUseCase.cs", + "sha256": "e3773e0f32e5b50e8d07edfb2f914b9323658341c863e0bb369efa83c68b7431", + "bytes": 2401 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/ListProjectsUseCase.cs", + "sha256": "ebbda87d1e31869b747cc1af865e143ee1f677404087f8e0fefced637f74b344", + "bytes": 1348 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/SetProjectIconUseCase.cs", + "sha256": "d2c113d6100d3b743751a45ca988c2805329fc68587cb68a502fdd8093ff0f46", + "bytes": 7312 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/UnlinkProjectDocumentUseCase.cs", + "sha256": "51f38dd52346faf46d366c1379380aac687ffdbee9794a805ed5fa7f50330e50", + "bytes": 1499 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/UpdateProjectMilestoneUseCase.cs", + "sha256": "782396194553e59f8f1b951e1fa625308148731097f4853dea55cb700e1c7bae", + "bytes": 2715 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/UpdateProjectUseCase.cs", + "sha256": "35579002f88c1168bc476ea593245cd6c8688a135c5fb6785014b472ee45c217", + "bytes": 3852 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Runbooks/IRunbookRepository.cs", + "sha256": "ba66358f0e161dd1d95433e02ea1f7df332769e62ab37cb375890af58f922b0f", + "bytes": 1097 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/CreateRunbookUseCase.cs", + "sha256": "c973e3b96d49a8cae500322dd9555c8921c4c2ba5f15f045c4865a5fc6dfc8b2", + "bytes": 3671 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/GetRunbookUseCase.cs", + "sha256": "106a91784b742056050472790e2539b7c94885c455c50664a2984528bd140ded", + "bytes": 1017 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/ListRunbooksUseCase.cs", + "sha256": "576a4f923122935ce1e99909a435145a8bc038fc6fef709a4e3c141168ea691f", + "bytes": 404 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/UpdateRunbookProfileUseCase.cs", + "sha256": "c86fede92f9b9399bb29270cf6c6bedd6e6c01956fe3903f835505d309e2a0c1", + "bytes": 2800 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/ValidateRunbookUseCase.cs", + "sha256": "e095597b190139c1f01afb412e3bbccbdf57d4d19232e9f641755f8108f9010c", + "bytes": 2271 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Activity/ActivityContracts.cs", + "sha256": "4bc4ea6c5217e87fe3fb4bf26b84aa87abc0c8234d35d5c6c89bc1c27ee35927", + "bytes": 343 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Admin/CreateUserRequest.cs", + "sha256": "1e356619562a6ce95c8e6525f6b484aab6515352864e76f0311a21b860fbc7bc", + "bytes": 169 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Admin/IssueRecoveryTokenResponse.cs", + "sha256": "ae7aab2126f1fa2fce016cc1a73a3871b5b1ff2b080a95fc06b1730012dd0c63", + "bytes": 324 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Admin/StagingCleanupContracts.cs", + "sha256": "cf36a275ba811632edb9e2af14329f42678349e4a24408bd368e3b584e752110", + "bytes": 712 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Admin/UpdateUserRequest.cs", + "sha256": "12c0b7551e3f8c040fc461f2414ce5c804d210d267450cc26f33471eac22bf13", + "bytes": 301 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Admin/UserSummaryResponse.cs", + "sha256": "d54783845d869bb03edd751388db17097fb2e1cee09265bc84b42f728eee36d6", + "bytes": 244 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/AssemblyMarker.cs", + "sha256": "f4a5b737454da7c8883e71d39daa1cec302805c67c02067589b5a70fb7d29b83", + "bytes": 81 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Attachments/AttachmentSummary.cs", + "sha256": "f4eb12211104379392e8c844bd1075e7dd12532e7eff5a5fe518373969519bfb", + "bytes": 361 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Auth/CsrfTokenResponse.cs", + "sha256": "c74718c14d73ad6a24bfdf14ea04cb295c3f71ab7363bd8fdaf7ec5b1b19aaa2", + "bytes": 410 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Auth/LoginRequest.cs", + "sha256": "d908627bea430ae93cc8ae3ac301186b8ac209b15131e97b00ac0670188f5df4", + "bytes": 113 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Auth/RecoveryRedeemRequest.cs", + "sha256": "1e285478dabd029e15ddc1501f43a7ac284b1ad64b6d37f177aac317d3a9d18a", + "bytes": 125 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Auth/SessionResponse.cs", + "sha256": "739afe6252275fef45aedaaf40a6fab23670c7ec024811e593c06c6fc21340b0", + "bytes": 809 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Automation/ServiceAccountContracts.cs", + "sha256": "c13217dfe25cb7bb62ca4fd3cd6b67ed6279c8e28caeae067678fdc3fa35197d", + "bytes": 997 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Decisions/DecisionContracts.cs", + "sha256": "7bf010e3dcafb232403b32bf1a8c2ead0fcd47cfc2ffc1eb4be56da4c73d6c95", + "bytes": 1021 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Errors/ProblemTypes.cs", + "sha256": "1df111e70e84d889c6a88dd393f8b3a31a61c89197a8b6da98478f62f2f6b685", + "bytes": 3238 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Identity/WorkspaceMemberContracts.cs", + "sha256": "85690c34c02fd490b422a6ef36f4716ea91e6ef744471bf6dcda89a1aa59a457", + "bytes": 640 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Infrastructure/InfrastructureContracts.cs", + "sha256": "b67a8c57a3da1573bb1e1f723f73b82a0e9ae00d07676b9908e819d191e0d3af", + "bytes": 11056 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Ingestion/IngestionProposalContracts.cs", + "sha256": "ea3963276b4f7c8fb79da9e4f792f0ad610386897ca858d9a2985bf374f1bea8", + "bytes": 4684 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/ItWorx.KnowledgeHub.Contracts.csproj", + "sha256": "5542d279fb7b4b0a15516a8170928a519520f07e48a2b05dd64bc6dde047a2f8", + "bytes": 210 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/BacklinkEntry.cs", + "sha256": "56662869008a175eeaf6c441795169ec7ebd04915b5436b8fcc1bef82e806266", + "bytes": 222 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/BreadcrumbEntry.cs", + "sha256": "611d410c9a47ec2a521de24c8dec02a1a12e187f08d75ef4f6d9b80c5d02aead", + "bytes": 113 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/CreatePageRequest.cs", + "sha256": "ac033b1317750b538bef5552fecfebffe0890f30be11a0265aabb40531e3ce3f", + "bytes": 248 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/DraftResponse.cs", + "sha256": "c51e6129f22f115d669551b58414a567a52ad8b9c726846b02c68157bf989fd6", + "bytes": 437 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/MovePageRequest.cs", + "sha256": "e4c3ebd7e8191a4f825a0e9012ce313df700f74b821999e47545a110a02b0898", + "bytes": 424 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageChildrenResponse.cs", + "sha256": "746653eb1c04cc12ba44e798d0fc359d8e4b355dd82f1d4e2e0c0b2615762d1c", + "bytes": 334 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageCreationSessionContracts.cs", + "sha256": "897d00594961f9ce7b9b774e0c205bd881990e80fdb5fea4be88900f9d37f463", + "bytes": 445 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageDetail.cs", + "sha256": "faec98cc4e610e1135fb06c85795a96e26feebc824f2d9d90c965c8d857ce978", + "bytes": 572 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageRevisionDetailResponse.cs", + "sha256": "d7dc3c6532a6c3e4b57d5c0574011fbc74aae19fdd372a9d9c73e6da5dfb4fb9", + "bytes": 322 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageRevisionSummary.cs", + "sha256": "3f00e35bb671fa0971d7c8a748ae6b0bffb47b1681c38d212f2550d8b784bcaf", + "bytes": 269 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageSummary.cs", + "sha256": "a309477cb676953b85a72b37e13887527ba2bf608bd1e47842b33979868d1573", + "bytes": 263 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PreviewMarkdownContracts.cs", + "sha256": "fc4da9a3ce8c742f65de38e26e8afe2fbb0c4faac3b972592a851d01a16b3467", + "bytes": 182 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/RagPublicationManifestResponse.cs", + "sha256": "5346c835998a19c444dfb9d80b537fe4986c8ad3d3c5d7ba17189d4e0e909c9e", + "bytes": 936 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/SaveDraftRequest.cs", + "sha256": "352fedb03b728326bb0d22cb2d651f460f62e2af6d74a80f38a25baf473a06d0", + "bytes": 321 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/SearchResultItem.cs", + "sha256": "f05fb3b832078aeec40e8f5f87f5f640dc50dfb1a616333c91a87af518eb3843", + "bytes": 810 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/TemplateSummary.cs", + "sha256": "3bb262dbfaefb9f1e8f311f0cf60b431a7975f4679a01e745e84fdecc1d8676d", + "bytes": 156 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/TrashPurgeResponse.cs", + "sha256": "bba32ede9a5e639fcf48d0e323e0aa671f98bca1bf785932cb44d21e2b1dd757", + "bytes": 110 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/UpdatePageRequest.cs", + "sha256": "b546926daa3406c6f904f7d9280059e7f8e342d3bd7bfb9aa294e47ad2a99243", + "bytes": 251 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Migration/ImportContracts.cs", + "sha256": "839822a46c728e20a3608980c58ddcaeedd3ee80b0bffa0961d4a177459693be", + "bytes": 1554 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Migration/SourceBatchPurgeContracts.cs", + "sha256": "db74e5a27f7b9eb96db75977b1c8fb1c1cb81ac748ce11afdac442fe593f45de", + "bytes": 1549 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Operations/BackupContracts.cs", + "sha256": "0a16876805be758d255c1281681aa2ab2568991bca0ed70e793f6007cfe43cd8", + "bytes": 629 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Projects/ProjectContextResponse.cs", + "sha256": "6477193e420877603bd5b816fdc1848c0816e379836364b139cb6e160ccbc88f", + "bytes": 1216 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Projects/ProjectContracts.cs", + "sha256": "ca89b57a4da134415dff9e9191c06cab536044e26543fd5d6bccece9a2dc9dd1", + "bytes": 3723 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Runbooks/RunbookContracts.cs", + "sha256": "c08ec512028ecb7dcc8509e66c9a5bcfcdda3c7de5509be2ce0b7f90a923306e", + "bytes": 1227 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Setup/CompleteSetupRequest.cs", + "sha256": "0519daa08e5afb46950b1010fdf705021e6a8ee8ced8e4cfd923f63d598ac869", + "bytes": 181 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Setup/CompleteSetupResponse.cs", + "sha256": "576c45d58aa08f4945a791e92fd87890a152122487b1234f0a1ece0b8bd1aaf0", + "bytes": 123 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/Setup/SetupStatusResponse.cs", + "sha256": "bea9df35265d90e0ef6aa7275d4de2dea74df6cf471f327bb217dde8204b7c6d", + "bytes": 378 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Contracts/SystemInfo/SystemInfoResponse.cs", + "sha256": "95f2116184cc51e9e4318f3ab08a07573fc19b4ab5f1bf2d010d0b755743a539", + "bytes": 1312 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/AssemblyMarker.cs", + "sha256": "a05f0f51f88ad56ba31b86cc9ad254115b28ba0c7776b63207b2d041a32793d4", + "bytes": 78 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentBlob.cs", + "sha256": "ecefe43f3758398bd29fca7b8f37723f27f753f0ffd4841135f3f8533f3b022d", + "bytes": 2746 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentDeliveryPolicy.cs", + "sha256": "987b84cb6ffed6f57958b124dc7f0a4b81c65a1337716245345efa346cf26054", + "bytes": 1019 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentReference.cs", + "sha256": "5977374a5a431337210a4498af29141a8b9fb750524261ac127c8c9b3b9b77f5", + "bytes": 2659 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentStorageState.cs", + "sha256": "ade532e021f82f04b9e3eb725fd405c42dde1ed4ec0ee2a8bbc15d206e764f8f", + "bytes": 157 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentUsage.cs", + "sha256": "5691f6f401e7c5bf897d955dda36d27f9233e6888fe4755774eeff8e5ceb3c5a", + "bytes": 4405 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Attachments/SafeFilename.cs", + "sha256": "e8a0c5afd12f72cc616b60b95d8391a900e3cc1525b2519820ebf4e4b8c98cd1", + "bytes": 2614 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Automation/IdempotencyRecord.cs", + "sha256": "e0927a081094b00f1c8fae7c71cde1a755333c66d861cf38c3e28e153ea7810f", + "bytes": 1874 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Automation/OutboxMessage.cs", + "sha256": "e9291d6cf512d09f20341c215d022cecfef36b9e0cf714ac200694a539208c4d", + "bytes": 2651 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Automation/Scopes.cs", + "sha256": "a5ccffaa44939bb2e3acca675f2d6346f6d442ef2b841954925e6166501f9105", + "bytes": 2512 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Automation/ServiceAccount.cs", + "sha256": "eb751d7ce9ed17247c209eeacc83ce4a2864e6c12e1a65286a96569ac5122f4d", + "bytes": 1834 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Automation/ServiceAccountToken.cs", + "sha256": "e34a7581c0dbe2d15cbb235a3dd15077ff99e249e8437ae7997e3f16bcfa4f9d", + "bytes": 3278 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Decisions/DecisionRecord.cs", + "sha256": "cfe4edd47b28ca717b78d0954b22d867bebf656109e6691552b4c37002d0a46f", + "bytes": 4633 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Decisions/DecisionStatus.cs", + "sha256": "dc1328d16d9a7a8f13a5f87c8f3d94cc89c16786e5140153dca43b26ef438939", + "bytes": 239 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/DisplayName.cs", + "sha256": "8550e2ada53afa05f916d12f6e83fcebb6f3b3f0a793f91602926246de76cd74", + "bytes": 858 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/EmailAddress.cs", + "sha256": "b342db1a3db0bdf2110d03b30b18e5b36ec536822051d5e9543a72e3bc319542", + "bytes": 1826 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/Installation.cs", + "sha256": "e0d1fa5b4125bd73d95263974a0d1afe892a9d9a7df537d4eb8ab3fc24568d9d", + "bytes": 2341 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/Workspace.cs", + "sha256": "3fcf1385bb5a80a376b3529039c5eb219446c845dbe6bb8ffca846f6095b2174", + "bytes": 1474 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/WorkspaceMembership.cs", + "sha256": "c00334d1e17f1a62e23d19acc0a4191fab2952205a58c9d80fea7ece4e39b463", + "bytes": 1430 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/WorkspaceName.cs", + "sha256": "af534c66b9b76a2cd45cd1c7b276c7d92dbf2d17b4a9a5be3660a74047d0026d", + "bytes": 2116 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Identity/WorkspaceRole.cs", + "sha256": "4a7230d011b27ed6e366889bde4b146c6fb92cd77150df7fdcb25834b040c7ea", + "bytes": 446 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/EditorialKnowledge.cs", + "sha256": "47389ad0d4855b4a64582c115eb6ede83fb2ff6aefcc62a14d2a2c3d363e9a3d", + "bytes": 5828 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/FileSystemPathBoundary.cs", + "sha256": "ce8f080a83bb1f3cbb8fc429b2b6691403a54affc7fb0e9656d0653fe44bf1c8", + "bytes": 770 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InfrastructureEntities.cs", + "sha256": "434ee1885e8d967368d33fe6b5c308b24df613b6581b8efd71dd0095e3f459ba", + "bytes": 17140 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InfrastructureEnums.cs", + "sha256": "47f813ce30ef5878952c9df1326beba835ca936cf48b7604455063fcd11de0b6", + "bytes": 546 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InventoryChange.cs", + "sha256": "d222ccd7b6bcacd168747226f70646edfd1809182c6a233ca06d1a0d9269ef07", + "bytes": 1597 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InventorySnapshot.cs", + "sha256": "4914ec4d49146202cf17153227807ec86e4ba05223fe61e045298f30bdcf367f", + "bytes": 2086 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Ingestion/IngestionProposal.cs", + "sha256": "a7e971fa288b598972ed63e7e6fcae511e4022ac84f45228301ad650b25b2eb4", + "bytes": 18472 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Ingestion/IngestionProposalStatus.cs", + "sha256": "52723bdb96db4b61fe608e8470979bdc483762e8b175fd533519f5d20a95c341", + "bytes": 3075 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/ItWorx.KnowledgeHub.Domain.csproj", + "sha256": "c1b5edb028e42d9dc83f9c8a32e440919de008aac3b8c72ea66cf03facfa5101", + "bytes": 204 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/DocumentStatus.cs", + "sha256": "e5303d9d38a684b7de41a8ae270c17d31fa627c9d70d9139dcb16361cf850674", + "bytes": 169 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/DocumentType.cs", + "sha256": "56a5c151c61ecdb52dab6a17ce152abf89188871c4e2bf81f67bbc969bd94ce5", + "bytes": 312 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/Draft.cs", + "sha256": "2a1109080f1439b27545e949cd77461703054479060ce2b0fb73d5642ffa94c9", + "bytes": 4107 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/Page.cs", + "sha256": "d2f7d5f6dbbeb753af9301362b41b1133dd237632e29d4f0ce6f0b3fc4754b01", + "bytes": 8197 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageCreationSession.cs", + "sha256": "5026b0d587523d0157a248a503f1f76e6f0c75d3e7913855061e0a2eaac2b9e3", + "bytes": 4083 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageCreationSessionStatus.cs", + "sha256": "03ff59010c07406bfef7c1b93783fbc7293e88353f1802b8e048768ae5a50e99", + "bytes": 130 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageId.cs", + "sha256": "970f79f6918ad8ebb5ff6cae3850b5b0d1b15495c3c63f56e3ae9711b5de4b70", + "bytes": 217 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageLink.cs", + "sha256": "a6eb37c26feef0bb118b8a6bb97c0c6d5ee00e41cef780ed4f0112548572b2de", + "bytes": 2059 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageRevision.cs", + "sha256": "f32c1083728aeab7e45440d094530b1b156e3803b954db30edd599c3d1b97e62", + "bytes": 2337 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageSlug.cs", + "sha256": "b1a9ceebcd83a0c45521c49bf887855187510bc6030d41c4e0e76435734aaf4d", + "bytes": 1781 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageTitle.cs", + "sha256": "8dced8ff8e2394cbd1723b04753fb4bae8751d2e05d029415b0f12ee5820e4ef", + "bytes": 847 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/RagPublicationTags.cs", + "sha256": "6cd1f7a8853dcd579944cdbd97849f4796070aace98f273c9c20ddf97b24476f", + "bytes": 4389 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/SortKey.cs", + "sha256": "07216e4576150ac96453edce8489eafae9807c4005553a1f24eb5ce32b13359c", + "bytes": 2283 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportItem.cs", + "sha256": "b6a264874c6060c721cbd5d2d85c3fdf9d77e26421e40db4efefd698cdd5e1e5", + "bytes": 2426 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportItemStatus.cs", + "sha256": "67f25fc74696ab90e2fe83b4dd1fefc49fd9212f9bb654dcc18ad2e4525d3327", + "bytes": 167 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportJob.cs", + "sha256": "88e87ef0fe96a2e4714d1c18d682bba8ce3198b020546d3cff5f482413047c9e", + "bytes": 4771 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportJobStatus.cs", + "sha256": "ad88031f5a4c809fd64f9064a560bdc229162102f3b5b7e5f7fbd71fb5c56ef7", + "bytes": 193 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportProvenance.cs", + "sha256": "463f94642167b95f696a6532c4e179f3923074ebfda103373adda7b2b7c36fd4", + "bytes": 1545 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportSourceKind.cs", + "sha256": "6b9e638c34d51b1415b946f497e4b8662c7717d6ea78bbbf930d227cdb50bfc9", + "bytes": 154 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportTargetEntityType.cs", + "sha256": "c36e8ac9d38dda7afc4302bb2cff47ac5354ebfe45b670b193fde6c1825481d2", + "bytes": 130 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/Project.cs", + "sha256": "03e9e860ce6a9397ca18dd86dc6b62f130e332d5c23a8032ef3548be6f635211", + "bytes": 9175 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectDocument.cs", + "sha256": "a5145414554707f68ddcd2bf91b3df44ddb3ade631b31beb16453892c0190a54", + "bytes": 1006 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectDocumentRole.cs", + "sha256": "47bb9f654e0fd5bd2307a9618af47b94a12a4ce3521c5384f94c797d6d2c26ca", + "bytes": 376 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectId.cs", + "sha256": "31235810ecf15eb9ca0740b1204e925543621e436ded4314571dbd921c97441d", + "bytes": 222 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectLifecycleStatus.cs", + "sha256": "8957f929650518ecd5ef2046be900c64279a7004f55e1393afcfaa0f45c3f3f4", + "bytes": 292 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectMilestone.cs", + "sha256": "6671768e30927ccbbebaf38eafae0b88df86f9d852f6415453e5ece7a7a25fa9", + "bytes": 3003 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectMilestoneState.cs", + "sha256": "42e3cd8ea2ffcb7916c5cdeff2183a26eb283d285e619cc9ddd9754eb401579d", + "bytes": 160 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectPriority.cs", + "sha256": "6f6a4a8f8d9a39450c91e3bf33bbaa7a78a635a6907b3ce254f93c19f8bb3a42", + "bytes": 140 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectStatus.cs", + "sha256": "8d7595bb44c0924012313cc4dc9df304d50d9e994c351c83c88e533956100ecc", + "bytes": 218 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectUpdate.cs", + "sha256": "933822f5954364c5b7e7cd232bd5698f160e637336e25f0c6a7f09924baa814d", + "bytes": 4819 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectUpdateCategory.cs", + "sha256": "2921d236d8f031297dde42b451d2ffdf7b9ff1b0ff9cb11200d4c980482336ff", + "bytes": 349 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectUpdateVerificationStatus.cs", + "sha256": "420c57ec370dc23bc594fc8f537fa3a4d401ebfd89bce72050556830abfbc1ee", + "bytes": 280 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Runbooks/RunbookProfile.cs", + "sha256": "566f89057f31288c3cb81163b5798a71705ecb0b04de4b577d7a07b39e26191b", + "bytes": 4252 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Runbooks/RunbookRiskLevel.cs", + "sha256": "423720b7fa907e67ac45a6abf6dfa9b7fc83c15beda7636797b4d37f5bdb2278", + "bytes": 141 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Domain/Runbooks/RunbookStatus.cs", + "sha256": "e44594d3827cf7d4d1f3ed46e5bf4ca8cd1673bfc0900a0a736337b794c5c35b", + "bytes": 128 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/AssemblyMarker.cs", + "sha256": "dec74426a34424ca6e10fcc7affc2849e6120ad25c9b77b896dcb13b9c95b187", + "bytes": 86 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Attachments/AttachmentRepository.cs", + "sha256": "bdbcf291789a0883fdbf1c2a8873199f8104089f9c4e33e5b5c11b899d3cc0fd", + "bytes": 9017 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Attachments/ContentSignatureDetector.cs", + "sha256": "6ee1b2111bb1131b6cbc8c29875a1940c235638b52400e75a543a33b0f1e1851", + "bytes": 7577 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Attachments/FileSystemAttachmentBlobStore.cs", + "sha256": "48cefcf9f330754b91a3dd4e08aa7e952cff5b60deb5b9c446bb0b2a6bd07af4", + "bytes": 5330 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Audit/AuditEventReader.cs", + "sha256": "8b6328d93c07595bb79ff9986ff3890a6ade9d1bfdaa85a12378b3b6400772d3", + "bytes": 856 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Audit/AuditEventRecorder.cs", + "sha256": "966fb8fb60c8e261176937fe66b6c884c77e0bdc691c1aaa8b6e9e3a1deb91ac", + "bytes": 2179 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Automation/IdempotencyStore.cs", + "sha256": "b3e3dd6e6f6c8c53fcb977721d6d5190fa5e374ae3efc9fc4c835d1052250d20", + "bytes": 808 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Automation/OutboxRepository.cs", + "sha256": "9b3b6ae4048ca463e8e961f215a4665d0a6b6b2790e2939ce51b19e23ac12241", + "bytes": 826 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Automation/ServiceAccountRepository.cs", + "sha256": "927a4d3eb6def6dbdbd5029e8089623648fb340cdc4b1cd0ad44acd744548f55", + "bytes": 2306 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Automation/ServiceAccountTokenIssuer.cs", + "sha256": "eee6c5691a639633d5c7341cc745dadaee30ac7162cef2a8bd76e9274a312846", + "bytes": 2918 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Decisions/DecisionRepository.cs", + "sha256": "92039bc9ce0e1bab64416243d97eedce439f5751ba3b8b7d3ff93a4b12704153", + "bytes": 5646 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/EnabledAccountConfirmation.cs", + "sha256": "a441d6a4061acc96969f603ceaca9b9acece218526fbbb06ce78429deb000474", + "bytes": 670 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/InstallationDefaults.cs", + "sha256": "1e440d241431058efd72216fcad091116b579f282a8c5543ebbccf063be28b80", + "bytes": 383 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/InstallationRepository.cs", + "sha256": "2120ba96a9784122b52aaf3d09c4b4cd50aba8e67518da6f6a746253f0be6478", + "bytes": 2079 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/RecoveryTokenService.cs", + "sha256": "7a09fc6f31b4a6e60eb1b74182ea87257987d28490f89072c3c4af22339a8028", + "bytes": 2095 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/UserAccountManager.cs", + "sha256": "de5e9405a6686f5eb9563b9678ad196b1d06b733d8104d3c3c94674e899fdf9c", + "bytes": 10406 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/WorkspaceMembershipRepository.cs", + "sha256": "af615654cfa076009db969ba3293bd5f0c1b5b108b970b61cbfdbf374af7d7ef", + "bytes": 2278 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/WorkspaceRepository.cs", + "sha256": "d2fb2e9500d076ba6a8d2f0c096303e47b270c4d04e54cefead0d6fdf72cc577", + "bytes": 1219 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureKnowledge/InfrastructureRepository.cs", + "sha256": "14a5ad07630b4a5021b60adbe4f21b2fc1fe0ebc20cfc7d82148aed8a955fc8b", + "bytes": 46297 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureServiceCollectionExtensions.cs", + "sha256": "6e3152184b8ec84c84554e30d482f8577708bdbf01c897166ae992bb99cfc3be", + "bytes": 12268 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Ingestion/IngestionProposalRepository.cs", + "sha256": "ee7db685377749130961f50567e548b46230f7cf44ebaf7e1605205ea53204ff", + "bytes": 9800 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/ItWorx.KnowledgeHub.Infrastructure.csproj", + "sha256": "f43ad4df8cd0ccdd59ab35f4d360a7375ff41b90c09e146648ac2082b300eb98", + "bytes": 1977 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/DraftRepository.cs", + "sha256": "f26a54519b8438f431267290615f4c5af8997319601db0ce4517ee41894871b8", + "bytes": 972 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/ManagedMarkdownControlMarkers.cs", + "sha256": "46ca709375e7bfbe6d8a05d4b784dc4cb1fcd82cc62265850af7a9f941c28317", + "bytes": 1993 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/MarkdownRenderer.cs", + "sha256": "b47b6f266968c087b999971df7d6a6d66158a0ab25b78c767e05b1d09b8dafbf", + "bytes": 5785 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/PageCreationSessionRepository.cs", + "sha256": "718fd2fb95f8a0aefe3590a2adf5ba82515669f26b748566e249d439e7e8b774", + "bytes": 2508 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/PageRepository.cs", + "sha256": "5adbc6a1bb178d4b6782b8744813448d3b9ed34bd87ec2621a26f794db2ed831", + "bytes": 14455 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/SearchRepository.cs", + "sha256": "dead86bcaa367b2a2145527e80985db94876261223584bd66ee5670cd8065cea", + "bytes": 10486 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/TemplateProvider.cs", + "sha256": "03d16448b0e6ea94bc181d4c053b27dc2bb9b6d55b89ff8448910a68bbaa91ce", + "bytes": 3347 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/FileSystemLeafWikiSourceReader.cs", + "sha256": "c662b430a273061149f81e14b74c1dafda25c5b2d11a36798d6272b739c66793", + "bytes": 9995 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/FilesystemMarkdownAdapter.cs", + "sha256": "2cc4ac904a933ded459ee5a822da7c5c633a064c2fcb9b50b370419dd30896e5", + "bytes": 11751 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/ImportRepository.cs", + "sha256": "448523af343769e2e995b676f6a4b891cfafb5273dd8876eaa66f37c78e8f32c", + "bytes": 2572 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/LeafWikiAdapterCatalog.cs", + "sha256": "95669a90fbc68d314885fe92cdbba82c3565666507c4ff27ee7594cca67db62e", + "bytes": 1033 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/LeafWikiV5FilesystemAdapter.cs", + "sha256": "e6e350cc024a29e0515a711271d8a1fcc98bdd029079397345c21617dffe8acf", + "bytes": 28263 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/PageRollbackRepository.cs", + "sha256": "96fe7f7961355bd629ab1518e1d5c8994c775cb9cee8f487590be785039f9fb6", + "bytes": 3146 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/SourceBatchPurgeRepository.cs", + "sha256": "3155060c6ed9dff25439e293ed1aae0a9b1e4e6e5badab7122a58dc880f85d40", + "bytes": 15355 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/SyntheticSqliteAdapter.cs", + "sha256": "385ba973d2d6e7352c9926f7f0692f3683baa9c56c485b58b129de6d7088791a", + "bytes": 7007 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/BackupPaths.cs", + "sha256": "06307665479009f869538cd8b02c067603f9d84bd38e7b247ba45e34e11bf9e8", + "bytes": 343 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/DatabaseHealthCheck.cs", + "sha256": "e499b5449be4ffc6b7e21986fd5793d635ae741030b3b2e260b90379999dc088", + "bytes": 1142 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresBackupService.cs", + "sha256": "6bfd16d3e2d9624d88e316624277ab407c14e88ed9e5f2879d9c1ccd14b45551", + "bytes": 13845 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresRestoreService.cs", + "sha256": "79112a12d80277c4d6fbf3b97ad747ff855d309ac74a8ea0e00c71a8c81aa84c", + "bytes": 8451 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/ReleaseManifest.cs", + "sha256": "a66d471872764249ce540121d7d54a6e0702172c83fb929074e3f879547d2a62", + "bytes": 2732 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/StorageHealthCheck.cs", + "sha256": "37d4178f1d789531b1d4f2f835f2dfff90a4648240592666a3af8b503d57b7c7", + "bytes": 2146 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/TableDiscovery.cs", + "sha256": "51a30c4605eb0e6b1743136244244d7d3394e0ff27c1eaabdaabffe6b3e05e2d", + "bytes": 2951 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/ApplicationUser.cs", + "sha256": "57b170cc56a1fe90f4c9e37501a8c1425438a67b96f4dfde8bb84247e2bbcdc2", + "bytes": 1021 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/AuditEventEntity.cs", + "sha256": "b042afc922bc2b8000432db5de1f026c459fb058e3b3c78ba29049d8b43bed47", + "bytes": 561 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ApplicationUserConfiguration.cs", + "sha256": "bc17d8afb8769c1ca7370b4179001589a4cf53f056c198674c91589f842619ff", + "bytes": 555 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/AttachmentBlobConfiguration.cs", + "sha256": "f1988ee304c77826c3bb7a35d0aa651a076510ce5ac00614336fd1158c98c6a8", + "bytes": 977 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/AttachmentReferenceConfiguration.cs", + "sha256": "ab47d614d0ef5ac38902dcf87bd20944bf07de1f364c5ad9d100f25361139f87", + "bytes": 1322 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/AttachmentUsageConfiguration.cs", + "sha256": "e0389efa13e83ab9badc513e8bca45040b53aeb4a301e0aa4cc47ebcbd49f688", + "bytes": 2519 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/AuditEventConfiguration.cs", + "sha256": "0a4a4be8d48fd2797c1eda1a6139f539612ee677a81deef3a5bdf941d353f91e", + "bytes": 1417 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/DecisionRecordConfiguration.cs", + "sha256": "c42b89b33d71a403bda2a1bb7844d6d009202cd50fd59847aa82b621f6f35983", + "bytes": 2965 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/DraftConfiguration.cs", + "sha256": "a111afe705c1f33797330454707d7b68b88202b9e0b36fd558fedfd37bc59042", + "bytes": 1568 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/EditorialKnowledgeConfigurations.cs", + "sha256": "4e8616e91984d44f3ee1a395cb7dfe44e7bf0506fd6d6fb266712148a64020f3", + "bytes": 3352 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/IdempotencyRecordConfiguration.cs", + "sha256": "df0f4317843b4914024bcc39baae7dfb04edecc7087037f90cb1a6cbc0f08a7d", + "bytes": 1335 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ImportItemConfiguration.cs", + "sha256": "85ddc9aeaa4042bd19b5980e9537d00b1bb691c5a1250ff47efe5cc178c5db80", + "bytes": 994 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ImportJobConfiguration.cs", + "sha256": "5d51453c46dc48edb0a26d71bff00210b3035d20e09263c0a86bf09c6d60074b", + "bytes": 1048 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ImportProvenanceConfiguration.cs", + "sha256": "02d19a1b14b8f92e32bbb0472f34713e1d0a57db36c9dc077cfa6132c5292755", + "bytes": 842 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InfrastructureHostConfiguration.cs", + "sha256": "d8ef0e853e5329e992aa1560fe789db801c301c12213a044ee62030e0d55102d", + "bytes": 1287 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InfrastructureServiceConfiguration.cs", + "sha256": "b9887878c9bf6532589ef559195dfbcd6f2e4cab6087dde383d0a8a59e209d27", + "bytes": 2412 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/IngestionProposalConfiguration.cs", + "sha256": "f8f46a14e05b3d5115d0de16d78dd210f7ffdaa05f9d2b88e4cb44c207f910c0", + "bytes": 5750 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InstallationConfiguration.cs", + "sha256": "a75e1227e8c2eebd4df8f4872d69a680ec6ffb548e615f2239025e9e357897d8", + "bytes": 1344 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InventoryChangeConfiguration.cs", + "sha256": "ef9bcca4ffe7f2eea6c657e1d9f4ee849dbf00861858f40f881cddeb86bc2921", + "bytes": 1172 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InventorySnapshotConfiguration.cs", + "sha256": "ededf6ec0e844622722cb8d28fedd94c7fd8bcebe95e41c0c9126929e3db5a73", + "bytes": 1155 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs", + "sha256": "178b4ebc339a04c0fd17bf2dacc32faf0a7f65588eb598234a25df52883cce0d", + "bytes": 1834 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PageConfiguration.cs", + "sha256": "1d056c3ff24bc47122f32d34b499bc39c59144ff3c53f525270d924c49250f62", + "bytes": 3568 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PageCreationSessionConfiguration.cs", + "sha256": "bc1e8dbba8f117d40c30d2e169e214a1f74e90fd0177bfe2fdf3e7ff08de8124", + "bytes": 1582 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PageLinkConfiguration.cs", + "sha256": "627189435806fd6c614e42d90a3cf109e7fe1d856030397406042fec1ed8a797", + "bytes": 883 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PageRevisionConfiguration.cs", + "sha256": "8c34f8ad4df1aaf8025c6ce555b29416f5efdd4aedb3fc3cfa38e7c7886481a6", + "bytes": 1434 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs", + "sha256": "eeacfc9a485dfe616a8e65650339947fc95c519687aaa4ca8b997ff21edb533e", + "bytes": 3194 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ProjectDocumentConfiguration.cs", + "sha256": "701f12b8a5a5e2c4e66819bb85a4d05bba01b2edc670e94feaf195445ecc1fc6", + "bytes": 957 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ProjectMilestoneConfiguration.cs", + "sha256": "d72e435cd6789339af8e2df1600ad3cafdb2659111427a9e7fd0e32a1bf5448c", + "bytes": 1717 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ProjectUpdateConfiguration.cs", + "sha256": "257f9614c0f39a5afc22c32a9bbff5bd011cf4f165075c3e00d64ef0b4087a58", + "bytes": 2204 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/RecoveryTokenConfiguration.cs", + "sha256": "5a6aa1649427a14b0016587de43635fdc02f18a7a4bb796f6f25d9b0c812f380", + "bytes": 771 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/RunbookProfileConfiguration.cs", + "sha256": "17dfe164b07a80469317ec35770796da5d838f73f865da4f62b44091e547422c", + "bytes": 2007 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ServiceAccountConfiguration.cs", + "sha256": "05f7566a384f6efc66187098b28337a9a6dea49df36cb2ee2d9df01d821485ed", + "bytes": 1157 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ServiceAccountTokenConfiguration.cs", + "sha256": "aa4983c755881dae8aaf57ad27fb7a5ea730fd7b89f9af69823f0bd4e96566d9", + "bytes": 1447 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ServiceFactConfigurations.cs", + "sha256": "72102ccde04a119dc184406db54846de68518273aacd16e8939c0fc56fc4217b", + "bytes": 6176 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/WorkspaceConfiguration.cs", + "sha256": "3e3622cb241a137cbe553aeee321c218cd428ce628a30c0b971144965f35675f", + "bytes": 853 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/WorkspaceMembershipConfiguration.cs", + "sha256": "e330a5532015c1475523aa70754004096228b7812728d633fa926cf112ecbf47", + "bytes": 878 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/DatabaseConnectionOptions.cs", + "sha256": "082d951535b6df7a8e8b1d922ea63c310a026bf6c458b859bc36eb9ab6c59cc6", + "bytes": 371 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/DatabaseConnectionStringFactory.cs", + "sha256": "ef652423b5f1d8a4235138fb8baf8247194661ec01ffff2bc5d4fc62068275ed", + "bytes": 1463 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContext.cs", + "sha256": "b456897a42d4576917c1ebc6936a5e740c99b63464be2a2d7b7a2c2dc83a4b5f", + "bytes": 5958 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContextFactory.cs", + "sha256": "7776afa1cdb9451c39fb52d37168b6472579ef4cfc130841c8691a208f6b5c0d", + "bytes": 1077 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731162849_InitialCreate.Designer.cs", + "sha256": "46effc45cdea0b166c8dbb8462ba6b5232d770fc70216eaaa09033d053da0d70", + "bytes": 19665 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731162849_InitialCreate.cs", + "sha256": "a76b3a2bfe8039d55a5818cb9d65e441865a427c4df1d282b36755bbe2aefcc8", + "bytes": 14084 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731185224_AddKnowledgePages.Designer.cs", + "sha256": "ced1eb0c7275dc843b538e9034a41593d3b242b9ce1ade9d02fd0b5e1ad0c02e", + "bytes": 31845 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731185224_AddKnowledgePages.cs", + "sha256": "9aab5aed585fe58984584f768384f1860f205ee9a9c088087280e1784f29ad99", + "bytes": 8884 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731201729_AddDiscoveryAndSearch.Designer.cs", + "sha256": "a352e67918091c2ef60acda11093a55146727e196269d1a1059eab80c0cf6ea8", + "bytes": 32054 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731201729_AddDiscoveryAndSearch.cs", + "sha256": "e9d73c49bfa24d1888227586aa32926ebef9992577d4094f67c158e5c3fc6b8e", + "bytes": 4767 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731215422_AddAttachmentsAndImports.Designer.cs", + "sha256": "6e6a1d792e4633ecd308a01e94338d05d2aedbeadbf1bd448916007e82952c93", + "bytes": 45859 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731215422_AddAttachmentsAndImports.cs", + "sha256": "28e0db6377e9b5a0b5531eb95935dc84fde278e10745bb2a77ad51d4dc5a770b", + "bytes": 10562 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260801013144_AddProjectHubAdrsRunbooks.Designer.cs", + "sha256": "1206414497c45936abe96a6993686391eb41ba9b6a1c735cae649e261b0dcfde", + "bytes": 65369 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260801013144_AddProjectHubAdrsRunbooks.cs", + "sha256": "8b6a267309ec4be99f1b44c37db82e4a4393ee1abbf77a61c2498f21d9e5fa5c", + "bytes": 14044 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260801023652_AddAutomationAndOutbox.Designer.cs", + "sha256": "e766c4626516d705d8904eda31eda68bea1280cfcbfbb6d8c3aa2ee7ae738ca1", + "bytes": 74306 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260801023652_AddAutomationAndOutbox.cs", + "sha256": "02526db84cc8b7ca30a43479470a791cd6e33f94ef55bd01d3f78a0e99cf1a05", + "bytes": 6832 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260802040639_AddStructuredInfrastructureKnowledge.Designer.cs", + "sha256": "cff9c22c72cb7490cb6ca224197a277cee0cdbde2d2ca833d3542958e3d2afa9", + "bytes": 111371 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260802040639_AddStructuredInfrastructureKnowledge.cs", + "sha256": "0e4bb6d2ae5e18407cbfb4b74286dfdd5a833cb83d74f452241c7be6c1b8d787", + "bytes": 32958 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260803193236_AddPageCreationStaging.Designer.cs", + "sha256": "f99e8cdee431be06981b36242da01e6c8ff2011d62962ad656922bfc93f3646d", + "bytes": 114381 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260803193236_AddPageCreationStaging.cs", + "sha256": "5eca746e697518fc9ead6e8d09a06902bf61ce550fb6901d6fa7796dce246387", + "bytes": 4251 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260803195722_ExtendDraftMetadata.Designer.cs", + "sha256": "02b05c4c45aadb2b9c0eb720b6e9776f155ce1f95ff135caa382618002483630", + "bytes": 115243 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260803195722_ExtendDraftMetadata.cs", + "sha256": "b2f9b13872c641de3d58b54be8319af635a568ba03feeb16c30750ef89080825", + "bytes": 1857 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260804005007_AddInfrastructureServicesDetectedNameIndex.Designer.cs", + "sha256": "b94f1d13a804ee35a3286aff631699fc0a78478ea941c647e87fe771ad005f23", + "bytes": 115460 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260804005007_AddInfrastructureServicesDetectedNameIndex.cs", + "sha256": "a8b9c3465d2c3626fb2998bb4439a05db68e2b7773c5a2431fbfc754be4fbb2a", + "bytes": 914 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920120000_AddIngestionProposals.Designer.cs", + "sha256": "7288bc485eb105a5971a8120f282ca2f245e1d3bb6a84eca735fc6f4cf650684", + "bytes": 126857 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920120000_AddIngestionProposals.cs", + "sha256": "399049a315e5cac2131e964614e42bfc70945de108dbd2a959d03a4dfb2141c1", + "bytes": 7707 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920155218_AddIngestionProposalAttachmentUsages.Designer.cs", + "sha256": "3e54b8a5e4aaa81bd7f588ec74de3f95cff335a870c8dfdae562d8c963967ad3", + "bytes": 127635 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920155218_AddIngestionProposalAttachmentUsages.cs", + "sha256": "f145293b34186da861314af8d9d11ecb75fb32e5263618642c9deaead44cf351", + "bytes": 2618 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920173238_AddIngestionPublication.Designer.cs", + "sha256": "8201e6ef926b33da0511d4814850be3bcfa0e19fd9c369015b0e307e843df3ca", + "bytes": 131112 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920173238_AddIngestionPublication.cs", + "sha256": "4474288b4b762af30080d27526a9bdf1a952bd2b903c868b01c38cf489967fdc", + "bytes": 4559 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260923221837_AddPageTagsGinIndex.Designer.cs", + "sha256": "1909debeadb1dad39a1c8e4823eef079af2db468032705dc883accc3ab9d39cc", + "bytes": 131298 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260923221837_AddPageTagsGinIndex.cs", + "sha256": "72288097d2c02207e82ed7efbb18ea1565b1d75066faa9ec1b113bd30715c40c", + "bytes": 784 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260924182209_AddProjectIcons.Designer.cs", + "sha256": "03035c0b25976008d9f10d560a667bfe01e4aae285e2506ab156eb86a3913f69", + "bytes": 132691 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260924182209_AddProjectIcons.cs", + "sha256": "29718e07eab4ace7c338b69b31536ca97a9c8c5d01ba49a6078f409b5becf900", + "bytes": 3832 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/KnowledgeHubDbContextModelSnapshot.cs", + "sha256": "0eb1a59c19a2ac9afa587c53d46b9f41a0f836925b84756c72d7cbbf3102a786", + "bytes": 132595 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/RecoveryTokenEntity.cs", + "sha256": "8b191dd8b4d388b8cfcdfd8549cc3c8f3aa515451ebc95afce5e38bdfbe342a1", + "bytes": 569 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/SchemaGuard.cs", + "sha256": "6903b8571b5ee9d6048a74fb3473c20950a8a9754715af113d8890a9085b313f", + "bytes": 2566 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Projects/ProjectRepository.cs", + "sha256": "6b69dde4243a86e437c14145fdd08f102547d4a86f0c5e8c78672840f594b1cc", + "bytes": 13959 + }, + { + "path": "src/backend/ItWorx.KnowledgeHub.Infrastructure/Runbooks/RunbookRepository.cs", + "sha256": "d5b25afe37ce718ace1a370e57eba4880749b7f6f1be8518630a7a93d30643d7", + "bytes": 5290 + }, + { + "path": "src/frontend/eslint.config.mjs", + "sha256": "f2b1ded6fa9f74260438527be5969e1ae9d708df596ed6bba09e6b9ca51e93e3", + "bytes": 976 + }, + { + "path": "src/frontend/index.html", + "sha256": "01a6f25ed87a6b2ebab9a6ae9a1d2ede2baffb73785930595aaff02fa8f2520a", + "bytes": 598 + }, + { + "path": "src/frontend/package.json", + "sha256": "ace9c70d5fe5ae6076d4f52494b3fd41fe2c44c4bca518c4233bf6a2a450605c", + "bytes": 1826 + }, + { + "path": "src/frontend/public/favicon.svg", + "sha256": "37fd61466b3a818994e57d56ab8625e7803b94775ccff349b2a05e9fe737c198", + "bytes": 302 + }, + { + "path": "src/frontend/public/project-icons/blockpilot-autonomous.png", + "sha256": "427889b0183ad56c4895dcdc3acb9b458b98ad61fa61b388d3f8c4504ebe0496", + "bytes": 55470 + }, + { + "path": "src/frontend/public/project-icons/chimera.png", + "sha256": "7c9eadb384e0b2832e426045dfdbda4f1ed298ae5d3605c8beb90040172eb292", + "bytes": 353492 + }, + { + "path": "src/frontend/public/project-icons/devrunbook.png", + "sha256": "15bccb4ec53777e93ad3c07ac6aa83878898f61f9ae73f63ee415dd936cd029e", + "bytes": 15050 + }, + { + "path": "src/frontend/public/project-icons/dockdeck.png", + "sha256": "b15cc1330d5d79639eca6efe88af796f2448d9bad71eee61fbd339092a673b0a", + "bytes": 100522 + }, + { + "path": "src/frontend/public/project-icons/evidencevault.png", + "sha256": "ff41dabc59bc8c0ce24abe8188b99c1d629a59f971aae41c3e5d9ad116900e20", + "bytes": 23068 + }, + { + "path": "src/frontend/public/project-icons/geointel.png", + "sha256": "3521f712a10796ddaa88c6ad6368997c4c2d30f06324df884c31c22e4b639575", + "bytes": 25979 + }, + { + "path": "src/frontend/public/project-icons/gifted.png", + "sha256": "04856261a6de7124fac925f4d992e9e29042d7a43f0cd74f02f1a6f2e17cb8b5", + "bytes": 17248 + }, + { + "path": "src/frontend/public/project-icons/infrabinder.png", + "sha256": "a29c0c974fa31a7afb234e5fb61ee68c3e000dafdf0ccb1a6ce05e3354d38277", + "bytes": 13330 + }, + { + "path": "src/frontend/public/project-icons/itworx-mcp-hub.png", + "sha256": "10082dc4d75e3fe93a46137b76ef036d95e04af5ea9aaab491f1fffcbca1d1c7", + "bytes": 1242042 + }, + { + "path": "src/frontend/public/project-icons/knowledge-hub.svg", + "sha256": "37fd61466b3a818994e57d56ab8625e7803b94775ccff349b2a05e9fe737c198", + "bytes": 302 + }, + { + "path": "src/frontend/public/project-icons/ludarium.png", + "sha256": "29862374bd95218010a21722fe82e43c469f7180c41d753421000f71843a49ce", + "bytes": 34655 + }, + { + "path": "src/frontend/public/project-icons/lumaops.svg", + "sha256": "b978309cbf85b0fad32793337244805d7451ed330e12b8673ca15007f016f584", + "bytes": 577 + }, + { + "path": "src/frontend/public/project-icons/mcop.png", + "sha256": "486a6e8d5a5a3cb840b888cf0e5396701de2b4e5f2cabf9a5c02ba4abc595c26", + "bytes": 10754 + }, + { + "path": "src/frontend/public/project-icons/mobilityops.svg", + "sha256": "3d92feb153ec1cd1b6a523cbcb9b20a1c010eae6da4cd0c4e5cc9a47d4b8b8cd", + "bytes": 344 + }, + { + "path": "src/frontend/public/project-icons/pokemon-legacy-rom-builder.png", + "sha256": "967ed6d5806f12d36950a39893ab6580d58cbc334e2b289413b130d53f578e38", + "bytes": 103981 + }, + { + "path": "src/frontend/public/project-icons/portfolio-itworx-tech.png", + "sha256": "8b20df10effac8e8fc404db829275b23b437d54d1c42c57600bce1d66d072d1e", + "bytes": 54726 + }, + { + "path": "src/frontend/public/project-icons/project-launchpad.png", + "sha256": "35bc3d8be877c3cc5d4a702be7c7562cff73fdf3991e421b2758dccf129bc53c", + "bytes": 7293 + }, + { + "path": "src/frontend/public/project-icons/projectbrain.png", + "sha256": "83bcd21e2f2aeb09cb2543d3812a84ad9bd841168cfed7b9b937c25e08231b8e", + "bytes": 29208 + }, + { + "path": "src/frontend/public/project-icons/psforge.png", + "sha256": "82bcafb6f1afa6eef9352febb7106170a63a60b4678c25b1a55b4d95bac94855", + "bytes": 7379 + }, + { + "path": "src/frontend/public/project-icons/quizmonitor.png", + "sha256": "bb6b1b4553fa23ae0c61d7026d946e33237a26595e16ac34ed16093ee9cd486b", + "bytes": 3091 + }, + { + "path": "src/frontend/public/project-icons/ragcore.svg", + "sha256": "3cb8ee1ba0b9223ba4324baac06821de74efc6f0185a884b451738ea1cc45fb8", + "bytes": 568 + }, + { + "path": "src/frontend/public/project-icons/spoortracker.png", + "sha256": "240e218c23e97b6ab0aa2a70d3c837d4e88b763ee250ac92765cf0a52183e600", + "bytes": 4602 + }, + { + "path": "src/frontend/public/project-icons/unraid-appops-gateway.png", + "sha256": "1c388d073c49546182cb6ff2587df04601f8e2d32eb9f1038bb1a4a6fca63de8", + "bytes": 6567 + }, + { + "path": "src/frontend/public/project-icons/vacatureradar.svg", + "sha256": "e5fa24a32a3206e44526b1e5e12dabbc3bf0d5968ef4110cb7d451a4b155aa62", + "bytes": 464 + }, + { + "path": "src/frontend/public/project-icons/widefrog.png", + "sha256": "f97ff062faba1acc2d4ad280f00b87a7f7ca54f0e3565d405fda8137aaddae6c", + "bytes": 8546 + }, + { + "path": "src/frontend/src/App.test.tsx", + "sha256": "6d3682d2a5489a85d802a5111d0ce5918e4479e1f37971a6c6a966af0deb901c", + "bytes": 4991 + }, + { + "path": "src/frontend/src/App.tsx", + "sha256": "8b29bb586d679c01fd1b3e8a945cc60078df050dcf81e368dc4c49141131d6f4", + "bytes": 2789 + }, + { + "path": "src/frontend/src/api/activityTypes.ts", + "sha256": "807fc7880567fe41cee14eddeb7f1fa839527b13f8dbed7bfb0f7de09a93ca00", + "bytes": 289 + }, + { + "path": "src/frontend/src/api/adminTypes.ts", + "sha256": "5e1f23d7ae48e94e70f7ce3445dd6804d80873bd927bb562883a9b2b7cef155b", + "bytes": 1976 + }, + { + "path": "src/frontend/src/api/attachmentTypes.ts", + "sha256": "028e1f1be74d91d1f9d3c39e024cf5e694a68219ce59f914e0aac9992fdc3afe", + "bytes": 290 + }, + { + "path": "src/frontend/src/api/client.test.ts", + "sha256": "fb8feafa96a8e619f7a79b2b647167169e3bf7e50e2cb6f4d9ca4c9333364ae7", + "bytes": 2241 + }, + { + "path": "src/frontend/src/api/client.ts", + "sha256": "e6bb3514a6ee97dc7b198994628dcaf281a7b723f4bd0ffc07bff423a23c3768", + "bytes": 6411 + }, + { + "path": "src/frontend/src/api/infrastructureTypes.ts", + "sha256": "13fa7ba0d356cd081b59840a7f586b48141ccdee7e18548853ce33c1157e0339", + "bytes": 5955 + }, + { + "path": "src/frontend/src/api/intake.test.ts", + "sha256": "cac6ce0a0fc9b21f6190b9f19b68a94b7fcdd791b2fc12288765904f8544fde4", + "bytes": 4765 + }, + { + "path": "src/frontend/src/api/intake.ts", + "sha256": "62ce3e9604296f68aca3515f96ced52fb870e6a59d52134737bc6150561fcd0b", + "bytes": 4441 + }, + { + "path": "src/frontend/src/api/intakeTypes.ts", + "sha256": "ad1037654fbfacd3116fa7862362ec5fb8031086ea4800aaddb5087bae98147e", + "bytes": 3656 + }, + { + "path": "src/frontend/src/api/knowledgeTypes.ts", + "sha256": "212d1a540875ce687a25e864966ecd785b6a0a84332529ae7e0bd80b10eedd1c", + "bytes": 4101 + }, + { + "path": "src/frontend/src/api/projectTypes.ts", + "sha256": "fb8dda2261fd54dcedecec55bf8b2898cc82f356ee47736366500aca5e30dea2", + "bytes": 7785 + }, + { + "path": "src/frontend/src/api/setupTypes.ts", + "sha256": "8a64981191ec496492e45b194829ae2d57c42004c075ea8f9505b0764b3fe38a", + "bytes": 82 + }, + { + "path": "src/frontend/src/api/types.ts", + "sha256": "58be4ccc1003181ba995c13ae1ba30c0f11e6a9fcddc423e9307f2356cc21f71", + "bytes": 617 + }, + { + "path": "src/frontend/src/auth/SessionContext.tsx", + "sha256": "bd75bb6e09b78b04aef7a4f621482ec4a3344e2bd29c130dc89b2e27d7742182", + "bytes": 1467 + }, + { + "path": "src/frontend/src/auth/session-context.ts", + "sha256": "4e242cfebd7d9d9f3c5b8bbd1940fcb29fa6f24b6f3cbd526e654929104617f7", + "bytes": 617 + }, + { + "path": "src/frontend/src/components/AuthShell.tsx", + "sha256": "3222e264ae7524802cc4e3cdcb86403c1de591d91439406a68daeb2eb9c6955d", + "bytes": 1756 + }, + { + "path": "src/frontend/src/components/CommandPalette.test.tsx", + "sha256": "728f321a25070b3da081d2f1c4c19c144eefdc23a18a612a0a1497c242e7fa42", + "bytes": 5663 + }, + { + "path": "src/frontend/src/components/CommandPalette.tsx", + "sha256": "22aee35c90ef6006073562b5ae9fe0255d213199ca538d4fd3a2c3158543870f", + "bytes": 12265 + }, + { + "path": "src/frontend/src/components/DataTable.test.tsx", + "sha256": "a98eed66bd060fddd584add5efeebd37003728274b8680c2d90d8ced17274db3", + "bytes": 7384 + }, + { + "path": "src/frontend/src/components/DataTable.tsx", + "sha256": "f92e216bb04fda99b680f5710eabe6acea8dab9c8423f1ec555b8e51327041fd", + "bytes": 10019 + }, + { + "path": "src/frontend/src/components/EntityDialogs.test.tsx", + "sha256": "6a33f0c9db1191da1daa93f24d19f0dea1a86084a3b167e31370ced081420ed3", + "bytes": 5999 + }, + { + "path": "src/frontend/src/components/InventoryReviewPanel.test.tsx", + "sha256": "2e6e4b7c6d727d2b0a606ba6a735995de4a29280d8e4622afc4d2bf586865cbb", + "bytes": 3174 + }, + { + "path": "src/frontend/src/components/InventoryReviewPanel.tsx", + "sha256": "d88c29419f5b8bdd662d0759eac9f62ec5c7deb00ff19fc573e676d368457118", + "bytes": 7844 + }, + { + "path": "src/frontend/src/components/LeafWikiImportPanel.test.tsx", + "sha256": "740584be9dc12f063deeae9a54677708f16b898644c6cb66c8f628d487c5c47d", + "bytes": 3927 + }, + { + "path": "src/frontend/src/components/LeafWikiImportPanel.tsx", + "sha256": "a69c67c12a754da08d0db5d48ef80e7b36cfc1b74085a0d2f3ce79c0cab7685f", + "bytes": 9547 + }, + { + "path": "src/frontend/src/components/MarkdownContent.test.tsx", + "sha256": "88280950c55bf2de0e1b96a60d12138b4fa9289acd6da30642825b1934977230", + "bytes": 3694 + }, + { + "path": "src/frontend/src/components/MarkdownContent.tsx", + "sha256": "b44e224cbc05ed2b56a1edd3121e3e22e127f0539f0e498449a7ab82831e7e41", + "bytes": 2037 + }, + { + "path": "src/frontend/src/components/OwnerPicker.test.tsx", + "sha256": "b627f7d665333aec494e34764f3ea195238abce270b6c69b69767613c9177de2", + "bytes": 3913 + }, + { + "path": "src/frontend/src/components/OwnerPicker.tsx", + "sha256": "143d2a2d96d21b46ba41bdacd147a49d21fc3e50b448721ed0ca802ab5b49da5", + "bytes": 7310 + }, + { + "path": "src/frontend/src/components/PageActions.test.tsx", + "sha256": "2aff591fb173a4eb6887bf0640ae2dc131ac513d930ed0cb0d23e2912045f49d", + "bytes": 3984 + }, + { + "path": "src/frontend/src/components/PageActions.tsx", + "sha256": "23dca85a1aa79f6ca19200488af593808dfa32ea3c5d143fa7f2f5578c896c13", + "bytes": 10032 + }, + { + "path": "src/frontend/src/components/ProductPrimitives.tsx", + "sha256": "ac8b7aa028c792b649a0df3a6da651aa1818377bfc7746cc3ccbda2a0697feec", + "bytes": 2090 + }, + { + "path": "src/frontend/src/components/ProjectDialogs.tsx", + "sha256": "d385c5b17c611b391c11facb9966f7cb0d0d076b23715ce7b4837de96047ad51", + "bytes": 11824 + }, + { + "path": "src/frontend/src/components/ProjectWorkspaceActions.test.tsx", + "sha256": "5cc188e83132f23d8d778dc70abc7037024ced268254708b9a8adc1599b90335", + "bytes": 6183 + }, + { + "path": "src/frontend/src/components/ProjectWorkspaceActions.tsx", + "sha256": "96af22a2cc25df2dd5e1a7f775a3ae5e302b5530f87fc9684708f17aa62864cd", + "bytes": 15540 + }, + { + "path": "src/frontend/src/components/RunbookCreateDialog.tsx", + "sha256": "06c60b1d14d60cc2638b7ba9480e7af1c25766f0ead85de82b26d1042dea6503", + "bytes": 4779 + }, + { + "path": "src/frontend/src/components/ServiceOverrideManager.test.tsx", + "sha256": "6b5596796a4733d85bf726166ecbaca1db92df9e1973db9cb42ec460e8808e47", + "bytes": 5987 + }, + { + "path": "src/frontend/src/components/ServiceOverrideManager.tsx", + "sha256": "b3be450753cec5ae662070bf98a1bd2fe87da4c4c33fa2035a922bb509b2af13", + "bytes": 7269 + }, + { + "path": "src/frontend/src/components/Tooltip.test.tsx", + "sha256": "7e45a31916ef6effa4118bccfd7bed3db9a852e8942440cab95421632e571994", + "bytes": 1784 + }, + { + "path": "src/frontend/src/components/Tooltip.tsx", + "sha256": "098b59565771d08f2889adc2f2aee97201bbd1d09b65fd780d1c2e0e38e65fe1", + "bytes": 1781 + }, + { + "path": "src/frontend/src/components/infrastructureFormat.ts", + "sha256": "241bd9adb63e516c94dfcdf6c70686119373c28ae1e1010c4beb50d3c150e360", + "bytes": 203 + }, + { + "path": "src/frontend/src/components/ingestion/EmptyState.tsx", + "sha256": "c917f29ea427db8e1e277bb2e26983900838b07f683c12918cec1cbe75dae3d7", + "bytes": 351 + }, + { + "path": "src/frontend/src/components/ingestion/ErrorState.tsx", + "sha256": "b0fbecc2cb873e9b95d2337131a70b82d20214587d2967e4298ab618f16ff667", + "bytes": 812 + }, + { + "path": "src/frontend/src/components/ingestion/IngestionProgress.tsx", + "sha256": "32fa02e1e6ff61ed931faa9b553503eda2e06034a395e39eec9bf9807a6938c5", + "bytes": 305 + }, + { + "path": "src/frontend/src/components/ingestion/IngestionQueueCard.test.tsx", + "sha256": "11a68093a22e9a174cae284486cf3403841b345b5456ff5a94ed1bf2d72c6788", + "bytes": 2801 + }, + { + "path": "src/frontend/src/components/ingestion/IngestionQueueCard.tsx", + "sha256": "5aee3f46fc0905e632b2b07a1e701df2ce78b9d8561adca4eacbc69b4977d25b", + "bytes": 1770 + }, + { + "path": "src/frontend/src/components/ingestion/IngestionQueueFilters.test.tsx", + "sha256": "cbb5167071678c7c9ae5d4ef1a81bd6829726c8490e1e5d7dd23323608d6cc3b", + "bytes": 1650 + }, + { + "path": "src/frontend/src/components/ingestion/IngestionQueueFilters.tsx", + "sha256": "a3d75b8682675caf5982b78f1079cea30a2f465acf656da0fa2f01197d2231cc", + "bytes": 4069 + }, + { + "path": "src/frontend/src/components/ingestion/IngestionQueueTable.tsx", + "sha256": "7c76c4b5f8ce417bbdd72dfa2a48267e05730f208140dd86a3678e4835806ff1", + "bytes": 3185 + }, + { + "path": "src/frontend/src/components/ingestion/LoadingState.tsx", + "sha256": "cd112058bd9a9d8a329550f65d6fd5f28734713db4021fe940379081618e2b12", + "bytes": 377 + }, + { + "path": "src/frontend/src/components/ingestion/ManualDocumentIntake.test.tsx", + "sha256": "7fbfcc3386745a14a56e35688be7d6a54182f357d9e550109bbff7e93fd9575b", + "bytes": 5532 + }, + { + "path": "src/frontend/src/components/ingestion/ManualDocumentIntake.tsx", + "sha256": "9d85e521d7e16df96fbda9a966065a358ae98b530ff33410484bb7cee56829b8", + "bytes": 5817 + }, + { + "path": "src/frontend/src/components/ingestion/MobileReviewPanels.tsx", + "sha256": "bc0b380967993edc9eeedd4bbdf645a311c24a94d8c4368ebe8ddd07bf7c7487", + "bytes": 1501 + }, + { + "path": "src/frontend/src/components/ingestion/ProposalContentDiff.tsx", + "sha256": "0550fa213f6430377152d961d96a85ab7f1ea4b8348099ccae4fbd1020aa54d2", + "bytes": 1009 + }, + { + "path": "src/frontend/src/components/ingestion/ProposalDecisionPane.tsx", + "sha256": "e2cd6555c96bdd8d005d8ed7be26afc0bd37cb447ee0175b645dc038c503ecb8", + "bytes": 2296 + }, + { + "path": "src/frontend/src/components/ingestion/ProposalEvidencePane.tsx", + "sha256": "4dc0b3f27cbaf31de0b6ee9ec776d71980afdfc5aa47ce424f839edd57b6f71e", + "bytes": 1950 + }, + { + "path": "src/frontend/src/components/ingestion/ProposalMetadataForm.tsx", + "sha256": "5103c6d0c51de98bcdaee5e70e730778d71d1f30366ef5940358b74a8f32dd66", + "bytes": 2897 + }, + { + "path": "src/frontend/src/components/ingestion/ProposalQueuePane.tsx", + "sha256": "5c91a162bf39479bed3c5625447400665c5f8eb6ab70a0dad96eb4c12215744d", + "bytes": 766 + }, + { + "path": "src/frontend/src/components/ingestion/ProposalReviewHistory.tsx", + "sha256": "cda9d69ac4a9c34f833e940405ec54f089efc891f639e5c4d5515e5e00d4047c", + "bytes": 825 + }, + { + "path": "src/frontend/src/components/ingestion/ProposalSourceList.tsx", + "sha256": "ad0b7fd032e523ec6291809a162afe2e03810a2efa3029dcc1c25fbe0beeb6f9", + "bytes": 759 + }, + { + "path": "src/frontend/src/components/ingestion/StatusBadge.test.tsx", + "sha256": "d83abe2cabfce64565861ab20c48a0d2b34d47db24e17dcd15cfb48764d43e73", + "bytes": 1548 + }, + { + "path": "src/frontend/src/components/ingestion/StatusBadge.tsx", + "sha256": "e410ebf5f9095d2cbca33285d0e9f094d746ebdeaa4228a62bc75a3dbead8970", + "bytes": 1872 + }, + { + "path": "src/frontend/src/components/ingestion/ingestionQueueFilterState.ts", + "sha256": "2df507e0a54c9b7f9848a1975f1c06646073f1669d02a14c407daa64169d33fb", + "bytes": 2205 + }, + { + "path": "src/frontend/src/components/statusLabels.ts", + "sha256": "9711673b4cf102dc790e12ec1179bebe002f48ffee33efbfb9e8b019e645ddda", + "bytes": 3679 + }, + { + "path": "src/frontend/src/design-system/auth.css", + "sha256": "6012643644c8dd2d67fbea51a69a61f09ff3fae9a1570d414804a602171a0b78", + "bytes": 5675 + }, + { + "path": "src/frontend/src/design-system/base.css", + "sha256": "42df86a609b82bafe3c0c1585db94e0bc8736f76091bf9eea645c2a53ed90ee3", + "bytes": 2261 + }, + { + "path": "src/frontend/src/design-system/components.css", + "sha256": "05513d987cf9dca9bd0a460c0e5063e26d9cf18b35430f4e063d21ac0c64f4fb", + "bytes": 24623 + }, + { + "path": "src/frontend/src/design-system/documents.css", + "sha256": "6e12961e364e8d117354a9ab4cac7a46162d2d1252848e2eb43abf892ed7672b", + "bytes": 7042 + }, + { + "path": "src/frontend/src/design-system/editor.css", + "sha256": "7c19d13c29f92be98317067bc9465fb9cabf3ddba4f9004056029147d2b427d0", + "bytes": 6999 + }, + { + "path": "src/frontend/src/design-system/forms.css", + "sha256": "264238dd76215afeb69e62e0e4949535876a8fdab17ba63445f99b7da753f777", + "bytes": 9072 + }, + { + "path": "src/frontend/src/design-system/responsive.css", + "sha256": "229fcc6e8a67078daabb36aac5e3265ca0b06892500e5f7ea901a3ae8e82c4bf", + "bytes": 8672 + }, + { + "path": "src/frontend/src/design-system/shell.css", + "sha256": "3f6eeab9771ece799ef486bf9c8593070f4f8f20be0abce9c924f006195b0a93", + "bytes": 10347 + }, + { + "path": "src/frontend/src/design-system/tokens.css", + "sha256": "3aa66166f06719569ec3906dd52495075adef217c1942b65402c9f18b936cf8b", + "bytes": 5566 + }, + { + "path": "src/frontend/src/design-system/typography.test.ts", + "sha256": "e683614e7a558a6cebbf73a01588bfd0d2595f367ce9f117c93b0995a5714a53", + "bytes": 6757 + }, + { + "path": "src/frontend/src/design-system/workspaces.css", + "sha256": "410521fdbfe7ab23425f988055096845f7e396a759d9493b1ce9fd0dfcc6d0a4", + "bytes": 44803 + }, + { + "path": "src/frontend/src/hooks/useDocumentTitle.ts", + "sha256": "5534a1472a981c98c3408fe53cf97ceb2d76b5bebb74129472859345b565f847", + "bytes": 266 + }, + { + "path": "src/frontend/src/hooks/useMobileViewport.ts", + "sha256": "54f806bac73c6cfd476bebf94e18887361945d0336494496a120c4bc5d42951c", + "bytes": 2223 + }, + { + "path": "src/frontend/src/hooks/useOnlineStatus.ts", + "sha256": "97eb14b3e9cc86cb963e7a2b64853034352a96bfbd33e8bb19a7664759265e72", + "bytes": 1303 + }, + { + "path": "src/frontend/src/layouts/AppShell.accessibility.test.tsx", + "sha256": "1a384b988564c29da990b1fbe3bf807d7eb0095f8069d21d73c915fb5e36a20d", + "bytes": 2544 + }, + { + "path": "src/frontend/src/layouts/AppShell.tsx", + "sha256": "06bda7a6984a8e88a30d8d5f433d204c275a9af55c68b00c4b8db8b0a9533dd6", + "bytes": 25060 + }, + { + "path": "src/frontend/src/layouts/active-entity-context.ts", + "sha256": "6473ee840c649799c8154d2b8905e2ae05862c4fc4da945d621dcd7fa6cf2527", + "bytes": 2152 + }, + { + "path": "src/frontend/src/layouts/context-shortcuts.test.ts", + "sha256": "9e989f1b92093062d9477a4c7e2159f6f630f7c9d0d5ab088f1a97d4f4a1ba9e", + "bytes": 1636 + }, + { + "path": "src/frontend/src/layouts/context-shortcuts.ts", + "sha256": "97c42c2b64bb077e569abfc059cd2f805f82593197ace022e3094150ae640e2d", + "bytes": 903 + }, + { + "path": "src/frontend/src/lib/clipboard.ts", + "sha256": "77c402ff2e4534f29128088e558329c1cf3036a3b0f2a96677a6c3bc70188653", + "bytes": 1977 + }, + { + "path": "src/frontend/src/lib/uuid.test.ts", + "sha256": "aea10ca1598b963b6240675a7aa3a71618109eefc5f324cd0d5c6dcd2280e5b8", + "bytes": 1334 + }, + { + "path": "src/frontend/src/lib/uuid.ts", + "sha256": "542adb8514eaa15bfdcc257a1609a68fdefd94c8b005246397e0104846ecd9cb", + "bytes": 2106 + }, + { + "path": "src/frontend/src/main.tsx", + "sha256": "a5323c4abcc96b43a2a1e83bb3988f84be503075a0d90a7daf46f083b79807f9", + "bytes": 925 + }, + { + "path": "src/frontend/src/notifications/ErrorBoundary.tsx", + "sha256": "193ccfccca3d3dd18db64d309ad0a2f13956a5fa0d1e5d5b99ad03bd64e39d9f", + "bytes": 3274 + }, + { + "path": "src/frontend/src/notifications/OfflineBanner.test.tsx", + "sha256": "324dc3e70ccb0342335d8b1e44271e910b21317163b89e4c45c950c508c8fe13", + "bytes": 1898 + }, + { + "path": "src/frontend/src/notifications/OfflineBanner.tsx", + "sha256": "0c16dabe2000c8d775ed8b838768a6a6b02ab322e07529ea118a56e1290475b7", + "bytes": 1287 + }, + { + "path": "src/frontend/src/notifications/ToastProvider.tsx", + "sha256": "6ed0217d7c21867ba7f2b278a9de2c7c8494b23f1d08fdf8923874dc14d5bc70", + "bytes": 3622 + }, + { + "path": "src/frontend/src/notifications/toast-context.ts", + "sha256": "b4fbe6217feab3d02e3a49b45b5e6d5383ce8600e432202deedbaf659ec7e1b7", + "bytes": 2542 + }, + { + "path": "src/frontend/src/pages/AdminPage.courseTagging.test.tsx", + "sha256": "1fd3d7478bda0f4d7dbaa7a26ba6320265235d43488e426123c58231bf8dd01c", + "bytes": 11619 + }, + { + "path": "src/frontend/src/pages/AdminPage.test.tsx", + "sha256": "fdbce01e913a1faf3af6a2cd0728e4846a20f6bea302bccfd2409b7a9de8192b", + "bytes": 15685 + }, + { + "path": "src/frontend/src/pages/AdminPage.tsx", + "sha256": "c401bd59c461cc4d9c6771afcd115a2982321a257155800139251368d810992a", + "bytes": 38145 + }, + { + "path": "src/frontend/src/pages/LoginPage.test.tsx", + "sha256": "0735439984705d1b1e91c40a56bfef67b6161a7050dca5a24c6c2f419acea2fd", + "bytes": 7510 + }, + { + "path": "src/frontend/src/pages/LoginPage.tsx", + "sha256": "e31525d48251bb4b21ab50ecfe941440e52a1b58fd019b61a7ebd219b03cb2be", + "bytes": 10718 + }, + { + "path": "src/frontend/src/pages/SetupPage.test.tsx", + "sha256": "58684772100475407c40e9450d6aee7484f146148feaf77ff260f7c3c7a10546", + "bytes": 1611 + }, + { + "path": "src/frontend/src/pages/SetupPage.tsx", + "sha256": "370e52b926012a516e2413bf5b48ba292ed6d496796ec5ec03cc25728d4a75c8", + "bytes": 4681 + }, + { + "path": "src/frontend/src/pages/StatusScreens.tsx", + "sha256": "be2ca9e5abf4c90aaff36c76a520b682b6966e73e44bfb1258fcf9fb63c18bc1", + "bytes": 535 + }, + { + "path": "src/frontend/src/pages/knowledge/Attachments.test.tsx", + "sha256": "41f060d518de44e15322c6d25cf81ff4e9ad7cb420bcc8613d6a0c786320824b", + "bytes": 4158 + }, + { + "path": "src/frontend/src/pages/knowledge/Attachments.tsx", + "sha256": "934ea510c84d1ad0a56c136eb95aefebc5ab0d592368e9780aaaf963d7168194", + "bytes": 4605 + }, + { + "path": "src/frontend/src/pages/knowledge/Backlinks.test.tsx", + "sha256": "fc6119da886624491d0ee025ec60dcebc19554ecaa8ec51b276fcd23d24043c9", + "bytes": 1970 + }, + { + "path": "src/frontend/src/pages/knowledge/Backlinks.tsx", + "sha256": "b52a36b30790a00b541bc57026e0a54b42474e2550829b795de5e1af61a73fd6", + "bytes": 1887 + }, + { + "path": "src/frontend/src/pages/knowledge/KnowledgeTree.test.tsx", + "sha256": "393ca8197144c518c4b192b9af05e2313300e12c1c508f3d673a9ca4916546e7", + "bytes": 12505 + }, + { + "path": "src/frontend/src/pages/knowledge/KnowledgeTree.tsx", + "sha256": "669cc8f5ec3dbb6058a1f121b0de55a9bf51d390151807500cae6430a9033bdf", + "bytes": 16468 + }, + { + "path": "src/frontend/src/pages/knowledge/PageEditorPage.insecureContext.test.tsx", + "sha256": "fcd6c673d5c323756a63c495eb965a4f4703053a1db8abd9c47fc4032832e2ab", + "bytes": 2441 + }, + { + "path": "src/frontend/src/pages/knowledge/PageEditorPage.test.tsx", + "sha256": "fc4013443bd4312fb2ad01c21b6a127e60a85baed40860f97fe296391271e80e", + "bytes": 23744 + }, + { + "path": "src/frontend/src/pages/knowledge/PageEditorPage.tsx", + "sha256": "ad31836a900aa7c9b149fd675853d77d91e050c1d3f8955709a563b89703f636", + "bytes": 31286 + }, + { + "path": "src/frontend/src/pages/knowledge/PageViewPage.test.tsx", + "sha256": "0c67d1d09d4e641ff18ee92e2753598c5eee2d3cb46686e6469addfe6fff3069", + "bytes": 6567 + }, + { + "path": "src/frontend/src/pages/knowledge/PageViewPage.tsx", + "sha256": "65625b32065bccea4057e351f2e9d6ce9b877b25f49fd38f724159ca514ae350", + "bytes": 9717 + }, + { + "path": "src/frontend/src/pages/knowledge/RevisionHistory.test.tsx", + "sha256": "5d6b17aa5e1ac7a45bbfaf2f04648628abe58228626a94b196510461de95466d", + "bytes": 3850 + }, + { + "path": "src/frontend/src/pages/knowledge/RevisionHistory.tsx", + "sha256": "7f93dc9acebc576ef42264c65f1147683983c469891b5999695bea8dfebf69d1", + "bytes": 7536 + }, + { + "path": "src/frontend/src/pages/knowledge/SearchPage.test.tsx", + "sha256": "a08aa1bbed51daac290eab427f35727d77383ef3942da2bbeba0a7818038d2f3", + "bytes": 7571 + }, + { + "path": "src/frontend/src/pages/knowledge/SearchPage.tsx", + "sha256": "20926e56e1cd7fdd619dcd79b681a29783b4ce9f5a7b09436432ea8ee0cedea9", + "bytes": 7207 + }, + { + "path": "src/frontend/src/pages/knowledge/editorAppearance.ts", + "sha256": "a6fe89a104c018d5ef61160201bb9c4ce963ae7668120e4954750ae44d23ff5d", + "bytes": 5922 + }, + { + "path": "src/frontend/src/pages/knowledge/knowledgeMetadata.test.ts", + "sha256": "06ba8598a6dc76c48f9de131c476d860b643ffa2205d95e1d02eafccbbabd40a", + "bytes": 943 + }, + { + "path": "src/frontend/src/pages/knowledge/knowledgeMetadata.ts", + "sha256": "fa54f6da13302e2864741fc53f16506139349f82b333cf33bd88e7085166fff2", + "bytes": 1653 + }, + { + "path": "src/frontend/src/pages/knowledge/recentKnowledge.test.ts", + "sha256": "242769a93daae7022bcac98adf30d4ba57a1750b94f787a03673a052b838896b", + "bytes": 972 + }, + { + "path": "src/frontend/src/pages/knowledge/recentKnowledge.ts", + "sha256": "972d03910c80f9b5eeaf9721665061ba60ec38f46ad989821430c532bcc3cf99", + "bytes": 1095 + }, + { + "path": "src/frontend/src/pages/knowledge/revisionDiff.ts", + "sha256": "3b3a7b54cdde2c7c9cab6bfbb34c36e66720f9031d9612e25d4fdc259bb5522a", + "bytes": 941 + }, + { + "path": "src/frontend/src/pages/projects/DecisionDetailView.tsx", + "sha256": "6a7d2dc4fd079f547ca3240d477aef8247e44bfec6138d9f3a9a174d1f0714b4", + "bytes": 9339 + }, + { + "path": "src/frontend/src/pages/projects/RunbookDetailView.test.tsx", + "sha256": "c82229b148f7557c2ceb3e1b290465bb42147aef3e43f96795c24abddf6f9c08", + "bytes": 3529 + }, + { + "path": "src/frontend/src/pages/projects/RunbookDetailView.tsx", + "sha256": "b152ab77859438ee2093f482b606dbd4df42cf6c88b3603d3c964e4e758d4f2c", + "bytes": 10943 + }, + { + "path": "src/frontend/src/pages/projects/formHelpers.ts", + "sha256": "50f1bc388997fc5696f8076e1b6fa756fdc39e9def166b4f12b47e5bfcb2c592", + "bytes": 464 + }, + { + "path": "src/frontend/src/pages/v2/ActivityPage.test.tsx", + "sha256": "e831b5392ccfe398e9fac6f834f128b7104ab1c5597d2ba0003980b7016e1b5c", + "bytes": 2017 + }, + { + "path": "src/frontend/src/pages/v2/ActivityPage.tsx", + "sha256": "9de80f11748165a4366d260842b1f10e94741086cfdb90fa3dd965d5e3a8a6c7", + "bytes": 5491 + }, + { + "path": "src/frontend/src/pages/v2/AdminRoutePage.tsx", + "sha256": "eed028b2549cb07d6e3bf24fecc433bb34614accdf7edae8f0e4de77ad676039", + "bytes": 202 + }, + { + "path": "src/frontend/src/pages/v2/DashboardPage.test.tsx", + "sha256": "e5f9c7de670616a02598f89e475c022a94b8ea2d58190a77082ee28561e04176", + "bytes": 9522 + }, + { + "path": "src/frontend/src/pages/v2/DashboardPage.tsx", + "sha256": "dbcb0cdab67c76d2049c3b4c4d2b9ffc6e1a8115ea92c150164d857b4c1418f8", + "bytes": 11706 + }, + { + "path": "src/frontend/src/pages/v2/DecisionWorkspacePage.tsx", + "sha256": "aa0a3085bda7b9126c863e23c339d537903b23ca451e79789979bd3ac851b9c9", + "bytes": 1199 + }, + { + "path": "src/frontend/src/pages/v2/InfrastructureOverviewPage.test.tsx", + "sha256": "ee6f69ada39849a55ffff66a8810e81e3aef34cb3104346175b14bd54fe0f265", + "bytes": 3110 + }, + { + "path": "src/frontend/src/pages/v2/InfrastructureOverviewPage.tsx", + "sha256": "81d5bd6d68ce719e61db421941d2c3c92a8691c88bfcd6d3dc733a414929ff2f", + "bytes": 5289 + }, + { + "path": "src/frontend/src/pages/v2/IngestionQueuePage.test.tsx", + "sha256": "1235119a2619473f50d8c3f388fb7e04976794ddab5c6c556d9a02f73c875966", + "bytes": 14414 + }, + { + "path": "src/frontend/src/pages/v2/IngestionQueuePage.tsx", + "sha256": "c2256a0f9d4f935884dc98ccca91e0778c7564070ae4f62702c210ec94eb88f3", + "bytes": 6873 + }, + { + "path": "src/frontend/src/pages/v2/IngestionReviewPage.test.tsx", + "sha256": "495c6094bb36d99767864bd0c361982be0534fba08146edd5e9fea791959adb8", + "bytes": 25611 + }, + { + "path": "src/frontend/src/pages/v2/IngestionReviewPage.tsx", + "sha256": "a49e5aa2bf1b7f509e437b4ccacaea9ea2698a7fa8f5770a29402d8d3527d0ce", + "bytes": 11124 + }, + { + "path": "src/frontend/src/pages/v2/KnowledgeEditorRoute.tsx", + "sha256": "7c3cb59c3bfe5841dd7f82505446da3d5822c379479c0bf3c227baeecba681d0", + "bytes": 2380 + }, + { + "path": "src/frontend/src/pages/v2/KnowledgeRoutePage.test.tsx", + "sha256": "633e803a017dfedd9646df3682ff51b9feddf33aae26ab25f36b28ee7333fef8", + "bytes": 7081 + }, + { + "path": "src/frontend/src/pages/v2/KnowledgeRoutePage.tsx", + "sha256": "78ecf930f452ef844a8236ba62cf97865add07ae8be3bd1f888a5c5a9b6a219c", + "bytes": 9898 + }, + { + "path": "src/frontend/src/pages/v2/NotFoundPage.tsx", + "sha256": "2de25eefa9cde911f01e142bf92a4685b48c163bc6a45049181ba3a17a2d1f88", + "bytes": 573 + }, + { + "path": "src/frontend/src/pages/v2/ProjectWorkspacePage.tsx", + "sha256": "b15eae6398795dd9b7f06697903e22e6c79b17a9dce8fd60b63ea30d5fba62d0", + "bytes": 15139 + }, + { + "path": "src/frontend/src/pages/v2/ProjectsOverviewPage.test.tsx", + "sha256": "5e9029619595cef9711220cfebd17ccf1e34744d14f5a1b2697bfbc1114af677", + "bytes": 9715 + }, + { + "path": "src/frontend/src/pages/v2/ProjectsOverviewPage.tsx", + "sha256": "fe84386f4c339db910f592a459311b4dc5308f4734699796285aedf967998af8", + "bytes": 17151 + }, + { + "path": "src/frontend/src/pages/v2/RouteStates.tsx", + "sha256": "65ab0884f4f3882adfbc0a5ebdb41a3a5bc933efcad1aa36f48aa005034070b7", + "bytes": 1666 + }, + { + "path": "src/frontend/src/pages/v2/RunbookWorkspacePage.tsx", + "sha256": "d21f3610ed654aea55cdcaa5aa8b60b162ad8c7b65f591d2724686cad52920fc", + "bytes": 991 + }, + { + "path": "src/frontend/src/pages/v2/RunbooksOverviewPage.test.tsx", + "sha256": "a431231392ca97c22bb153e617faed724ef521df76f53220f9daae2fe7dc3c05", + "bytes": 1567 + }, + { + "path": "src/frontend/src/pages/v2/RunbooksOverviewPage.tsx", + "sha256": "609827d6cda62adf1945355b2b30dc94b76b8525f45f4c77f231ab965918b1d1", + "bytes": 4010 + }, + { + "path": "src/frontend/src/pages/v2/SearchRoutePage.tsx", + "sha256": "bf6cb16f4c0773011538f5c93640fcce12022ed4b1d8ffa541a6e7d8932bb3b5", + "bytes": 352 + }, + { + "path": "src/frontend/src/pages/v2/ServiceDetailPage.tsx", + "sha256": "15210ed67f9edc0d5448e58959e9f5ccbf728fdf7161e0e5aa7e6c61f96261df", + "bytes": 20205 + }, + { + "path": "src/frontend/src/pages/v2/ServicesCatalogPage.test.tsx", + "sha256": "a86a4a185a4d48a65d3d5b35073b97eef3977c944c82722ea454234186e264f1", + "bytes": 9277 + }, + { + "path": "src/frontend/src/pages/v2/ServicesCatalogPage.tsx", + "sha256": "186446a5ccf3bc61093c161ed9d0cca58fabdd63979b73bb011a9f67e163557c", + "bytes": 16560 + }, + { + "path": "src/frontend/src/pages/v2/SettingsPage.tsx", + "sha256": "a19495a86a45e7a3a1854e014b092fb45eef17124e0216a4f73288be4d68f143", + "bytes": 8044 + }, + { + "path": "src/frontend/src/pages/v2/StoragePage.test.tsx", + "sha256": "6e7065424c4d29325973217ce32d33ec0b24cf4b6d571769d856fbfe113b2b22", + "bytes": 2528 + }, + { + "path": "src/frontend/src/pages/v2/StoragePage.tsx", + "sha256": "12f21266029100c803416d72c98d6fe2ec3692355a643397e1779fc699ab1410", + "bytes": 8109 + }, + { + "path": "src/frontend/src/pages/v2/ingestionProposalMutations.ts", + "sha256": "cf55775adf60969d96dce338bd7f1eaafb33bcd11cfe245e21e6aa7c6766e11d", + "bytes": 1401 + }, + { + "path": "src/frontend/src/preferences/PreferencesContext.test.tsx", + "sha256": "3c6253589bd474435c344d27591b5e0946805112ae1a3ae697c41ad2701cae66", + "bytes": 7534 + }, + { + "path": "src/frontend/src/preferences/PreferencesContext.tsx", + "sha256": "5919e3b6097439653a077fb43a6a374262f6486d9189d61dbc092d08cb20a017", + "bytes": 6486 + }, + { + "path": "src/frontend/src/preferences/preferences-context.ts", + "sha256": "28391797b9529468d52ec0573a61be0c1170cacd00cfd8f4c9221b1315b2cf20", + "bytes": 1538 + }, + { + "path": "src/frontend/src/router/AuthenticatedRoutes.test.tsx", + "sha256": "1c2b4a125d1f95c22dd255d80119733ca30ae25b015946090046e77ed02ba0dc", + "bytes": 9912 + }, + { + "path": "src/frontend/src/router/AuthenticatedRoutes.tsx", + "sha256": "20462a0607f21728be97e6467d0bc5b7ee3879f6ef7bf12f84b904cd2ccf366a", + "bytes": 3962 + }, + { + "path": "src/frontend/src/test/motionTestUtils.tsx", + "sha256": "7a68f4f27f462770e4ef86618e238c7eed82a51a566a0f4cace6c35226197692", + "bytes": 2159 + }, + { + "path": "src/frontend/src/test/setup.ts", + "sha256": "8782a831c3470856266e93f37c39c071950f68958639143049685386a30628d1", + "bytes": 1044 + }, + { + "path": "src/frontend/src/visualizations/ProjectArtwork.tsx", + "sha256": "47a32cc9b5d9604d08c84b9d8d1a2989d71a4372cfd10e3db02130c909f08edf", + "bytes": 1542 + }, + { + "path": "src/frontend/src/visualizations/ProjectSignature.test.tsx", + "sha256": "27c77afbdb5671eadf466fa85a9a1ba71bdf0bb5fd0d7c2ff6f3ee6865c54b71", + "bytes": 5743 + }, + { + "path": "src/frontend/src/visualizations/ProjectSignature.tsx", + "sha256": "f7bd4c57b82f9be0b5b93e1c74e495e0519a7061207e59d6d90276b7764d6ec8", + "bytes": 2570 + }, + { + "path": "src/frontend/src/visualizations/ServerMap.test.tsx", + "sha256": "9c152f41578f7c1229dbcce75f3838d25b4968f1759521260573fada01e46faf", + "bytes": 7736 + }, + { + "path": "src/frontend/src/visualizations/ServerMap.tsx", + "sha256": "e092478b3a5e32c78b15a664ba936c2fc807bf821780a94e75161d44982d261d", + "bytes": 4998 + }, + { + "path": "src/frontend/src/visualizations/ServiceTopology.test.tsx", + "sha256": "87f1fa37d22494b14bd2cee6d2978c30bba4e9e77ef0533096f8c9adb11f46e0", + "bytes": 5579 + }, + { + "path": "src/frontend/src/visualizations/ServiceTopology.tsx", + "sha256": "6b8d77a77debdf198ea2c69a8f4912ce4aba842671fd6cb2a6e5795a536beb58", + "bytes": 2969 + }, + { + "path": "src/frontend/src/visualizations/hostLabel.ts", + "sha256": "0cf72bffc447e4fa8271e7c2f8304f57d9fd3d4c7b7cb6299449940471b43a21", + "bytes": 834 + }, + { + "path": "src/frontend/src/visualizations/projectArtworkGeometry.ts", + "sha256": "8b8b8124736263343b4d2c63882eb5230606395a2e322b0a6128397006e3b5b1", + "bytes": 996 + }, + { + "path": "src/frontend/src/visualizations/projectLogoSource.ts", + "sha256": "88c5123b49d0fdd36f8d20bfddc875e7fa1326154702233a8c7c0d843afd244e", + "bytes": 2123 + }, + { + "path": "src/frontend/src/vite-env.d.ts", + "sha256": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5", + "bytes": 38 + }, + { + "path": "src/frontend/tsconfig.app.json", + "sha256": "787debd064cab0f699e9ded5a1a8d46236a802dd35756871f7d3da65064b7133", + "bytes": 696 + }, + { + "path": "src/frontend/tsconfig.json", + "sha256": "770b4140bbb581e2dfd9ea9946ffc9c75a1d86ba7d2db5f77c83e37cbdf9d808", + "bytes": 119 + }, + { + "path": "src/frontend/tsconfig.node.json", + "sha256": "da44988fbbab51979e9e52dd5f6a9854ff1511880082f4e17e15dc7bbdca067a", + "bytes": 457 + }, + { + "path": "src/frontend/vite.config.ts", + "sha256": "47e30c5389e6189649e55601974c5184b9af9a1a0d8715c320639d7e22f1f553", + "bytes": 1194 + }, + { + "path": "src/frontend/vitest.config.ts", + "sha256": "c10fc444e6bd01da9d219f418203e0d483673dacdb91281e0a3e15681872a717", + "bytes": 912 + }, + { + "path": "tests/backend/Directory.Build.props", + "sha256": "b0a567b357d9616ffcadbe8b8170643fa98392a0c427ee27deb3cc03eb35646b", + "bytes": 578 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/ForwardedHeadersTrustTests.cs", + "sha256": "9aace185b11e25f193677eb235d7f84fb75337217c3d1da79ed00a5d7001a0a7", + "bytes": 4654 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/GlobalUsings.cs", + "sha256": "50436eb3a60162e0cfad749eddfb06f8908f821a90ca44532df946a53e071359", + "bytes": 20 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/HealthEndpointTests.cs", + "sha256": "bcb11a815d34a01f5ad5d76e65640a1e2d39e0092786ac83a1fd335f20cadf93", + "bytes": 1702 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/ItWorx.KnowledgeHub.Api.Tests.csproj", + "sha256": "ac500693872ad89e1f82ab15342c895bf4c0427e4db6fbbf3a4bad0bec211ef8", + "bytes": 719 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/KnowledgeHubApiFactory.cs", + "sha256": "2be914fd004cdc46b61dbe7c6726c672cb729b2f6a8eab834fd6ee85e67b3b94", + "bytes": 1145 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/RestoreModeRunnerTests.cs", + "sha256": "a929db8f9b7cb5624392ec42c63160965cce78e50081e01ba83bc72f545a0766", + "bytes": 3535 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Api.Tests/SecurityHeaderTests.cs", + "sha256": "455e6322739575e1f3ba4a67c88ec1781e7dc245bdfbe20616a23fb1b7b37657", + "bytes": 2011 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Architecture.Tests/GlobalUsings.cs", + "sha256": "50436eb3a60162e0cfad749eddfb06f8908f821a90ca44532df946a53e071359", + "bytes": 20 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Architecture.Tests/ItWorx.KnowledgeHub.Architecture.Tests.csproj", + "sha256": "43044be136fbcca44499bb237f9a3a655c0f7d7499cab9ce0fa1681652ff4c4e", + "bytes": 511 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Architecture.Tests/ProjectReferenceTests.cs", + "sha256": "25f0e49444022866c57c246f4fa171c97e11844c0c09a4d2c3a2b9ea5aa173c9", + "bytes": 2520 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/AttachmentUsageTests.cs", + "sha256": "6f8bcb85df3b59b9a4432c304f1329d8053a74a13dc402e8d21d06257d997c31", + "bytes": 473 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/DecisionRecordTests.cs", + "sha256": "35ca6db2abf98f84530244b94fd8d10789c333a8b98fd522df1f700aae51a954", + "bytes": 2919 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/DraftTests.cs", + "sha256": "630fa8c6ae9a01e5819bf01cb42a7bf749bf6c1d9d6046c201755a84aacd273c", + "bytes": 2281 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/EmailAddressTests.cs", + "sha256": "441cdd5399caf8ef23b05d7f803b99c4c71c19b8183f2af78217f322954b9c16", + "bytes": 875 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/FileSystemPathBoundaryTests.cs", + "sha256": "2ad617bdc1cd392a5f784aa11bb8dbf1d97710a0331187fd85bd17d2b6f48e21", + "bytes": 881 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/GlobalUsings.cs", + "sha256": "50436eb3a60162e0cfad749eddfb06f8908f821a90ca44532df946a53e071359", + "bytes": 20 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/InfrastructureKnowledgeTests.cs", + "sha256": "37b87f9661a15c45e6c8da318354190cd68a51f6f9bdc0ce8429e5473df477c6", + "bytes": 2301 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/IngestionProposalTests.cs", + "sha256": "d1fc168870abbb9fb2ea09a164ca8a687ba5e9a4bf750c13fa7c876cbc581389", + "bytes": 16179 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/InstallationTests.cs", + "sha256": "2c2761d5a92acc7328bb3f84a283c8f4b516ec06c07daf334f66098f5f6803be", + "bytes": 957 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/ItWorx.KnowledgeHub.Domain.Tests.csproj", + "sha256": "c44e6c3b9ab1dea0f22d4533e74fc89adcbe039118a384e164d844803bbeaf54", + "bytes": 876 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/PageCreationSessionTests.cs", + "sha256": "cc0b2cade55bae47275eca75a76eecc042eb9a1a6af5113d38ef91bc5a744fc5", + "bytes": 2934 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/PageTests.cs", + "sha256": "1c1187908b6c9e2f05e04e5dae5d8cbded3a6debc99eaf4b18039efaa48d4edc", + "bytes": 4642 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/PageTitleTests.cs", + "sha256": "c720221a2a8b2cb7cb91508acfec97edcd624774456d1f5cea2bd5656fd83c31", + "bytes": 781 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/ProjectTests.cs", + "sha256": "6baa1ac26163e3de2463d57c2003ddf1826c8ec94ab4264fddb78ccc337e99b7", + "bytes": 3113 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/RunbookProfileTests.cs", + "sha256": "30259da4f5ffbd219fa28d4741e0d78f05a3598d824aa7b62aa0e45cdc72ef77", + "bytes": 1291 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/SafeFilenameTests.cs", + "sha256": "fc102dd3034d014bb6fc342a5ab9d85b5b772a94ca4bfbd225fdf9300765b6de", + "bytes": 1934 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/ServiceAccountTests.cs", + "sha256": "139ea348309adae5783b7e3f223944909e68d56b9c12f17cc037f7d3e289a40d", + "bytes": 3896 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/SortKeyTests.cs", + "sha256": "94ca257b33bb77184fe1d067d8b421fc13175c7b481598886d21e33f24f55262", + "bytes": 1141 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.Domain.Tests/WorkspaceNameTests.cs", + "sha256": "6452cc4936d52ec4bae52d2b750de955b50faac1d3d7f6e1a7e7c9c996697357", + "bytes": 1034 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Attachments/AttachmentUploadTests.cs", + "sha256": "00b15bf1134d2d8095ea3168678fb50a3fa081fe28baff9899049b3fc7166ce3", + "bytes": 12430 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Attachments/StagedAttachmentAuthorizationTests.cs", + "sha256": "b9f8221062632a2d7338d93eba9feeb7b4da6f749cea477c3f160286cff9194d", + "bytes": 12107 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/ConcurrentLoginTests.cs", + "sha256": "ef8b668d88ed9a53d9db952421a01e4fde541ca305613b7905d9fd5aa5b4545e", + "bytes": 1176 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/CsrfTests.cs", + "sha256": "1fd06004af33335e7a5e6c78ea6b495e1a3f1bac7cd4e27f40c567d91509a8a7", + "bytes": 1942 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/LoginLogoutLockoutTests.cs", + "sha256": "3fd9d8fdec4a169bae6e8e620ba6e57a6f135e19dab4e97dd973b4b4dacb3d09", + "bytes": 5513 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/RecoveryTests.cs", + "sha256": "79570d35497ea473070d36cfd7873e0406b0c7272642c0f581b1216d2f6eb3b1", + "bytes": 6817 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/SessionExpiryTests.cs", + "sha256": "4ddbb62765e695fc0b6f2bd7dbc0e521aead5d08d6fcb64ac5723dfb5ce3e134", + "bytes": 1403 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/WorkspaceMemberSearchTests.cs", + "sha256": "e06e00a180859067661a417ee930f9710bdbb4991ce38a337e111c3f1f0748ce", + "bytes": 3933 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Authorization/RoleAuthorizationTests.cs", + "sha256": "fd4269fdae83874b088c5a91177e6bcf4f34004d01229e0d218d3a4fd8ba833c", + "bytes": 5290 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/IdempotencyTests.cs", + "sha256": "444abd821152e08670d1bda0b6c0e27ada696f85bb1abf2ee9968fc459d02040", + "bytes": 4400 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/OutboxTests.cs", + "sha256": "0c3d6780ab8f5983b26d8064350e231685f5d3c429b55bc75e8a4ef58726d9f8", + "bytes": 4302 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/ProjectContextTests.cs", + "sha256": "c291aac579f522c775f480c4576f1d6434e44fd362e571bfaf13d6a2a1c1b19d", + "bytes": 4781 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/ServiceAccountTests.cs", + "sha256": "176001168511217b11b766324de9060087d6510e76e7b4f3543c339e06068cca", + "bytes": 19737 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/ServiceAccountTokenIssuerTests.cs", + "sha256": "8f50ed1460b7d8d3b57bb61d70483aa5c7ce1052892c342c9a0311bbc9014748", + "bytes": 2342 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/GlobalUsings.cs", + "sha256": "50436eb3a60162e0cfad749eddfb06f8908f821a90ca44532df946a53e071359", + "bytes": 20 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/KnowledgeHubIntegrationFactory.cs", + "sha256": "d8a4803a44bf92699a33010106c4626d4b9c0e368c5817a24c4c0cec02dba11c", + "bytes": 6309 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/OwnerSetupTestBase.cs", + "sha256": "81a3e9a3653433178f44d47c064c57db81e1a6473ec2f6a2eb4a4aaad2b5fa11", + "bytes": 1599 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/PostgresContainerFixture.cs", + "sha256": "e2e309740077baed94d23fba8c3b46076ac7c1cb7024cfd839e09bce7f4cbbdf", + "bytes": 2249 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/PostgresTestGroup.cs", + "sha256": "acad3cf4cc8f0bada688837f4b5babf750cbf5b7d6701f3f0071df6978a43fa1", + "bytes": 225 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/SetupHelper.cs", + "sha256": "8d07df84d6fa27625e06ec65355036c3b09aae03b87f38a98d80e59df70e6d0b", + "bytes": 5230 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/EditorialProfileValidationTests.cs", + "sha256": "5ddbc9f870f008e1d977bb65170e3b8bc709957969c9884c48bcd5233917cf51", + "bytes": 11578 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/ExpandedManualOverrideCatalogTests.cs", + "sha256": "c869f264f0c2006c031b32f07448b0f63878d0a40405a2426468ed78d3706d1c", + "bytes": 9307 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/InfrastructureCatalogReleaseScaleTests.cs", + "sha256": "63d7471175327a5953e0541514adae8e761fcf8ba0a087dd36938fef2f8a5af7", + "bytes": 12611 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/InfrastructureCatalogScaleTests.cs", + "sha256": "70bf7e2bf08bbe3593a091be680a173f3bca87083f400ee819874123d22ee6a8", + "bytes": 8715 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/InfrastructurePaginationTests.cs", + "sha256": "8606278c03d5c3bc09bcf27e62b1c570d59379c1e16bfe93dfccf5d88ab501a3", + "bytes": 12149 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/InfrastructureSynchronizationTests.cs", + "sha256": "8551e55a4bcc65e88e0e43336f0fad44f2062cc0030b129c92be59f4778065e2", + "bytes": 30073 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/ManualOverrideCatalogTests.cs", + "sha256": "352183037e11e4ebd2123798c73324d6faeb79fe26b111c1e2aaf5e4f8143da9", + "bytes": 9483 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionObservabilityTests.cs", + "sha256": "b4425360c31ddc9af65e6dc025b07ebecb301aa6b0a6f7add272199aed0e87e2", + "bytes": 9748 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionPilotAvailabilityTests.cs", + "sha256": "7599a942d9cc6d948bfa7942db9e324ffb63a340d6a3b45e67dfccdd901934bc", + "bytes": 2838 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionPilotContractTests.cs", + "sha256": "8f60385ccdcb312d188801ad0e61c22497ec6cadcebff7220ae6334abe160a60", + "bytes": 15287 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalApiTests.cs", + "sha256": "8643b011fa8621b68aee41bf8e54bbb8eb5085f06954b51013c9708fde7b642a", + "bytes": 43019 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalAuthorizationTests.cs", + "sha256": "87a1b67f6beaef4151a141cc622eb6abf557b6424da096bec0e83603988a9156", + "bytes": 9371 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalEtagContractTests.cs", + "sha256": "570749cd19e820ad837fd622860904ec170650b65441a80273a8d74657183a48", + "bytes": 4535 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalPersistenceTests.cs", + "sha256": "d07601fbdf2d7f405e59a8d0c81aa374dc7ea8b198a1297e028780e2bde2de9d", + "bytes": 8420 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalPrivacyTests.cs", + "sha256": "114d88cc28609bb30eda2167e7d0d4c258f2feafc42730813d9bac4462d9c595", + "bytes": 11741 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalSourceCompensationTests.cs", + "sha256": "dce305a08f045cf9d33e567da1344d677d98a1338241a93643a314fdeced9097", + "bytes": 12190 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionPublicationTests.cs", + "sha256": "ed579474d0a6ff66696baac5a55ca0602d2d19c0d8abea2363a143a78b03e898", + "bytes": 20199 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionSourceRetentionPersistenceTests.cs", + "sha256": "f40ed6fc2d1012e0f1333ee26747aeb465be2b3665f17d6876b2b46da9dac4b2", + "bytes": 9354 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionSourceRetentionTests.cs", + "sha256": "ebc00d50c7a4ab35d7422ad27ca21eb2ae23f520d373e671647649cb293d9cef", + "bytes": 9840 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/ItWorx.KnowledgeHub.IntegrationTests.csproj", + "sha256": "89b9209c9a6f02cdc697b25fc57cc385d65e64c08552101ed960791c9d18e060", + "bytes": 1834 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/BacklinksTests.cs", + "sha256": "b4d44afd5f5ef42260c7e658a04aea2569c2fc326ace5a88a94bb9de20df2a9c", + "bytes": 3646 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/DraftAutosaveTests.cs", + "sha256": "2a3052b4b69f9928e5164183104f9b07992cb4c1fcd715b466199efe900f71de", + "bytes": 4500 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/InternalLinkResolutionTests.cs", + "sha256": "515ea3013a1863ccd1879040875bce5ae8532ce93694e2db339875208a200f5f", + "bytes": 4975 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/MarkdownSanitizationTests.cs", + "sha256": "8d4e180987c311f388b2c93c74689f15f8f58b0b7d56ece98350b334c27fa648", + "bytes": 10526 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/PageConcurrencyTests.cs", + "sha256": "b1d6a793a66b0403a2808b5c33cee011e67461bdefce33bc351419351b9370bd", + "bytes": 3835 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/PageCreationStagingTests.cs", + "sha256": "aaa2b6c1edd544baae60e0f9ecc562275072c60a0f5678f730f2e11989641b33", + "bytes": 13089 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/PageCrudTests.cs", + "sha256": "2e8b63081fc159a3623af57ef6f0daff6fb668dccbf9f08a10fa2c2cd134ebb7", + "bytes": 9087 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/PageTreeScaleTests.cs", + "sha256": "6b066fc33c57ad51d1498bf1482ab79f0f885a98c65c99df0935153fd2006073", + "bytes": 4162 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/RagPublicationManifestTests.cs", + "sha256": "b39ca25e0b74d490280b3900d35b2468c822c9104676dadce6f882ddc7a8f7f4", + "bytes": 13618 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/RevisionsTests.cs", + "sha256": "fca318dbe303eb869cda7dbbbfc900c5e9baa8a08af0a70fecc060ec4d3d68d6", + "bytes": 5046 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/SearchFullScaleTests.cs", + "sha256": "bc93cf09fab0c37c5689bbdc33aa51af6de99858e72eec955181a2d4bad112be", + "bytes": 21113 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/SearchScaleTests.cs", + "sha256": "8febc36432eaae5642c762018c06c62873c5221a3288f2c86ffe8a468d73b62f", + "bytes": 4075 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/SearchTests.cs", + "sha256": "ed8ffb2b302f238d9e21d52c7ff55e99af3b9659d97fe6a085e70cb56ae41761", + "bytes": 6270 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/StagingSessionCleanupTests.cs", + "sha256": "0fe285c8e68ca30d56042e6c6afae953dd7f54dc6762c38766a8500bcc4f0e86", + "bytes": 15574 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/TemplateTests.cs", + "sha256": "72376ccc16b54beb2c9a035c070fd3b26dfaa6f980f5b49d73447eb18cb3fb66", + "bytes": 2842 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/TrashPurgeTests.cs", + "sha256": "7c6ea2e7ceebf0542fc8ec040569f635e24213097d3794a3a317670105ecdcc5", + "bytes": 4606 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/CutoverRehearsalTests.cs", + "sha256": "dd6aadc78a9f2416d801ebe4d1049a28310e9c836bf1c2d21668422c3a9b175a", + "bytes": 13214 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/InPlaceUpgradeRehearsalTests.cs", + "sha256": "6be5365f778870dd38a63d9552ccb672da9e181737380b73e1e74828b6a523f1", + "bytes": 9921 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/LeafWikiImportTests.cs", + "sha256": "7bb69f38364e6854c7c533b1d15a7b2365feee742139ca69fddaa7439c14f57a", + "bytes": 27489 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/LeafWikiV5ImportTests.cs", + "sha256": "5839381bc9b01d3d9124f98aae1ee8085f74cbd8d690f235293eb5e1d68b12fa", + "bytes": 15377 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/SourceBatchPurgeTests.cs", + "sha256": "0c6190b595bef291b3f8df07652ba5d004fd01a94433a27b3bbd58aecf219edb", + "bytes": 8874 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/ActivityFeedTests.cs", + "sha256": "5439e76efa08f3588fad4f3dde3ace611a789aecdc1292b5557ecf3c58b8988c", + "bytes": 1472 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/BackupServiceTests.cs", + "sha256": "a533eabdb6a606bc9d9db6ed33910b97c0e80ac572b50bfd5f54327921ff914c", + "bytes": 4196 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/HealthCheckTests.cs", + "sha256": "ce0a7755a62645880fc0619089c004cb33db0ca7f3bb45eda09f4d0a14566557", + "bytes": 5519 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/LoginRedactionTests.cs", + "sha256": "fa1438b0483dd76b2485377b7e9ce40b11e77d4ebbbe46dc57239ec3a551329a", + "bytes": 2449 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/RestoreServiceTests.cs", + "sha256": "e18e516bd4586574b39d880ce1e52cf989d25c5b37242405b7283bac64c0a923", + "bytes": 8438 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/SchemaGuardTests.cs", + "sha256": "15a5441e5248229d69d6c0af53c10b2b2b4287fb8a60a0caa2c3bb7827bf06af", + "bytes": 4448 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/SystemInfoTests.cs", + "sha256": "52fee800671424b2c7123602e7ec9ec1bb743118c68ae190d93e3068ad9ba537", + "bytes": 3119 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Projects/DecisionTests.cs", + "sha256": "add1093964043ae40de795fedd3f9c5d9fa2af54dd30264dba796b131ab8f002", + "bytes": 7772 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Projects/ProjectHubTests.cs", + "sha256": "9934bfd60c1286730ea9e315da1641c706e5b804d9b09c99e2cfbdfad9c82e6d", + "bytes": 18936 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Projects/RunbookTests.cs", + "sha256": "1c03d35c0f293c89947ebec214bcc0a1fd04b75a472d67c2c0ed66c880bda430", + "bytes": 6683 + }, + { + "path": "tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Setup/FirstRunSetupTests.cs", + "sha256": "65e49d37e0256c2ec14ed2ea94f782beb8da177a0585409ba1a128dff87bc8b5", + "bytes": 2617 + }, + { + "path": "tests/e2e/global-setup.ts", + "sha256": "87142c3c6dc78d3cff7b0bfb4ef76ec7bd6c35eb052023a408f8003cf2aaef61", + "bytes": 4101 + }, + { + "path": "tests/e2e/package.json", + "sha256": "9f9484c32e1edfdb76206fef167b5517b9a06b55bc3d6de548d0040889912b2b", + "bytes": 398 + }, + { + "path": "tests/e2e/playwright.config.ts", + "sha256": "ad749af57a54caa7ae3161b9b9a0e69daa692eae7ea0a730a63a0d72f601f4f0", + "bytes": 944 + }, + { + "path": "tests/e2e/specs/attachments.spec.ts", + "sha256": "c05f2645157e15a72c4ce6288ed1244248b14e27c7d4fc10b3f26d104068beaa", + "bytes": 3024 + }, + { + "path": "tests/e2e/specs/auth-journey.spec.ts", + "sha256": "f89bb871fe6494a9a786c992e39da15c7a2b4bb45e61c2d2668f9aab69ed82ae", + "bytes": 2576 + }, + { + "path": "tests/e2e/specs/baseline.spec.ts", + "sha256": "3fad54bff5595955a16898aabfadef48451b5bf89a39f039cd35c6db74bc032b", + "bytes": 773 + }, + { + "path": "tests/e2e/specs/discovery.spec.ts", + "sha256": "1ab37f9fb35e3b470e585a4fefb2324b4791e96fc358b8fb745f667260583893", + "bytes": 5462 + }, + { + "path": "tests/e2e/specs/ingestion-review.spec.ts", + "sha256": "1536ee1e6260d9f20f0be89007b6af2fdfd8b9974f0e9a30a0a0a0f1a82fc7ef", + "bytes": 4100 + }, + { + "path": "tests/e2e/specs/knowledge-editing.spec.ts", + "sha256": "ff9c1ba0974d9860c3e34ff9b2109c9c2b03e2529acfe84240b711afa6c02e19", + "bytes": 7003 + }, + { + "path": "tests/e2e/specs/project-hub.spec.ts", + "sha256": "eb5d70422430b58cbfbfb354928bc9581b140793486c901f9ae2896434994bfc", + "bytes": 9315 + }, + { + "path": "tests/e2e/specs/support/credentials.ts", + "sha256": "439e2847b04fb7c2858b5e441391a1de808ee826b9603299271e3d76b8e033c9", + "bytes": 411 + }, + { + "path": "tests/e2e/specs/support/v2.ts", + "sha256": "b4f8131748087dae86b4746ba80cd7581a00d9a1737abfb65b72fbebb407c4d6", + "bytes": 2960 + }, + { + "path": "tests/e2e/specs/v2-editor-leafwiki-floor.spec.ts", + "sha256": "f24f4203bf9f6f2f5e9a159112010c910b7e1662265fe6144bd3de51e878aee2", + "bytes": 10847 + }, + { + "path": "tests/e2e/specs/v2-infrastructure-review.spec.ts", + "sha256": "5718cd25ced1e7b8ce204b41d70d166662a07dfa5afb06ea65de718a5c9e5850", + "bytes": 9358 + }, + { + "path": "tests/e2e/specs/v2-m5-workspace-fidelity.spec.ts", + "sha256": "a73b3c05027b1f7cd54daaaacc08a18288056d080d75cd4e66245a5427cf5be1", + "bytes": 7430 + }, + { + "path": "tests/e2e/specs/v2-m6-motion-and-matrices.spec.ts", + "sha256": "8288720d427ca6e2ababd538df25cf7eec95a9897119da22863745d4c08ba4ab", + "bytes": 6928 + }, + { + "path": "tests/e2e/specs/v2-m7-admin-actions.spec.ts", + "sha256": "33b59a667459a76ee1f574244b6f1e0cc479f1f689d90d8cbac43c979d76e0d8", + "bytes": 10604 + }, + { + "path": "tests/e2e/specs/v2-m8-accessibility-and-fidelity.spec.ts", + "sha256": "a1de19825eb55b3103f3174797c909e3a393e3b079dad034de71f4c0801c4cb5", + "bytes": 8986 + }, + { + "path": "tests/e2e/specs/v2-r3-daily-ux-completeness.spec.ts", + "sha256": "1787d051c085de0cdc8e9cbdeeffdd6786eb23add8b1eb12e35207fbec195ad2", + "bytes": 6706 + }, + { + "path": "tests/e2e/specs/v2-routing-and-fidelity.spec.ts", + "sha256": "fc6823cf7cffdee2deea7c63f951c4098b6a66f799a03961788a8e802b53c2e6", + "bytes": 13598 + }, + { + "path": "tests/e2e/specs/v3-intake-pilot.spec.ts", + "sha256": "98f62bf9087c5463e15094e543c192fa1045ee5d28a8af14812c5e62ce6cc4db", + "bytes": 10361 + }, + { + "path": "tools/leafwiki-inspector/README.md", + "sha256": "24bae3b3a276cf7e84f3908c8e5f3b7b0920d377dba65f355e5b9d219fe689e9", + "bytes": 1359 + }, + { + "path": "tools/leafwiki-inspector/inspect.py", + "sha256": "10239ed5ed307bb0ac7b6d03900e1a6335aec1f7d58364b444174cd0f2d106d0", + "bytes": 19648 + }, + { + "path": "tools/leafwiki-inspector/tests/test_inspect.py", + "sha256": "d4d508b7760d9b83326ab90798685dcf474576c2598f626b4f822986ac6aa8f2", + "bytes": 4098 + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..5129d86 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# ItWorx Knowledge Hub + +A self-hosted knowledge, documentation and project-intelligence platform for people +who want the speed of Markdown with the safety of revisions, permissions and verified +backups. + +Knowledge Hub combines a technical wiki, project dossiers, architecture decisions and +runbooks in one searchable application. It remains fully useful without AI. A scoped +REST API supports external automation, while MCP transport deliberately lives outside +this repository. + +## What you can do + +- Organize Markdown pages in a fast, hierarchical knowledge tree. +- Search titles, content and tags with PostgreSQL full-text and fuzzy matching. +- Edit safely with private drafts, immutable revisions and explicit conflict handling. +- Upload deduplicated attachments with signature-based type validation. +- Track projects, milestones, updates, architecture decisions and runbook validation. +- Import LeafWiki content through a read-only, preflighted and reversible workflow. +- Create scoped, revocable service-account tokens for the versioned `/api/v1` API. +- Back up the database and attachments together, then verify a clean-target restore. + +## Screenshots + +These captures come from a local instance populated with synthetic demo data. + +![Synthetic workspace dashboard](docs/assets/screenshots/02-dashboard.png) + +![Synthetic Markdown knowledge workspace](docs/assets/screenshots/03-knowledge.png) + +## Quick start + +Requirements: Docker with Compose, Node.js 24 and Corepack. + +```bash +cp .env.example .env +# Set a strong, unique POSTGRES_PASSWORD in .env. +node scripts/doctor.mjs +docker compose up --build -d +``` + +Open `http://localhost:1240` and complete the one-time owner setup. The default stack +contains one application container and one PostgreSQL container; PostgreSQL is not +published to the host. + +For a production deployment, terminate TLS at a trusted reverse proxy, use mounted +secrets, persist every documented data volume and test restore before relying on a +backup. Start from the reviewed [Docker Compose configuration](docker-compose.yml) and +the [backup/restore guide](docs/guides/back-ups-herstel-en-unraid-beheer.md). + +## Architecture + +```text +Browser or scoped API client + | + HTTPS + | + ASP.NET Core + React + | + PostgreSQL + | + attachments / backups +``` + +The backend is a modular monolith with separate Domain, Application, Contracts, +Infrastructure and API projects. React is built into the production image. Markdown is +the canonical human-readable content format, PostgreSQL is the transactional source of +truth, and attachments are stored by SHA-256 outside the database. + +## Development and verification + +```bash +corepack pnpm install --frozen-lockfile +node scripts/verify.mjs --fast +node scripts/verify.mjs --ci +``` + +The CI gate covers formatting, compilation, static analysis, backend and frontend +tests, OpenAPI consistency and the production image. The release gate adds dependency, +secret and container scans plus the full-scale search proof: + +```bash +node scripts/verify.mjs --release +``` + +Integration tests use real PostgreSQL through Testcontainers. Browser tests exercise +desktop and mobile flows and include automated accessibility checks. + +## API and automation + +The OpenAPI contract is in [docs/api/openapi.yaml](docs/api/openapi.yaml). External +clients use one-time-display service-account tokens with explicit scopes. Mutating +content routes use ETags and `If-Match`; retryable creates support `Idempotency-Key`. + +This repository does not contain an MCP server, shell executor, arbitrary script +runtime or runbook execution engine. + +## Security and data safety + +- Content is private by default and authorization is enforced server-side. +- Cookie mutations require CSRF protection; API clients use scoped bearer tokens. +- Normal deletion uses trash and retention; permanent purge is an explicit admin act. +- LeafWiki inspection and import never modify the source. +- Restore targets must be clean and are verified against backup manifests and hashes. +- The production container runs non-root with a read-only root filesystem and dropped + capabilities. + +Report vulnerabilities using [SECURITY.md](SECURITY.md). Do not include secrets or real +private knowledge in an issue. + +## Project status and limitations + +The implemented platform includes the wiki, search, projects, ADRs, runbooks, +attachments, reversible import, scoped API and backup/restore path. Current limitations +include no real-time collaborative editing, no built-in MCP transport, no arbitrary +runbook execution and no public anonymous publishing by default. + +Historical delivery and production evidence is retained in the private canonical +repository. The `itworx-knowledge-hub-Public` repository is automatically generated from the curated source boundary after merges to private canonical `main`. The manifest identifies the exact source revision. Private history, real knowledge, operational evidence and internal Actions remain excluded. + +## Contributing and license + +See [CONTRIBUTING.md](CONTRIBUTING.md) before proposing a change. The project remains proprietary under [LICENSE](LICENSE). Public visibility grants no additional redistribution or open-source rights; third-party dependencies retain their own terms. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5afc1b2 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 0000000..f3fb013 --- /dev/null +++ b/deploy/docker/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..db8db13 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/00-product-vision.md b/docs/00-product-vision.md new file mode 100644 index 0000000..89cae6f --- /dev/null +++ b/docs/00-product-vision.md @@ -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` diff --git a/docs/01-scope-and-non-goals.md b/docs/01-scope-and-non-goals.md new file mode 100644 index 0000000..a0943f5 --- /dev/null +++ b/docs/01-scope-and-non-goals.md @@ -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. diff --git a/docs/05-non-functional-requirements.md b/docs/05-non-functional-requirements.md new file mode 100644 index 0000000..7053716 --- /dev/null +++ b/docs/05-non-functional-requirements.md @@ -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. diff --git a/docs/06-system-architecture.md b/docs/06-system-architecture.md new file mode 100644 index 0000000..9d46385 --- /dev/null +++ b/docs/06-system-architecture.md @@ -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. diff --git a/docs/07-domain-model.md b/docs/07-domain-model.md new file mode 100644 index 0000000..0826513 --- /dev/null +++ b/docs/07-domain-model.md @@ -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. diff --git a/docs/08-database-schema.md b/docs/08-database-schema.md new file mode 100644 index 0000000..434bc71 --- /dev/null +++ b/docs/08-database-schema.md @@ -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. diff --git a/docs/09-api-contract.md b/docs/09-api-contract.md new file mode 100644 index 0000000..58d0f23 --- /dev/null +++ b/docs/09-api-contract.md @@ -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__ +``` + +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. diff --git a/docs/10-search-and-indexing.md b/docs/10-search-and-indexing.md new file mode 100644 index 0000000..4b2fbd7 --- /dev/null +++ b/docs/10-search-and-indexing.md @@ -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. diff --git a/docs/11-markdown-and-linking.md b/docs/11-markdown-and-linking.md new file mode 100644 index 0000000..15096f1 --- /dev/null +++ b/docs/11-markdown-and-linking.md @@ -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 + + + + +``` + +These tokens remain in PostgreSQL and immutable revisions so synchronization can replace +only the managed source region and preserve user notes byte-exact. The renderer removes +only these four exact tokens from a transient render copy, replacing them with line +boundaries before Markdown parsing. Stored Markdown is never normalized by rendering. +Unknown comments, HTML, scripts and event handlers are not recognized as controls and +remain subject to the normal raw-HTML-disabled and allowlist-sanitized pipeline. + +## Internal wiki links + +Author syntax: + +```text +[[Target title]] +[[Target title|Visible label]] +``` + +The editor autocomplete may insert an invisible stable marker or structured editor +metadata, but exported Markdown must remain human-readable. + +At publish time, each link records: + +- source page/revision; +- authored target text; +- optional display text; +- resolved stable target ID; +- resolution state; +- occurrence or source range where practical. + +Resolution order: + +1. explicit stable target marker; +2. exact title in the same collection/subtree; +3. exact title in the workspace; +4. unique normalized match; +5. unresolved or ambiguous. + +The UI must ask the editor to resolve ambiguity before or after publishing; it must not +silently choose an arbitrary page. + +## Slugs + +- Derived from title for readability. +- Unique only where required by routing policy; stable page ID prevents identity loss. +- Old canonical URLs redirect using ID. +- Slugs contain no authorization meaning. +- Import preserves the legacy path separately as provenance. + +## Heading anchors + +Heading IDs are deterministic within a rendered revision. Duplicate headings receive +stable suffixes. Links to headings use page ID plus anchor. + +A heading rename may break an authored anchor; the system should detect this as a link +maintenance warning but need not maintain a permanent heading identity in v1. + +## Rendering safety + +- Parse using a fixed configured extension set. +- Do not pass raw HTML through. +- Remove only the four documented import control tokens from render input; never expose + them in reader views, snippets or revision previews. +- Sanitize generated HTML with an allowlist. +- Rewrite external links with safe `rel` attributes. +- Reject `javascript:`, `data:` and unsafe protocols except explicitly generated safe + image data where controlled. +- Render Mermaid with strict security and sanitized labels. +- Never allow Mermaid directives to access network/filesystem. +- Syntax highlighting is client-side or server-side without executing code. +- Cap nesting, token count and rendered size to resist pathological input. + +## Attachments in Markdown + +Use a stable application URI: + +```text +![Alt text](attachment:550e8400-e29b-41d4-a716-446655440000 "Caption") +``` + +The renderer resolves it through authorized metadata/content endpoints. Exports rewrite it +to a deterministic relative file path and include the attachment. + +Ordinary external image hotlinking is disabled by default for privacy. The editor may +store an external hyperlink instead. + +## Draft and revision behavior + +The draft stores raw Markdown and typed metadata. Preview never changes published +relations or search. + +Publish: + +1. validates size and parser limits; +2. parses internal links and attachment references; +3. authorizes all referenced private resources; +4. creates current state and immutable revision; +5. updates derived relations/search; +6. writes audit/outbox; +7. returns the new ETag. + +## Export frontmatter + +Export may include: + +```yaml +id: 550e8400-e29b-41d4-a716-446655440000 +title: PostgreSQL back-up herstellen +documentType: runbook +status: active +tags: + - PostgreSQL + - Back-up +updatedAt: 2026-07-31T18:00:00Z +``` + +Importers must not trust privileged fields such as author IDs, permissions or executable +paths from frontmatter. diff --git a/docs/12-auth-security.md b/docs/12-auth-security.md new file mode 100644 index 0000000..516bf45 --- /dev/null +++ b/docs/12-auth-security.md @@ -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/`. +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. diff --git a/docs/13-attachments.md b/docs/13-attachments.md new file mode 100644 index 0000000..bf228bc --- /dev/null +++ b/docs/13-attachments.md @@ -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/ +``` + +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. diff --git a/docs/14-revisions-and-audit.md b/docs/14-revisions-and-audit.md new file mode 100644 index 0000000..d1798f2 --- /dev/null +++ b/docs/14-revisions-and-audit.md @@ -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. diff --git a/docs/15-project-hub.md b/docs/15-project-hub.md new file mode 100644 index 0000000..1f91232 --- /dev/null +++ b/docs/15-project-hub.md @@ -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. diff --git a/docs/16-runbooks-and-adrs.md b/docs/16-runbooks-and-adrs.md new file mode 100644 index 0000000..23d7558 --- /dev/null +++ b/docs/16-runbooks-and-adrs.md @@ -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. diff --git a/docs/17-leafwiki-migration.md b/docs/17-leafwiki-migration.md new file mode 100644 index 0000000..a4e66ab --- /dev/null +++ b/docs/17-leafwiki-migration.md @@ -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_ +LeafWikiHybridAdapter_ +``` + +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//)`) + becomes `](/api/v1/attachments//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. diff --git a/docs/18-backup-restore.md b/docs/18-backup-restore.md new file mode 100644 index 0000000..2ba13b5 --- /dev/null +++ b/docs/18-backup-restore.md @@ -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. diff --git a/docs/19-observability.md b/docs/19-observability.md new file mode 100644 index 0000000..405c788 --- /dev/null +++ b/docs/19-observability.md @@ -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. diff --git a/docs/20-ui-ux-design-system.md b/docs/20-ui-ux-design-system.md new file mode 100644 index 0000000..7d8f66f --- /dev/null +++ b/docs/20-ui-ux-design-system.md @@ -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. diff --git a/docs/21-accessibility.md b/docs/21-accessibility.md new file mode 100644 index 0000000..6a516b8 --- /dev/null +++ b/docs/21-accessibility.md @@ -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. diff --git a/docs/22-testing-strategy.md b/docs/22-testing-strategy.md new file mode 100644 index 0000000..274d36f --- /dev/null +++ b/docs/22-testing-strategy.md @@ -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. diff --git a/docs/24-mcp-readiness.md b/docs/24-mcp-readiness.md new file mode 100644 index 0000000..7c1d8fd --- /dev/null +++ b/docs/24-mcp-readiness.md @@ -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. diff --git a/docs/26-threat-model.md b/docs/26-threat-model.md new file mode 100644 index 0000000..3e4dde6 --- /dev/null +++ b/docs/26-threat-model.md @@ -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. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml new file mode 100644 index 0000000..92dc9a6 --- /dev/null +++ b/docs/api/openapi.yaml @@ -0,0 +1,6133 @@ +openapi: 3.1.0 +info: + title: ItWorx Knowledge Hub API + version: 0.1.0-draft + description: | + Versioned API for the ItWorx Knowledge Hub web client and scoped automation. + This is not an MCP transport. The future MCP service will consume this API. +servers: + - url: /api/v1 +tags: + - name: System + - name: Setup + - name: Auth + - name: Admin + - name: Search + - name: Rag + - name: Pages + - name: Drafts + - name: Templates + - name: Attachments + - name: Imports + - name: Source batch purge + - name: Projects + - name: Decisions + - name: Runbooks + - name: Service accounts + - name: Backups + - name: Infrastructure + - name: Activity + - name: Workspace + - name: Ingestion proposals +security: + - cookieAuth: [] + - bearerAuth: [] +paths: + /ingestion-proposals: + get: + tags: [Ingestion proposals] + operationId: listIngestionProposals + description: Human review queue only. Page/pageSize and all filters are evaluated server-side; cursor/limit remain supported for existing clients. Project and risk are accepted but return no matches until this version persists those attributes, rather than fabricating filter results. + parameters: + - name: status + in: query + schema: { type: string } + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + - name: page + in: query + schema: { type: integer, minimum: 1, maximum: 10000, default: 1 } + - name: pageSize + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 50 } + - name: projectId + in: query + schema: { type: string, format: uuid } + - name: submitterId + in: query + schema: { type: string, format: uuid } + - name: minAgeDays + in: query + description: Minimum proposal age, calculated from the first immutable proposal revision. + schema: { type: integer, minimum: 0, maximum: 3650 } + - name: risk + in: query + schema: { type: string, enum: [low, medium, high] } + - name: documentType + in: query + schema: { type: string, maxLength: 64 } + responses: + "200": { description: Review queue, content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalListResponse" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Ingestion proposals] + operationId: createIngestionProposal + description: >- + Requires an Idempotency-Key and an editor cookie or an ingestion:write service + token. Repeating the same request with the same caller and key replays the + original 201 response. Reusing that key with a different request body returns + a 409 problem with type https://itworx.dev/problems/idempotency-key-conflict. + parameters: + - $ref: "#/components/parameters/RequiredIdempotencyKey" + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/CreateIngestionProposalRequest" } } } + responses: + "201": { description: Proposal created, headers: { ETag: { $ref: "#/components/headers/ETag" } }, content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalDetail" } } } } + "400": { description: Idempotency-Key is missing or invalid, content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": + description: Idempotency-Key was already used with a different request body. + content: + application/problem+json: + schema: { $ref: "#/components/schemas/ProblemDetails" } + examples: + idempotencyKeyConflict: + value: + type: https://itworx.dev/problems/idempotency-key-conflict + title: Idempotency-Key hergebruikt met een andere aanvraag. + status: 409 + correlationId: pilot-contract + detail: Deze Idempotency-Key is eerder gebruikt voor een andere aanvraag-inhoud. + "422": { $ref: "#/components/responses/BadRequest" } + /ingestion-proposals/{proposalId}: + parameters: + - name: proposalId + in: path + required: true + schema: { type: string, format: uuid } + get: + tags: [Ingestion proposals] + operationId: getIngestionProposal + description: A human reviewer may read any workspace proposal. An ingestion:write token may read only the proposal it submitted. + responses: + "200": { description: Complete metadata-only review/status dossier, headers: { ETag: { $ref: "#/components/headers/ETag" } }, content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalDetail" } } } } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [Ingestion proposals] + operationId: patchIngestionProposal + description: >- + Requires If-Match. Human editors may change manifest/rationale; an edit to an + Approved proposal clears its approval and returns it to ReadyForReview. Only + the submitting ingestion:write account may submit analysis, which advances the + proposal to ReadyForReview. A stale proposal ETag returns 412 with type + https://itworx.local/problems/ingestion-concurrency-conflict. + parameters: + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/PatchIngestionProposalRequest" } } } + responses: + "200": { description: Updated proposal, headers: { ETag: { $ref: "#/components/headers/ETag" } }, content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalDetail" } } } } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "412": + description: Proposal ETag does not match the current version. + content: + application/problem+json: + schema: { $ref: "#/components/schemas/ProblemDetails" } + examples: + ingestionConcurrencyConflict: + value: + type: https://itworx.local/problems/ingestion-concurrency-conflict + title: Versieconflict. + status: 412 + correlationId: pilot-contract + /ingestion-proposals/{proposalId}/sources: + post: + tags: [Ingestion proposals] + operationId: stageIngestionProposalSource + description: Requires If-Match and Idempotency-Key. Upload is quarantined, signature-validated and stored only as immutable metadata plus a content-addressed attachment reference; neither bytes nor storage paths are returned. + parameters: + - name: proposalId + in: path + required: true + schema: { type: string, format: uuid } + - $ref: "#/components/parameters/IfMatch" + - $ref: "#/components/parameters/RequiredIdempotencyKey" + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: { file: { type: string, format: binary } } + responses: + "200": { description: Source staged, headers: { ETag: { $ref: "#/components/headers/ETag" } }, content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalDetail" } } } } + "413": { description: Source exceeds the configured size limit } + "422": { $ref: "#/components/responses/BadRequest" } + /ingestion-proposals/{proposalId}/submit: + post: + tags: [Ingestion proposals] + operationId: submitManualIngestionProposal + description: >- + Human submitter only. Moves a proposal with publication details and at least + one staged source into ReadyForReview without claiming automated analysis. + Requires If-Match. A separate human review and approval remain mandatory. + parameters: + - name: proposalId + in: path + required: true + schema: { type: string, format: uuid } + - $ref: "#/components/parameters/IfMatch" + responses: + "200": { description: Submitted for review, headers: { ETag: { $ref: "#/components/headers/ETag" } }, content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalDetail" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "412": { $ref: "#/components/responses/PreconditionFailed" } + "428": { $ref: "#/components/responses/PreconditionRequired" } + /ingestion-proposals/{proposalId}/request-changes: + post: + tags: [Ingestion proposals] + operationId: requestIngestionProposalChanges + description: >- + Human reviewer only. Requires If-Match and returns the updated metadata-only + dossier. Stale ETags return 412 with type + https://itworx.local/problems/ingestion-concurrency-conflict. + parameters: + - name: proposalId + in: path + required: true + schema: { type: string, format: uuid } + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalDecisionRequest" } } } + responses: + "200": { description: Change request recorded, headers: { ETag: { $ref: "#/components/headers/ETag" } }, content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalDetail" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "412": { $ref: "#/components/responses/PreconditionFailed" } + "428": { $ref: "#/components/responses/PreconditionRequired" } + /ingestion-proposals/{proposalId}/approve: + post: + tags: [Ingestion proposals] + operationId: approveIngestionProposal + description: >- + Human reviewer only. Service accounts cannot approve. Requires If-Match and + approves a ReadyForReview proposal without publishing it. Stale ETags return + 412 with type https://itworx.local/problems/ingestion-concurrency-conflict. + parameters: + - name: proposalId + in: path + required: true + schema: { type: string, format: uuid } + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalDecisionRequest" } } } + responses: + "200": { description: Proposal approved for later publication, headers: { ETag: { $ref: "#/components/headers/ETag" } }, content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalDetail" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "412": { $ref: "#/components/responses/PreconditionFailed" } + "428": { $ref: "#/components/responses/PreconditionRequired" } + /ingestion-proposals/{proposalId}/reject: + post: + tags: [Ingestion proposals] + operationId: rejectIngestionProposal + description: >- + Human reviewer only. Service accounts cannot reject. Requires If-Match and + records the rejection comment on the metadata-only proposal dossier. Stale + ETags return 412 with type + https://itworx.local/problems/ingestion-concurrency-conflict. + parameters: + - name: proposalId + in: path + required: true + schema: { type: string, format: uuid } + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalDecisionRequest" } } } + responses: + "200": { description: Proposal rejected, headers: { ETag: { $ref: "#/components/headers/ETag" } }, content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalDetail" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "412": { $ref: "#/components/responses/PreconditionFailed" } + "428": { $ref: "#/components/responses/PreconditionRequired" } + /ingestion-proposals/{proposalId}/retry: + post: + tags: [Ingestion proposals] + operationId: retryIngestionProposal + description: Requires If-Match. An ingestion:write caller can retry only its own eligible failed proposal; a human editor may retry an eligible proposal. An optional Idempotency-Key safely replays a successful retry response. + parameters: + - name: proposalId + in: path + required: true + schema: { type: string, format: uuid } + - $ref: "#/components/parameters/IfMatch" + - $ref: "#/components/parameters/IdempotencyKey" + responses: + "200": { description: Eligible proposal reset for re-analysis, headers: { ETag: { $ref: "#/components/headers/ETag" } }, content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalDetail" } } } } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "412": { $ref: "#/components/responses/PreconditionFailed" } + "428": { $ref: "#/components/responses/PreconditionRequired" } + /ingestion-proposals/{proposalId}/publish: + post: + tags: [Ingestion proposals] + operationId: publishIngestionProposal + description: >- + Human review only. Requires a human editor cookie session, If-Match and + Idempotency-Key; service accounts are forbidden from publishing. A replay with + the same key and request body returns the original success response even when + it carries the original now-stale If-Match value, and does not create a second + page revision. Reusing the key with a different body returns a typed + idempotency conflict. A stale target page ETag returns 409 with type + https://itworx.local/problems/ingestion-target-stale and moves the proposal + back to ReadyForReview. + parameters: + - name: proposalId + in: path + required: true + schema: { type: string, format: uuid } + - $ref: "#/components/parameters/IfMatch" + - $ref: "#/components/parameters/RequiredIdempotencyKey" + responses: + "200": { description: Approved proposal published, headers: { ETag: { $ref: "#/components/headers/ETag" } }, content: { application/json: { schema: { $ref: "#/components/schemas/IngestionProposalDetail" } } } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": + description: >- + Invalid state transition, stale target, or idempotency-key conflict. Typed + problem types are https://itworx.local/problems/ingestion-invalid-transition, + https://itworx.local/problems/ingestion-target-stale, and + https://itworx.dev/problems/idempotency-key-conflict. + content: + application/problem+json: + schema: { $ref: "#/components/schemas/ProblemDetails" } + examples: + staleTarget: + value: + type: https://itworx.local/problems/ingestion-target-stale + title: Publicatiedoel is gewijzigd. + status: 409 + correlationId: pilot-contract + detail: Het voorstel is teruggezet naar de reviewwachtrij. + invalidTransition: + value: + type: https://itworx.local/problems/ingestion-invalid-transition + title: Conflict. + status: 409 + correlationId: pilot-contract + idempotencyKeyConflict: + value: + type: https://itworx.dev/problems/idempotency-key-conflict + title: Idempotency-Key hergebruikt met een andere aanvraag. + status: 409 + correlationId: pilot-contract + detail: Deze Idempotency-Key is eerder gebruikt voor een andere aanvraag-inhoud. + "412": + description: Proposal ETag is stale. + content: + application/problem+json: + schema: { $ref: "#/components/schemas/ProblemDetails" } + examples: + ingestionConcurrencyConflict: + value: + type: https://itworx.local/problems/ingestion-concurrency-conflict + title: Versieconflict. + status: 412 + correlationId: pilot-contract + "428": { $ref: "#/components/responses/PreconditionRequired" } + /system/info: + get: + tags: [System] + operationId: getSystemInfo + responses: + "200": + description: Permission-filtered system information + content: + application/json: + schema: + $ref: "#/components/schemas/SystemInfo" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /setup/status: + get: + tags: [Setup] + operationId: getSetupStatus + security: [] + description: >- + Reveals only whether first-run setup is still required. Never reveals any other + installation detail to an unauthenticated caller. + responses: + "200": + description: Setup status + content: + application/json: + schema: + $ref: "#/components/schemas/SetupStatus" + /setup/complete: + post: + tags: [Setup] + operationId: completeSetup + security: [] + description: >- + Single-use first-run owner setup. Requires CSRF protection and is rate limited. + Permanently fails once setup has completed. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CompleteSetupRequest" + responses: + "200": + description: Installation completed; caller is signed in + content: + application/json: + schema: + $ref: "#/components/schemas/CompleteSetupResponse" + + "409": + description: Setup already completed + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "422": + description: Validation failed (email/password/workspace name) + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "429": + $ref: "#/components/responses/RateLimited" + /auth/csrf: + get: + tags: [Auth] + operationId: getCsrfToken + security: [] + description: Issues the double-submit anti-forgery token required on cookie-authenticated mutations. + responses: + "200": + description: Anti-forgery token + content: + application/json: + schema: + $ref: "#/components/schemas/CsrfToken" + /auth/session: + get: + tags: [Auth] + operationId: getSession + security: [] + description: >- + Always returns 200. An unauthenticated caller only ever sees + `authenticated: false` plus whether setup is required — never whether a + specific account exists. + responses: + "200": + description: Current session state + content: + application/json: + schema: + $ref: "#/components/schemas/SessionResponse" + /auth/login: + post: + tags: [Auth] + operationId: login + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: Signed in + content: + application/json: + schema: + $ref: "#/components/schemas/SessionResponse" + "401": + description: Invalid credentials (also returned for an unknown account and a disabled account) + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "429": + $ref: "#/components/responses/RateLimited" + /auth/logout: + post: + tags: [Auth] + operationId: logout + responses: + "200": + description: Signed out + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + /auth/recovery/redeem: + post: + tags: [Auth] + operationId: redeemRecoveryToken + security: [] + description: Consumes a single-use operator-issued recovery token and sets a new password. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RecoveryRedeemRequest" + responses: + "200": + description: Password reset; all prior sessions for this account are invalidated + "400": + description: Invalid or expired token + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "422": + description: New password rejected + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "429": + $ref: "#/components/responses/RateLimited" + /admin/users: + get: + tags: [Admin] + operationId: listUsers + description: Administrator only. + responses: + "200": + description: Workspace members + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UserSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + post: + tags: [Admin] + operationId: createUser + description: >- + Administrator only. There is no invite email; the administrator sets an initial + password and communicates it out of band. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserRequest" + responses: + "200": + description: User created + content: + application/json: + schema: + $ref: "#/components/schemas/UserSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/BadRequest" + /admin/users/{userId}: + patch: + tags: [Admin] + operationId: updateUser + description: >- + Administrator only. Changes role and/or enabled state. Refuses a change that + would leave zero enabled administrators. + parameters: + - $ref: "#/components/parameters/UserId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateUserRequest" + responses: + "200": + description: Updated + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + description: Would remove the last enabled administrator + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /admin/users/{userId}/recovery-tokens: + post: + tags: [Admin] + operationId: issueRecoveryToken + description: >- + Administrator only. Issues a single-use, one-hour password-recovery token for + another account. The raw token is returned exactly once, in this response. + parameters: + - $ref: "#/components/parameters/UserId" + responses: + "200": + description: Token issued + content: + application/json: + schema: + $ref: "#/components/schemas/IssueRecoveryTokenResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /admin/trash/purge: + post: + tags: [Admin] + operationId: purgeTrash + description: >- + Administrator only. The explicit administrative purge + (docs/09-data-safety: never a permanent delete through a normal UI flow). + Permanently removes every trashed page in the workspace whose retention + window has already elapsed; never a page still within retention, never a + page that was never trashed. + responses: + "200": + description: Purge completed + content: + application/json: + schema: + $ref: "#/components/schemas/TrashPurgeResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /admin/staging-cleanup/status: + get: + tags: [Admin] + operationId: getStagingCleanupStatus + description: >- + Administrator only (docs/adr/ADR-029-v2-r5-final-hardening.md). Live counts of + currently-open and expired-pending-cleanup page-creation staging sessions, plus + the most recent cleanup run (scheduled or manual) and its result. The "last run" + fields are in-memory only and reset on application restart — an accepted, + documented limitation; the cleanup guarantee itself does not depend on this + history. + responses: + "200": + description: Current staging-cleanup status + content: + application/json: + schema: + $ref: "#/components/schemas/StagingCleanupStatusResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /admin/staging-cleanup/run: + post: + tags: [Admin] + operationId: runStagingCleanup + description: >- + Administrator only, CSRF-protected (docs/adr/ADR-029-v2-r5-final-hardening.md). + Runs the exact same reclamation logic as the periodic background worker, once, + synchronously. `dryRun: true` only counts currently-expired, claimable sessions + — it claims nothing, deletes nothing and is safe to call as often as wanted; + never expose a destructive "run now" action without this dry-run mode. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RunStagingCleanupRequest" + responses: + "200": + description: Cleanup run (or dry-run preview) result + content: + application/json: + schema: + $ref: "#/components/schemas/RunStagingCleanupResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /activity: + get: + tags: [Activity] + operationId: listActivity + description: >- + Requires a reader cookie session or an `audit:read` service-token scope. The + most recent audit/activity events across the workspace (page/project/decision + mutations, imports, backups, staging-session cleanup, etc.), newest first — + the same durable `audit_events` trail every mutation writes to + (docs/12-auth-security.md "Logging and privacy": never a password, raw token or + session identifier). + parameters: + - name: limit + in: query + required: false + description: Defaults to 100. + schema: + type: integer + minimum: 1 + maximum: 500 + responses: + "200": + description: Recent activity, newest first + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /workspace/members: + get: + tags: [Workspace] + operationId: searchWorkspaceMembers + description: >- + Requires a reader cookie session — deliberately not reachable by a + service-account bearer token (no automation use case has been named for this + yet). Backs the accessible owner picker (docs/adr/ADR-029-v2-r5-final-hardening.md, + replacing manual `OwnerId`/GUID entry): only enabled members of the current + workspace, matched case-insensitively against display name or email, capped at + 25 results. Never discloses accounts outside the workspace or disabled + accounts. An empty/omitted `q` returns the first 25 members alphabetically (the + picker's initial open state, before typing). + parameters: + - name: q + in: query + required: false + schema: + type: string + responses: + "200": + description: Matching workspace members + content: + application/json: + schema: + $ref: "#/components/schemas/WorkspaceMemberListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + /search: + get: + tags: [Search] + operationId: searchKnowledge + description: >- + PostgreSQL full-text (weighted title/tags/body) plus trigram typo-tolerant + title matching. Authorization is entirely SQL-level: always scoped to the + caller's workspace, always excludes trashed pages + (docs/10-search-and-indexing.md). An empty or absent `q` returns all + permitted, non-trashed pages (browsing by tag/type filter alone). + parameters: + - $ref: "#/components/parameters/CorrelationId" + - name: q + in: query + schema: + type: string + maxLength: 500 + - name: type + in: query + description: A single DocumentType value. + schema: + $ref: "#/components/schemas/DocumentType" + - name: tag + in: query + description: A single exact tag match. + schema: + type: string + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Permission-filtered search results + content: + application/json: + schema: + $ref: "#/components/schemas/SearchResponse" + "401": + $ref: "#/components/responses/Unauthorized" + /rag/publication-manifest: + get: + tags: [Rag] + operationId: getRagPublicationManifest + description: >- + A narrow, read-only view of every page tagged `rag:publish` + (docs/32-rag-publication-convention.md), built for RAGcore's n8n sync — the + one reliable way to discover the full curated publication set, since this API + has no `GET /pages` enumeration (only `/pages/roots` plus a tree walk). Reuses + the existing `pages:read` scope; no new scope was added. A page that still + carries `rag:publish` but is trashed, still a draft, awaiting review, archived, + or deprecated without the `rag:allow-deprecated` tag comes back with + `retired: true` and a `retiredReason`, and its `markdown`/`checksum` are + `null` — so a sync consumer can safely withdraw previously ingested content + instead of only ever learning about new content. + parameters: + - $ref: "#/components/parameters/CorrelationId" + - name: space + in: query + description: >- + A single `rag:space:` tag suffix. Case-sensitive lowercase slug; an + invalid shape is rejected with 422 rather than passed into the array- + containment filter. + schema: + type: string + maxLength: 64 + - $ref: "#/components/parameters/Cursor" + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 100 + responses: + "200": + description: Publication manifest page + content: + application/json: + schema: + $ref: "#/components/schemas/RagPublicationManifestResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + description: Invalid `space` parameter + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /pages/roots: + get: + tags: [Pages] + operationId: listRootPages + description: Top-level pages (no parent), cursor-paginated. + parameters: + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Root pages + content: + application/json: + schema: + $ref: "#/components/schemas/PageChildrenResponse" + "401": + $ref: "#/components/responses/Unauthorized" + /pages/preview: + post: + tags: [Pages] + operationId: previewMarkdown + description: >- + Requires a reader cookie session or a `pages:read` service-token scope. Renders + arbitrary Markdown through the exact same server-side pipeline (Markdig with + `.DisableHtml()` plus the allowlist `HtmlSanitizer` pass) used for a published + page revision — safe to call with unsaved editor content, never persists + anything. The same safe pipeline is reused for project free-text Markdown + fields (`currentStateMarkdown`, `summaryMarkdown`, etc. — + docs/adr/ADR-029-v2-r5-final-hardening.md). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewMarkdownRequest" + responses: + "200": + description: Rendered, sanitized HTML + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewMarkdownResponse" + "401": + $ref: "#/components/responses/Unauthorized" + /pages: + post: + tags: [Pages] + operationId: createPage + parameters: + - $ref: "#/components/parameters/CorrelationId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreatePageRequest" + responses: + "200": + description: Page created and published as revision 1 + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/PageDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + description: Validation failed, or an unknown templateKey was supplied + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /pages/{pageId}: + parameters: + - $ref: "#/components/parameters/PageId" + - $ref: "#/components/parameters/CorrelationId" + get: + tags: [Pages] + operationId: getPage + responses: + "200": + description: Current page, with server-sanitized rendered HTML + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/PageDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Pages] + operationId: updatePage + description: Publishes a new revision. Requires If-Match with the page's current ETag. + parameters: + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdatePageRequest" + responses: + "200": + description: Updated page + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/PageDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + description: Page is trashed and must be restored before it can be edited + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "412": + $ref: "#/components/responses/PreconditionFailed" + "422": + $ref: "#/components/responses/BadRequest" + "428": + $ref: "#/components/responses/PreconditionRequired" + /pages/{pageId}/children: + get: + tags: [Pages] + operationId: listPageChildren + parameters: + - $ref: "#/components/parameters/PageId" + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Direct children, cursor-paginated (proven at 10,000-child scale, docs/27-acceptance-matrix.md M2-A07) + content: + application/json: + schema: + $ref: "#/components/schemas/PageChildrenResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /pages/{pageId}/move: + post: + tags: [Pages] + operationId: movePage + description: >- + Reparents and/or reorders a page among its siblings. Rejects a move that would + make a page its own descendant. Requires If-Match with the page's current ETag. + parameters: + - $ref: "#/components/parameters/PageId" + - $ref: "#/components/parameters/CorrelationId" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MovePageRequest" + responses: + "200": + description: Moved page + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/PageDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + description: The requested move would make the page its own descendant (a cycle) + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + /pages/{pageId}/trash: + post: + tags: [Pages] + operationId: trashPage + description: >- + Moves a page to the trash/retention model. Never a permanent delete + (docs/09-data-safety). Requires If-Match with the page's current ETag and + accepts either an editor cookie session or a `pages:write` service token, + enabling bounded import rollback without permanent deletion. + parameters: + - $ref: "#/components/parameters/PageId" + - $ref: "#/components/parameters/CorrelationId" + - $ref: "#/components/parameters/IfMatch" + responses: + "200": + description: Page trashed + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + /pages/{pageId}/restore: + post: + tags: [Pages] + operationId: restorePage + description: Restores a trashed page. Requires If-Match with the page's current ETag. + parameters: + - $ref: "#/components/parameters/PageId" + - $ref: "#/components/parameters/CorrelationId" + - $ref: "#/components/parameters/IfMatch" + responses: + "200": + description: Page restored + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + /pages/{pageId}/revisions: + get: + tags: [Pages] + operationId: listPageRevisions + description: Immutable revision history, most recent first (M3-A01/M3-A02). + parameters: + - $ref: "#/components/parameters/PageId" + - $ref: "#/components/parameters/CorrelationId" + responses: + "200": + description: Revisions + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/PageRevisionSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /pages/{pageId}/revisions/{revisionNumber}: + get: + tags: [Pages] + operationId: getPageRevision + parameters: + - $ref: "#/components/parameters/PageId" + - $ref: "#/components/parameters/RevisionNumber" + - $ref: "#/components/parameters/CorrelationId" + responses: + "200": + description: One revision's full content, server-rendered + content: + application/json: + schema: + $ref: "#/components/schemas/PageRevisionDetailResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /pages/{pageId}/revisions/{revisionNumber}/restore: + post: + tags: [Pages] + operationId: restorePageRevision + description: >- + Publishes a new current revision whose content matches the chosen past + snapshot. Never rewrites history: the state right before this call is itself + preserved as its own revision. Requires If-Match with the page's current ETag. + parameters: + - $ref: "#/components/parameters/PageId" + - $ref: "#/components/parameters/RevisionNumber" + - $ref: "#/components/parameters/CorrelationId" + - $ref: "#/components/parameters/IfMatch" + responses: + "200": + description: Restored page + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/PageDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + description: Page is trashed and must be restored before it can be edited + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + /pages/{pageId}/backlinks: + get: + tags: [Pages] + operationId: getPageBacklinks + description: >- + "What links here": pages whose most recently published content contains a + resolved internal link to this page (M3-A03). + parameters: + - $ref: "#/components/parameters/PageId" + - $ref: "#/components/parameters/CorrelationId" + responses: + "200": + description: Backlinks + content: + application/json: + schema: + $ref: "#/components/schemas/BacklinksResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /drafts/{draftKey}: + parameters: + - $ref: "#/components/parameters/DraftKey" + - $ref: "#/components/parameters/CorrelationId" + get: + tags: [Drafts] + operationId: getDraft + description: >- + Private per-user autosaved draft. A draft never overwrites the published + revision (docs/27-acceptance-matrix.md, M2-A04). + responses: + "200": + description: Draft + content: + application/json: + schema: + $ref: "#/components/schemas/DraftResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Drafts] + operationId: saveDraft + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SaveDraftRequest" + responses: + "200": + description: >- + Draft saved. `baseRevisionIsStale` is true when the page has been published + again since this draft's base revision, without blocking the save. + content: + application/json: + schema: + $ref: "#/components/schemas/DraftResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + delete: + tags: [Drafts] + operationId: discardDraft + responses: + "200": + description: Draft discarded + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /templates: + get: + tags: [Templates] + operationId: listTemplates + description: Starter document templates available when creating a new page. + responses: + "200": + description: Available templates + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TemplateSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /pages/{pageId}/attachments: + parameters: + - $ref: "#/components/parameters/PageId" + - $ref: "#/components/parameters/CorrelationId" + get: + tags: [Attachments] + operationId: listPageAttachments + description: Requires a reader cookie session or an `attachments:read` service-token scope. + responses: + "200": + description: Attachments referenced by this page + content: + application/json: + schema: + $ref: "#/components/schemas/AttachmentListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [Attachments] + operationId: uploadAttachment + description: >- + Requires an editor cookie session or an `attachments:write` service-token + scope. + The upload pipeline from docs/13-attachments.md: streams once to quarantine + while hashing, verifies size, detects the real signature (never trusting the + declared type/extension alone), applies delivery policy, then commits. + Identical content deduplicates to one physical blob while still creating an + independent logical reference. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + responses: + "200": + description: Uploaded + content: + application/json: + schema: + $ref: "#/components/schemas/AttachmentSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "413": + description: File exceeds the configured maximum upload size + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "422": + description: Rejected content (unsafe signature) or an invalid filename + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /pages/staged: + post: + tags: [Pages] + operationId: beginPageCreationSession + description: >- + Opens a staging session for creating a new page that may need attachments + inserted before the page is ever visible to a reader + (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md). Requires an editor + cookie session or a `pages:write` service-token scope. + parameters: + - $ref: "#/components/parameters/CorrelationId" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BeginPageCreationSessionRequest" + responses: + "200": + description: Session opened + content: + application/json: + schema: + $ref: "#/components/schemas/PageCreationSessionResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + description: The supplied parentPageId does not exist + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /pages/staged/{sessionId}/attachments: + parameters: + - $ref: "#/components/parameters/SessionId" + - $ref: "#/components/parameters/CorrelationId" + post: + tags: [Attachments] + operationId: uploadStagedAttachment + description: >- + The same real, immediately-addressable attachment as + `POST /pages/{pageId}/attachments` — owned by an open page-creation session + instead of an existing page until that session's commit re-points ownership + atomically. Requires an editor cookie session or an `attachments:write` + service-token scope. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + responses: + "200": + description: Uploaded + content: + application/json: + schema: + $ref: "#/components/schemas/AttachmentSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + description: The session does not exist, is no longer open, or has expired + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "413": + description: File exceeds the configured maximum upload size + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "422": + description: Rejected content (unsafe signature) or an invalid filename + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /pages/staged/{sessionId}/commit: + parameters: + - $ref: "#/components/parameters/SessionId" + - $ref: "#/components/parameters/CorrelationId" + post: + tags: [Pages] + operationId: commitPageCreationSession + description: >- + The single explicit publication action: atomically creates the page and + re-points every attachment already uploaded into this session to it, in one + transaction — no half-published page with broken attachment links can ever + become visible (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md). + Requires an editor cookie session or a `pages:write` service-token scope. + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CommitPageCreationSessionRequest" + responses: + "200": + description: Page created and published, with every staged attachment now owned by it + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/PageDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + description: The session does not exist + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "409": + description: The session is no longer open (already committed, discarded, or expired) + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "422": + $ref: "#/components/responses/BadRequest" + /pages/staged/{sessionId}/discard: + parameters: + - $ref: "#/components/parameters/SessionId" + - $ref: "#/components/parameters/CorrelationId" + post: + tags: [Pages] + operationId: discardPageCreationSession + description: >- + Explicit compensation for an abandoned session: removes every attachment + uploaded into it that is not used elsewhere, then marks the session + discarded. Idempotent — discarding an already-discarded session is a safe + no-op. Requires an editor cookie session or a `pages:write` service-token + scope. + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + responses: + "200": + description: Session discarded (or already was) + content: + application/json: + schema: + $ref: "#/components/schemas/DiscardPageCreationSessionResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + description: The session does not exist + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "409": + description: The session was already committed and can no longer be discarded + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /infrastructure/overview: + get: + tags: [Infrastructure] + operationId: getInfrastructureOverview + description: >- + Requires a reader cookie session or an `infrastructure:read` service-token + scope. The dashboard summary: the most recent inventory snapshot (if any), + every known host, and aggregate service counts (total, proposed/unreviewed, + missing, unhealthy, unprotected-by-backup). Returns an all-zero/empty response + rather than an error when no workspace exists yet. + responses: + "200": + description: Infrastructure overview + content: + application/json: + schema: + $ref: "#/components/schemas/InfrastructureOverviewResponse" + "401": + $ref: "#/components/responses/Unauthorized" + /infrastructure/services: + get: + tags: [Infrastructure] + operationId: listInfrastructureServices + description: >- + Requires a reader cookie session or an `infrastructure:read` service-token + scope. Cursor-paginated services catalog + (docs/adr/ADR-026-v2-r2-infrastructure-scale-and-sync-durability.md) — proven + at real production scale (InfrastructureCatalogReleaseScaleTests.cs, 50,000 + services) with an index-order scan, never a full in-memory load. `projectId`, + `search` and `status` are applied server-side on the same paginated query; + filter results never depend on pages already loaded by the client. + parameters: + - name: includeHidden + in: query + required: false + description: Defaults to false — hidden/archived services are excluded from the default catalog view. + schema: + type: boolean + - name: projectId + in: query + required: false + description: Only services whose editorial profile links this project. + schema: + type: string + format: uuid + - name: search + in: query + required: false + description: Case-insensitive match against detected name, image reference, and editorial display name/category. + schema: + type: string + - name: status + in: query + required: false + description: Server-side operational status filter. + schema: + type: string + enum: [attention, healthy, missing] + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: A page of services, newest-`(DetectedName, Id)`-ordered + content: + application/json: + schema: + $ref: "#/components/schemas/InfrastructureServiceListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + /infrastructure/storage: + get: + tags: [Infrastructure] + operationId: listInfrastructureStorage + description: >- + Requires a reader cookie session or an `infrastructure:read` service-token + scope. Cursor-paginated storage listing (V2-R5, + docs/adr/ADR-029-v2-r5-final-hardening.md) — every known volume across every + service, each row carrying its owning service's ID/name for display. + parameters: + - name: search + in: query + required: false + description: Case-insensitive match against the owning service's detected name and the volume's own name/mount path, applied server-side. + schema: + type: string + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: A page of storage volumes + content: + application/json: + schema: + $ref: "#/components/schemas/InfrastructureStorageListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + /infrastructure/services/{serviceId}: + get: + tags: [Infrastructure] + operationId: getInfrastructureService + description: >- + Requires a reader cookie session or an `infrastructure:read` service-token + scope. The full fact graph for one service: editorial profile, manual + overrides, components, endpoints, networks, volumes, databases, dependencies, + health observations, deployment references and backup coverage, each fact + carrying its own provenance (source key, confidence, last-seen snapshot, + observed-at). The `ETag` response header is the editorial profile's + optimistic-concurrency token, required as `If-Match` on + `PUT .../editorial-profile`. + parameters: + - name: serviceId + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Full service detail + headers: + ETag: + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/InfrastructureServiceDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /infrastructure/services/{serviceId}/editorial-profile: + put: + tags: [Infrastructure] + operationId: updateInfrastructureEditorialProfile + description: >- + Editor role or `infrastructure:write` service-token scope required, CSRF- and + `If-Match`-protected. Updates the human-authored editorial layer for a service + (display name, summary, category, purpose, owning project, procedures, tags, + favorite/hidden/archived flags) — automatically-detected facts are never + touched by this endpoint. A stale `If-Match` is a 412, never a silent overwrite. + parameters: + - name: serviceId + in: path + required: true + schema: + type: string + format: uuid + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateEditorialProfileRequest" + responses: + "200": + description: Updated service detail + headers: + ETag: + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/InfrastructureServiceDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "412": + description: If-Match did not match the current editorial profile ETag + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "422": + $ref: "#/components/responses/ValidationFailed" + "428": + description: If-Match header is required and was missing + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /infrastructure/services/{serviceId}/overrides: + post: + tags: [Infrastructure] + operationId: setManualOverride + description: >- + Editor role or `infrastructure:write` service-token scope required, + CSRF-protected. Sets (creates or replaces) one field-level manual correction + over an automatically-detected fact, validated against the exact allowlist + `GET /infrastructure/overrides/catalog` returns for this object kind — an + inventory sync never overwrites or removes an existing override + (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H2). + parameters: + - name: serviceId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetManualOverrideRequest" + responses: + "200": + description: The created/updated override's ID + content: + application/json: + schema: + type: object + properties: + id: + type: string + format: uuid + required: [id] + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/ValidationFailed" + /infrastructure/overrides: + post: + tags: [Infrastructure] + operationId: setInfrastructureOverride + description: >- + Editor role or `infrastructure:write` service-token scope required, + CSRF-protected. The generic sibling of + `POST /infrastructure/services/{serviceId}/overrides` (V2-R5, + docs/adr/ADR-029-v2-r5-final-hardening.md): sets a field-level manual + correction for any `InfrastructureObjectKind` (Host, Network, Volume, Database, + Endpoint, Component, Deployment, Backup), not only a Service reached through + its own nested route — validated against the exact allowlist + `GET /infrastructure/overrides/catalog?targetKind=...` returns for that kind. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetInfrastructureOverrideRequest" + responses: + "200": + description: The created/updated override's ID + content: + application/json: + schema: + type: object + properties: + id: + type: string + format: uuid + required: [id] + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/ValidationFailed" + /infrastructure/overrides/{overrideId}: + delete: + tags: [Infrastructure] + operationId: removeManualOverride + description: >- + Editor role or `infrastructure:write` service-token scope required, + CSRF-protected. Removes one manual override, reverting that field to its + automatically-detected value (if any). + parameters: + - name: overrideId + in: path + required: true + schema: + type: string + format: uuid + responses: + "204": + description: Override removed + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /infrastructure/services/{serviceId}/review: + post: + tags: [Infrastructure] + operationId: reviewInfrastructureService + description: >- + Administrator only, CSRF-protected. Accepts or rejects a `Proposed` (newly + detected, unreviewed) service — the deliberate human-in-the-loop gate before an + automatically-detected container becomes part of the confirmed catalog. A + name match alone is never treated as a high-confidence relationship + (docs/adr/ADR-029-v2-r5-final-hardening.md). + parameters: + - name: serviceId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ReviewInfrastructureServiceRequest" + responses: + "200": + description: The service's resulting presence state + content: + application/json: + schema: + $ref: "#/components/schemas/ReviewInfrastructureServiceResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/ValidationFailed" + /infrastructure/inventory/snapshots: + post: + tags: [Infrastructure] + operationId: synchronizeInfrastructureInventory + description: >- + Administrator only, CSRF- and `Idempotency-Key`-protected. Imports one + inventory snapshot (hosts + services + every nested fact) captured by the + external, read-only inventory tooling — never destructive: existing editorial + profiles and manual overrides are always preserved, a previously-seen service + that is missing from this snapshot is marked missing (not deleted), and + replaying the same `sourceKey`/`sourceHash` is a safe no-op + (`alreadyImported: true`), not a duplicate import. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SynchronizeInventoryRequest" + responses: + "200": + description: Synchronization result + content: + application/json: + schema: + $ref: "#/components/schemas/InventorySyncResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + description: Idempotency-Key reused with a different request body + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "422": + $ref: "#/components/responses/ValidationFailed" + /infrastructure/inventory/curated-package: + post: + tags: [Infrastructure] + operationId: importCuratedInventoryPackage + description: >- + Administrator only, CSRF- and `Idempotency-Key`-protected. A single bounded + call that both synchronizes a raw inventory snapshot and applies a curated set + of editorial profiles/review decisions on top of it in one step — the + mechanism the curated-infrastructure-wiki publication used + (docs/deployments/2026-08-02-curated-infrastructure-wiki.md). Never overwrites + an editorial profile a human has since hand-edited. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ImportCuratedInventoryRequest" + responses: + "200": + description: Combined synchronization + curation result + content: + application/json: + schema: + $ref: "#/components/schemas/ImportCuratedInventoryResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + description: Idempotency-Key reused with a different request body + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "422": + $ref: "#/components/responses/ValidationFailed" + /infrastructure/changes: + get: + tags: [Infrastructure] + operationId: listInfrastructureChanges + description: >- + Requires a reader cookie session or an `infrastructure:read` service-token + scope. The most recent inventory-sync change log entries (new/changed/missing + objects), newest first, each naming its target kind/ID, source key, change + kind and the specific fields that changed. + parameters: + - name: limit + in: query + required: false + description: Defaults to 100. + schema: + type: integer + minimum: 1 + maximum: 500 + responses: + "200": + description: Recent inventory changes, newest first + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/InventoryChangeResponse" + "401": + $ref: "#/components/responses/Unauthorized" + /infrastructure/overrides/catalog: + get: + tags: [Infrastructure] + operationId: getManualOverrideFieldCatalog + description: >- + The exact, server-authoritative set of fields a ManualOverride may target for + the given InfrastructureObjectKind, with each field's JSON value kind, max + length and (for enum fields) allowed values + (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H3). The + frontend fetches this instead of keeping its own list, so the two can never + independently drift. Requires a reader cookie session or an + `infrastructure:read` service-token scope. + parameters: + - name: targetKind + in: query + required: false + description: Defaults to Service, the only kind reachable through the current UI/API. + schema: + type: string + enum: [Host, Service, Component, Endpoint, Network, Volume, Database, Deployment, Backup] + responses: + "200": + description: Field catalog for the requested target kind + content: + application/json: + schema: + $ref: "#/components/schemas/ManualOverrideFieldCatalogResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + description: Unknown targetKind + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /attachments/{attachmentId}/content: + get: + tags: [Attachments] + operationId: getAttachmentContent + description: >- + Requires a reader cookie session or an `attachments:read` service-token scope. + Every request re-authorizes the logical reference (docs/13-attachments.md) — + a content hash alone is never sufficient permission to retrieve a blob. Served + with a safe Content-Disposition (inline only for a known-safe raster image, + attachment otherwise) and X-Content-Type-Options: nosniff. + parameters: + - name: attachmentId + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: The raw file content + content: + application/octet-stream: {} + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /admin/imports/preflight: + post: + tags: [Imports] + operationId: preflightImport + description: >- + Administrator only. Discovers and fingerprints a read-only source beneath the + operator-mounted imports directory, selects an adapter, and builds a plan — + no target mutation occurs (docs/17-leafwiki-migration.md). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PreflightImportRequest" + responses: + "200": + description: Plan ready (or an idempotent repeat of an already-preflighted request) + content: + application/json: + schema: + $ref: "#/components/schemas/ImportPreflightResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + description: Invalid source path, or no adapter can interpret this source + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /admin/imports/{jobId}: + get: + tags: [Imports] + operationId: getImportJob + parameters: + - $ref: "#/components/parameters/ImportJobId" + responses: + "200": + description: Import job status + content: + application/json: + schema: + $ref: "#/components/schemas/ImportJobSummaryResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /admin/imports/{jobId}/execute: + post: + tags: [Imports] + operationId: executeImport + description: >- + Administrator only. Re-verifies the source fingerprint and plan hash have not + changed since preflight, then creates each planned page — parents before + children — with full provenance for rollback. Never overwrites an existing + page; an item already imported with unchanged content is skipped idempotently. + parameters: + - $ref: "#/components/parameters/ImportJobId" + - name: sourceRelativePath + in: query + required: true + description: The same relative path supplied to preflight, re-verified against its recorded fingerprint. + schema: + type: string + responses: + "200": + description: Execution result + content: + application/json: + schema: + $ref: "#/components/schemas/ExecuteImportResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + description: The source or plan changed since preflight, or the job is not in a plan-ready state + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /admin/imports/{jobId}/rollback: + post: + tags: [Imports] + operationId: rollbackImport + description: >- + Administrator only. Removes exactly the pages/attachments this job's own + provenance created — never a page a user has since edited, which is preserved + instead (docs/17-leafwiki-migration.md). + parameters: + - $ref: "#/components/parameters/ImportJobId" + responses: + "200": + description: Rollback result + content: + application/json: + schema: + $ref: "#/components/schemas/RollbackImportResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + description: There is nothing to roll back for this job + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /admin/source-batches/purge/dry-run: + post: + tags: [Source batch purge] + operationId: dryRunSourceBatchPurge + description: >- + Administrator only. Inspects an explicitly supplied, independently recorded + page-ID list for one source batch. It blocks on missing or unlisted source + pages, derived content outside the list, retained backlinks or project links. + This endpoint never writes data. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SourceBatchPurgeRequest" + responses: + "200": + description: Immutable-scope dry-run and confirmation hash + content: + application/json: + schema: + $ref: "#/components/schemas/SourceBatchPurgeDryRunResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /admin/source-batches/purge/execute: + post: + tags: [Source batch purge] + operationId: executeSourceBatchPurge + description: >- + Administrator only. Permanently removes only the pages and attachment + references proven by the preceding dry-run. The confirmation hash binds the + request to the current page hashes and attachment graph; any change requires a + new dry-run. Physical blobs are hash-verified and removed only after their + database references have been committed as absent. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ExecuteSourceBatchPurgeRequest" + responses: + "200": + description: Completed source-batch purge + content: + application/json: + schema: + $ref: "#/components/schemas/SourceBatchPurgeResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + description: The dry-run changed or the source scope is ambiguous + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /projects: + get: + tags: [Projects] + operationId: listProjects + parameters: + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + - name: status + in: query + schema: + $ref: "#/components/schemas/ProjectStatus" + - name: priority + in: query + schema: + $ref: "#/components/schemas/ProjectPriority" + - name: ownerId + in: query + schema: + type: string + format: uuid + - name: search + in: query + description: Case-insensitive match against project name or summary, applied before cursor pagination. + schema: + type: string + - name: updatedAfter + in: query + description: Only projects updated at or after this ISO-8601 timestamp. + schema: + type: string + format: date-time + - name: sort + in: query + schema: + type: string + enum: [name, updatedAt] + responses: + "200": + description: Project collection + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [Projects] + operationId: createProject + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateProjectRequest" + responses: + "200": + description: Created project (full bundle) + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectDetail" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /projects/totals: + get: + tags: [Projects] + operationId: getProjectCatalogTotals + description: Authoritative workspace totals for dashboard KPI rendering; independent of cursor-paginated catalog pages. + responses: + "200": + description: Total and active project counts + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectCatalogTotalsResponse" + "401": + $ref: "#/components/responses/Unauthorized" + /projects/{projectId}: + parameters: + - $ref: "#/components/parameters/ProjectId" + get: + tags: [Projects] + operationId: getProject + description: >- + The project's bounded context bundle — structured data plus stable citations + into linked pages, never a copy of their content (docs/15-project-hub.md + "Project context bundle", M5-A05). + responses: + "200": + description: Project detail bundle (documents, recent updates, milestones, decisions) + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Projects] + operationId: updateProject + parameters: + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateProjectRequest" + responses: + "200": + description: Updated project + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectDetail" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + /projects/{projectId}/context: + parameters: + - $ref: "#/components/parameters/ProjectId" + get: + tags: [Projects] + operationId: getProjectContext + description: >- + A bounded, single-call context bundle for automation and future MCP-server + consumers (docs/09-api-contract.md "Project context", docs/24-mcp-readiness.md, + M6-A04). Update/decision/document counts are clamped; the response reports + what was truncated instead of ever growing unbounded. Distinct from the + unbounded `GET /projects/{projectId}` detail bundle. + responses: + "200": + description: Bounded project context bundle with stable citations + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectContextResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /projects/{projectId}/icon: + parameters: + - $ref: "#/components/parameters/ProjectId" + post: + tags: [Projects] + operationId: uploadProjectIcon + description: >- + Replaces the current project icon using the attachment quarantine and + byte-signature validation pipeline. Accepts only PNG, JPEG, GIF or WebP up to + 2 MiB. The response points to same-origin, workspace-authorized attachment + content. Requires the project's current ETag. + parameters: + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + responses: + "200": + description: Project icon replaced + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectIconResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "412": + $ref: "#/components/responses/PreconditionFailed" + "413": + description: Icon file exceeds 2 MiB + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "422": + description: Unsupported or invalid image content + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "428": + $ref: "#/components/responses/PreconditionRequired" + /projects/{projectId}/status: + parameters: + - $ref: "#/components/parameters/ProjectId" + post: + tags: [Projects] + operationId: changeProjectStatus + description: Only the documented lifecycle transitions are accepted (docs/15-project-hub.md). + parameters: + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [status] + properties: + status: + $ref: "#/components/schemas/ProjectStatus" + responses: + "200": + description: Updated project + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + description: The requested transition is not allowed from the project's current status + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + /projects/{projectId}/documents: + parameters: + - $ref: "#/components/parameters/ProjectId" + post: + tags: [Projects] + operationId: linkProjectDocument + description: >- + Links a page to the project with a role; the page remains canonical, only the + relationship is stored (docs/15-project-hub.md). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LinkProjectDocumentRequest" + responses: + "200": + description: Created link + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectDocumentSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /projects/documents/{documentId}: + parameters: + - name: documentId + in: path + required: true + schema: + type: string + format: uuid + delete: + tags: [Projects] + operationId: unlinkProjectDocument + description: Removes only the relationship — the linked page is never touched or deleted. + responses: + "200": + description: Unlinked + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /projects/{projectId}/updates: + parameters: + - $ref: "#/components/parameters/ProjectId" + get: + tags: [Projects] + operationId: getProjectUpdates + description: Ordered, attributable, linkable timeline (M5-A02). + parameters: + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Update timeline page + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectUpdatesResponse" + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [Projects] + operationId: createProjectUpdate + description: >- + Immutable, append-only entry. Setting supersedesUpdateId marks the earlier + update Superseded without rewriting it (docs/15-project-hub.md). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateProjectUpdateRequest" + responses: + "200": + description: Created update + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectUpdateSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /projects/{projectId}/milestones: + parameters: + - $ref: "#/components/parameters/ProjectId" + get: + tags: [Projects] + operationId: getProjectMilestones + responses: + "200": + description: Milestones, in their stable sort order + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ProjectMilestoneSummary" + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [Projects] + operationId: createProjectMilestone + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateProjectMilestoneRequest" + responses: + "200": + description: Created milestone + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectMilestoneSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /projects/milestones/{milestoneId}: + parameters: + - name: milestoneId + in: path + required: true + schema: + type: string + format: uuid + put: + tags: [Projects] + operationId: updateProjectMilestone + parameters: + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateProjectMilestoneRequest" + responses: + "200": + description: Updated milestone + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectMilestoneSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + /decisions: + get: + tags: [Decisions] + operationId: listDecisions + parameters: + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + - name: projectId + in: query + schema: + type: string + format: uuid + - name: status + in: query + schema: + $ref: "#/components/schemas/DecisionStatus" + responses: + "200": + description: Decision collection + content: + application/json: + schema: + $ref: "#/components/schemas/DecisionListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [Decisions] + operationId: createDecision + description: >- + Creates one ArchitectureDecision page plus its lifecycle profile in one step + (docs/16-runbooks-and-adrs.md "unique page profile"). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateDecisionRequest" + responses: + "200": + description: Created decision (full detail, including rendered content) + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/DecisionDetail" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /decisions/{decisionId}: + parameters: + - name: decisionId + in: path + required: true + schema: + type: string + format: uuid + get: + tags: [Decisions] + operationId: getDecision + description: Includes lineage (supersedes/supersededBy) — links are bidirectional and cycle-free. + responses: + "200": + description: Decision detail with lineage + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/DecisionDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /decisions/{decisionId}/accept: + parameters: + - name: decisionId + in: path + required: true + schema: + type: string + format: uuid + post: + tags: [Decisions] + operationId: acceptDecision + description: >- + If this decision supersedes another, that decision must currently be Accepted + and is atomically flipped to Superseded with bidirectional links + (docs/16-runbooks-and-adrs.md). + parameters: + - $ref: "#/components/parameters/IfMatch" + responses: + "200": + description: Accepted decision + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/DecisionDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + description: Only a Proposed decision can be accepted + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + /decisions/{decisionId}/reject: + parameters: + - name: decisionId + in: path + required: true + schema: + type: string + format: uuid + post: + tags: [Decisions] + operationId: rejectDecision + parameters: + - $ref: "#/components/parameters/IfMatch" + responses: + "200": + description: Rejected decision + content: + application/json: + schema: + $ref: "#/components/schemas/DecisionDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + description: Only a Proposed decision can be rejected + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + /decisions/{decisionId}/withdraw: + parameters: + - name: decisionId + in: path + required: true + schema: + type: string + format: uuid + post: + tags: [Decisions] + operationId: withdrawDecision + parameters: + - $ref: "#/components/parameters/IfMatch" + responses: + "200": + description: Withdrawn decision + content: + application/json: + schema: + $ref: "#/components/schemas/DecisionDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + description: Only a Proposed or Accepted decision can be withdrawn + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + /runbooks: + get: + tags: [Runbooks] + operationId: listRunbooks + parameters: + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + - name: riskLevel + in: query + schema: + $ref: "#/components/schemas/RunbookRiskLevel" + - name: status + in: query + schema: + $ref: "#/components/schemas/RunbookStatus" + responses: + "200": + description: Runbook collection + content: + application/json: + schema: + $ref: "#/components/schemas/RunbookListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [Runbooks] + operationId: createRunbook + description: >- + Creates one Runbook page plus its validation/risk profile in one step + (docs/16-runbooks-and-adrs.md "unique page profile"). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateRunbookRequest" + responses: + "200": + description: Created runbook (full detail, including rendered content) + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/RunbookDetail" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /runbooks/{runbookId}: + parameters: + - name: runbookId + in: path + required: true + schema: + type: string + format: uuid + get: + tags: [Runbooks] + operationId: getRunbook + description: >- + isStale is true whenever the linked page has a newer revision than the last + validation, or was never validated (docs/16-runbooks-and-adrs.md). + responses: + "200": + description: Runbook detail + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/RunbookDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /runbooks/{runbookId}/validate: + parameters: + - name: runbookId + in: path + required: true + schema: + type: string + format: uuid + post: + tags: [Runbooks] + operationId: validateRunbook + description: >- + Records that a human reviewed the current revision — the platform never + executes any step (docs/16-runbooks-and-adrs.md). + parameters: + - $ref: "#/components/parameters/IfMatch" + responses: + "200": + description: Validated runbook + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/RunbookDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + /runbooks/{runbookId}/profile: + parameters: + - name: runbookId + in: path + required: true + schema: + type: string + format: uuid + put: + tags: [Runbooks] + operationId: updateRunbookProfile + parameters: + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateRunbookProfileRequest" + responses: + "200": + description: Updated runbook + headers: + ETag: + $ref: "#/components/headers/ETag" + content: + application/json: + schema: + $ref: "#/components/schemas/RunbookDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + /service-accounts: + get: + tags: [Service accounts] + operationId: listServiceAccounts + description: Administrator-only, cookie-authenticated — never reachable by a service token itself (docs/09-api-contract.md). + responses: + "200": + description: Service accounts and their (secret-free) tokens + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceAccountListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + post: + tags: [Service accounts] + operationId: createServiceAccount + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateServiceAccountRequest" + responses: + "200": + description: Service account and its one-time raw token — never retrievable again + content: + application/json: + schema: + $ref: "#/components/schemas/CreatedServiceAccountToken" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + description: One or more requested scopes are not recognized + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + /service-accounts/{serviceAccountId}/tokens: + parameters: + - name: serviceAccountId + in: path + required: true + schema: + type: string + format: uuid + post: + tags: [Service accounts] + operationId: issueServiceAccountToken + description: >- + Rotation pattern: issue a new token, update the client, then revoke the old + one — a credential is never briefly unusable. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/IssueServiceAccountTokenRequest" + responses: + "200": + description: New token — its one-time raw value + content: + application/json: + schema: + $ref: "#/components/schemas/CreatedServiceAccountToken" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /service-accounts/tokens/{tokenId}/revoke: + parameters: + - name: tokenId + in: path + required: true + schema: + type: string + format: uuid + post: + tags: [Service accounts] + operationId: revokeServiceAccountToken + description: Takes effect on the very next request using this token — tokens are verified live against the database, never cached as valid. + responses: + "200": + description: Revoked + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /admin/backups: + get: + tags: [Backups] + operationId: listBackups + description: >- + Administrator-only (docs/18-backup-restore.md). Never includes a filesystem + path or connection string — only what an administrator needs to judge backup + health. + responses: + "200": + description: Completed backups, newest first + content: + application/json: + schema: + $ref: "#/components/schemas/BackupListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + post: + tags: [Backups] + operationId: createBackup + description: >- + Synchronously creates and verifies a full backup (every table plus every + content-addressed attachment blob), then applies retention. A failed backup + never replaces the last known-good one. Restore is intentionally not exposed + here — it runs as a separate maintenance-mode invocation of the same + deployable image (docs/18-backup-restore.md; ADR-016 decision 4). + responses: + "200": + description: The newly created, verified backup + content: + application/json: + schema: + $ref: "#/components/schemas/BackupSummaryResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + description: A backup is already in progress + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" +components: + securitySchemes: + cookieAuth: + type: apiKey + in: cookie + name: ikh_auth + bearerAuth: + type: http + scheme: bearer + bearerFormat: IKH service token + parameters: + CorrelationId: + name: X-Correlation-ID + in: header + required: false + schema: + type: string + maxLength: 100 + IdempotencyKey: + name: Idempotency-Key + in: header + required: false + schema: + type: string + minLength: 16 + maxLength: 200 + RequiredIdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: + type: string + minLength: 1 + maxLength: 200 + Cursor: + name: cursor + in: query + required: false + schema: + type: string + maxLength: 2048 + Limit: + name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 25 + PageId: + name: pageId + in: path + required: true + schema: + type: string + format: uuid + SessionId: + name: sessionId + in: path + required: true + schema: + type: string + format: uuid + RevisionNumber: + name: revisionNumber + in: path + required: true + schema: + type: integer + format: int64 + minimum: 1 + ImportJobId: + name: jobId + in: path + required: true + schema: + type: string + format: uuid + ProjectId: + name: projectId + in: path + required: true + schema: + type: string + format: uuid + UserId: + name: userId + in: path + required: true + schema: + type: string + format: uuid + DraftKey: + name: draftKey + in: path + required: true + description: The page ID in edit mode, or a client-generated key for an unpublished new page. + schema: + type: string + IfMatch: + name: If-Match + in: header + required: true + schema: + type: string + headers: + ETag: + description: Strong entity version tag + schema: + type: string + responses: + BadRequest: + description: Invalid request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + Unauthorized: + description: Authentication required + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + Forbidden: + description: Insufficient permission + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + NotFound: + description: Resource not found or not disclosed + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + Conflict: + description: Idempotency or domain conflict + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + PreconditionFailed: + description: ETag does not match the current resource + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + PreconditionRequired: + description: If-Match is required + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + ValidationFailed: + description: Request failed validation + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + RateLimited: + description: Request rate limited + headers: + Retry-After: + schema: + type: integer + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + schemas: + CreateIngestionProposalRequest: + type: object + required: [manifest] + properties: + manifest: { type: string, minLength: 1, maxLength: 100000 } + correlationId: { type: [string, "null"], maxLength: 200 } + publication: { oneOf: [{ $ref: "#/components/schemas/IngestionPublicationRequest" }, { type: "null" }] } + IngestionPublicationRequest: + type: object + required: [title, markdown, documentType, documentStatus, mutationKind] + properties: + title: { type: string, minLength: 1, maxLength: 200 } + markdown: { type: string, minLength: 1, maxLength: 100000 } + documentType: { $ref: "#/components/schemas/DocumentType" } + documentStatus: { $ref: "#/components/schemas/DocumentStatus" } + mutationKind: { type: string, enum: [CreatePage, UpdatePage] } + parentPageId: { type: [string, "null"], format: uuid } + targetPageId: { type: [string, "null"], format: uuid } + expectedTargetETag: { type: [string, "null"], maxLength: 32 } + PatchIngestionProposalRequest: + type: object + description: Human callers use manifest/rationale; the submitting service account uses analysis only. + properties: + manifest: { type: [string, "null"], maxLength: 100000 } + rationale: { type: [string, "null"], maxLength: 2000 } + analysis: { type: [string, "null"], maxLength: 100000 } + IngestionProposalDecisionRequest: + type: object + properties: + comment: { type: [string, "null"], maxLength: 2000 } + IngestionProposalSourceDetail: + type: object + required: [id, fileName, mimeType, byteSize, digestSha256, scanResult, attachmentReferenceId] + properties: + id: { type: string, format: uuid } + fileName: { type: string } + mimeType: { type: string } + byteSize: { type: integer, format: int64 } + digestSha256: { type: string, pattern: '^[a-f0-9]{64}$' } + scanResult: { type: string } + attachmentReferenceId: { type: string, format: uuid } + IngestionProposalRevisionDetail: + type: object + required: [revisionNumber, status, operation, actorId, occurredAt] + properties: + revisionNumber: { type: integer, format: int64 } + status: { type: string } + operation: { type: string } + actorId: { type: string, format: uuid } + occurredAt: { type: string, format: date-time } + IngestionProposalSummary: + type: object + required: [id, correlationId, status, revisionNumber, sourceCount, submitterId, createdAt, destination, metadata, mutationKind] + properties: + id: { type: string, format: uuid } + correlationId: { type: string } + status: { type: string } + revisionNumber: { type: integer, format: int64 } + sourceCount: { type: integer } + submitterId: { type: string, format: uuid } + createdAt: { type: string, format: date-time } + destination: { oneOf: [{ $ref: "#/components/schemas/IngestionProposalDestination" }, { type: "null" }] } + metadata: { $ref: "#/components/schemas/IngestionProposalMetadata" } + mutationKind: { type: [string, "null"], enum: [CreatePage, UpdatePage, null] } + IngestionProposalListResponse: + type: object + required: [items, nextCursor, page, pageSize, total, correlationId] + properties: + items: { type: array, items: { $ref: "#/components/schemas/IngestionProposalSummary" } } + nextCursor: { type: [string, "null"] } + page: { type: integer, minimum: 1 } + pageSize: { type: integer, minimum: 1, maximum: 100 } + total: { type: integer, format: int64, minimum: 0 } + correlationId: { type: string } + IngestionProposalDestination: + type: object + required: [pageId, parentPageId, title] + properties: + pageId: { type: [string, "null"], format: uuid } + parentPageId: { type: [string, "null"], format: uuid } + title: { type: [string, "null"] } + IngestionProposalMetadata: + type: object + description: Project and risk are currently unavailable persisted attributes and are therefore explicitly null. + required: [documentType, documentStatus, projectId, risk] + properties: + documentType: { type: [string, "null"] } + documentStatus: { type: [string, "null"] } + projectId: { type: [string, "null"], format: uuid } + risk: { type: [string, "null"], enum: [low, medium, high, null] } + IngestionProposalCitation: + type: object + required: [label, pageId, excerpt, confidence] + properties: + label: { type: string } + pageId: { type: [string, "null"], format: uuid } + excerpt: { type: [string, "null"] } + confidence: { type: [number, "null"] } + IngestionProposalWarning: + type: object + required: [code, message, severity] + properties: + code: { type: string } + message: { type: string } + severity: { type: [string, "null"], enum: [low, medium, high, null] } + IngestionProposalCandidate: + type: object + required: [pageId, title, kind] + properties: + pageId: { type: [string, "null"], format: uuid } + title: { type: [string, "null"] } + kind: { type: [string, "null"] } + IngestionProposalDiff: + type: object + required: [before, after, summary] + properties: + before: { type: [string, "null"] } + after: { type: [string, "null"] } + summary: { type: [string, "null"] } + IngestionProposalPermissions: + type: object + required: [canEdit, canDecide, canPublish] + properties: + canEdit: { type: boolean } + canDecide: { type: boolean } + canPublish: { type: boolean } + IngestionProposalDetail: + type: object + description: Safe dossier metadata only; it never contains source bytes, paths, URLs, credentials, or tokens. + required: [id, correlationId, status, revisionNumber, manifest, retryCount, maxPublishRetries, isRetryEligible, sources, revisions, etag, submitterId, createdAt, destination, metadata, mutationKind, attachments, expectedTargetETag, citations, warnings, duplicateCandidates, conflictCandidates, diff, permissions] + properties: + id: { type: string, format: uuid } + correlationId: { type: string } + status: { type: string } + revisionNumber: { type: integer, format: int64 } + manifest: { type: string } + analysis: { type: [string, "null"] } + reviewerComment: { type: [string, "null"] } + retryCount: { type: integer } + maxPublishRetries: { type: integer } + isRetryEligible: { type: boolean } + failedLifecycleStatus: { type: [string, "null"] } + failedProcessingStage: { type: [string, "null"] } + failureErrorType: { type: [string, "null"] } + failureMessage: { type: [string, "null"] } + sources: { type: array, items: { $ref: "#/components/schemas/IngestionProposalSourceDetail" } } + revisions: { type: array, items: { $ref: "#/components/schemas/IngestionProposalRevisionDetail" } } + etag: { type: string } + publishedPageId: { type: [string, "null"], format: uuid } + publishedPageRevisionNumber: { type: [integer, "null"], format: int64 } + submitterId: { type: string, format: uuid } + createdAt: { type: string, format: date-time } + destination: { oneOf: [{ $ref: "#/components/schemas/IngestionProposalDestination" }, { type: "null" }] } + metadata: { $ref: "#/components/schemas/IngestionProposalMetadata" } + mutationKind: { type: [string, "null"], enum: [CreatePage, UpdatePage, null] } + attachments: { type: array, items: { $ref: "#/components/schemas/IngestionProposalSourceDetail" } } + expectedTargetETag: { type: [string, "null"] } + citations: { type: array, items: { $ref: "#/components/schemas/IngestionProposalCitation" } } + warnings: { type: array, items: { $ref: "#/components/schemas/IngestionProposalWarning" } } + duplicateCandidates: { type: array, items: { $ref: "#/components/schemas/IngestionProposalCandidate" } } + conflictCandidates: { type: array, items: { $ref: "#/components/schemas/IngestionProposalCandidate" } } + diff: + description: after contains the proposed publication Markdown. before is null when no current target snapshot is available. + oneOf: [{ $ref: "#/components/schemas/IngestionProposalDiff" }, { type: "null" }] + permissions: { $ref: "#/components/schemas/IngestionProposalPermissions" } + SystemInfo: + type: object + required: [product, version, commit, schemaVersion, environment, primaryLocale, timeZone, uptimeSeconds, release] + properties: + product: + type: string + const: ItWorx Knowledge Hub + version: + type: string + commit: + type: string + schemaVersion: + type: string + environment: + type: string + primaryLocale: + type: string + timeZone: + type: string + uptimeSeconds: + type: integer + release: + $ref: "#/components/schemas/ReleaseIdentity" + ReleaseIdentity: + type: object + description: >- + The canonical, machine-readable release identity + (docs/adr/ADR-029-v2-r5-final-hardening.md). Every field is honestly nullable — + a value not actually known at build or deploy time is never fabricated. + required: [semanticVersion, releaseTag, sourceCommit, imageRepository, imageTag, imageId, imageDigest, buildTimestamp, deploymentTimestamp, openApiVersion, openApiHash, frontendBuildHash, databaseMigrationRange, inventorySnapshotId, backupId] + properties: + semanticVersion: + type: [string, "null"] + releaseTag: + type: [string, "null"] + sourceCommit: + type: [string, "null"] + imageRepository: + type: [string, "null"] + imageTag: + type: [string, "null"] + imageId: + type: [string, "null"] + imageDigest: + type: [string, "null"] + buildTimestamp: + type: [string, "null"] + format: date-time + deploymentTimestamp: + type: [string, "null"] + format: date-time + openApiVersion: + type: [string, "null"] + openApiHash: + type: [string, "null"] + frontendBuildHash: + type: [string, "null"] + databaseMigrationRange: + type: [string, "null"] + description: "The first and last applied EF Core migration for the database this instance is actually connected to, formatted as 'first..last' (or a single migration name if only one has been applied)." + inventorySnapshotId: + type: [string, "null"] + backupId: + type: [string, "null"] + format: int64 + minimum: 0 + DocumentType: + type: string + enum: + - WikiPage + - StudyNote + - CourseModule + - ProjectDocument + - ArchitectureDecision + - Runbook + - Troubleshooting + - Reference + - ChangeLog + - SessionNote + - Checklist + DocumentStatus: + type: string + enum: [Draft, Active, NeedsReview, Deprecated, Archived] + PageSummary: + type: object + required: [id, parentPageId, title, slug, documentType, documentStatus, hasChildren, updatedAt] + properties: + id: + type: string + format: uuid + parentPageId: + type: [string, "null"] + format: uuid + title: + type: string + slug: + type: string + documentType: + $ref: "#/components/schemas/DocumentType" + documentStatus: + $ref: "#/components/schemas/DocumentStatus" + hasChildren: + type: boolean + updatedAt: + type: string + format: date-time + PageChildrenResponse: + type: object + required: [items, nextCursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/PageSummary" + nextCursor: + type: [string, "null"] + BreadcrumbEntry: + type: object + required: [id, title] + properties: + id: + type: string + format: uuid + title: + type: string + PageDetail: + type: object + required: + - id + - workspaceId + - parentPageId + - title + - slug + - markdown + - renderedHtml + - documentType + - documentStatus + - currentRevisionNumber + - tags + - breadcrumb + - createdBy + - updatedBy + - createdAt + - updatedAt + - publishedAt + - trashedAt + - eTag + properties: + id: + type: string + format: uuid + workspaceId: + type: string + format: uuid + parentPageId: + type: [string, "null"] + format: uuid + title: + type: string + slug: + type: string + markdown: + type: string + renderedHtml: + type: string + description: Server-rendered and sanitized. Never render untrusted Markdown as HTML client-side. + documentType: + $ref: "#/components/schemas/DocumentType" + documentStatus: + $ref: "#/components/schemas/DocumentStatus" + currentRevisionNumber: + type: integer + format: int64 + tags: + type: array + items: + type: string + breadcrumb: + type: array + items: + $ref: "#/components/schemas/BreadcrumbEntry" + createdBy: + type: [string, "null"] + format: uuid + updatedBy: + type: [string, "null"] + format: uuid + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + publishedAt: + type: [string, "null"] + format: date-time + trashedAt: + type: [string, "null"] + format: date-time + description: Null unless the page is in the trash/retention model. + eTag: + type: string + description: Also present as the response ETag header; included in the body so clients need not parse headers. + CreatePageRequest: + type: object + required: [parentPageId, title, documentType, markdown, templateKey] + description: >- + Either supply documentType and markdown directly, or supply templateKey to seed + the page from a starter template (in which case documentType/markdown are + ignored and may be null). + properties: + parentPageId: + type: [string, "null"] + format: uuid + title: + type: string + minLength: 1 + maxLength: 300 + documentType: + oneOf: + - $ref: "#/components/schemas/DocumentType" + - type: "null" + markdown: + type: [string, "null"] + maxLength: 2097152 + templateKey: + type: [string, "null"] + tags: + type: array + items: + type: string + maxItems: 25 + BeginPageCreationSessionRequest: + type: object + properties: + parentPageId: + type: [string, "null"] + format: uuid + PageCreationSessionResponse: + type: object + required: [sessionId, expiresAt] + properties: + sessionId: + type: string + format: uuid + expiresAt: + type: string + format: date-time + CommitPageCreationSessionRequest: + type: object + required: [title] + description: >- + No parentPageId (fixed at session-begin time) or templateKey (a template and + pre-publish attachments are mutually exclusive, matching the existing + product behavior). + properties: + title: + type: string + minLength: 1 + maxLength: 300 + documentType: + oneOf: + - $ref: "#/components/schemas/DocumentType" + - type: "null" + markdown: + type: [string, "null"] + maxLength: 2097152 + tags: + type: array + items: + type: string + maxItems: 25 + DiscardPageCreationSessionResponse: + type: object + required: [attachmentsRemoved] + properties: + attachmentsRemoved: + type: integer + UpdatePageRequest: + type: object + required: [title, markdown, documentType, documentStatus, changeSummary] + properties: + title: + type: string + minLength: 1 + maxLength: 300 + markdown: + type: string + maxLength: 2097152 + documentType: + $ref: "#/components/schemas/DocumentType" + documentStatus: + $ref: "#/components/schemas/DocumentStatus" + changeSummary: + type: [string, "null"] + maxLength: 500 + tags: + type: array + items: + type: string + maxItems: 25 + description: Omitted or null leaves the page's tags unchanged. + MovePageRequest: + type: object + required: [newParentPageId, afterSiblingId] + properties: + newParentPageId: + type: [string, "null"] + format: uuid + afterSiblingId: + type: [string, "null"] + format: uuid + description: Null means "first among the new siblings". + TemplateSummary: + type: object + required: [key, name, documentType, description] + properties: + key: + type: string + name: + type: string + documentType: + $ref: "#/components/schemas/DocumentType" + description: + type: string + DraftResponse: + type: object + required: [draftKey, pageId, baseRevisionNumber, title, markdown, updatedAt, expiresAt, baseRevisionIsStale] + properties: + draftKey: + type: string + pageId: + type: [string, "null"] + format: uuid + baseRevisionNumber: + type: [integer, "null"] + format: int64 + title: + type: string + markdown: + type: string + updatedAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time + baseRevisionIsStale: + type: boolean + description: True when the page has published a newer revision since this draft's base. + SaveDraftRequest: + type: object + required: [pageId, baseRevisionNumber, title, markdown] + properties: + pageId: + type: [string, "null"] + format: uuid + baseRevisionNumber: + type: [integer, "null"] + format: int64 + title: + type: string + markdown: + type: string + PageRevisionSummary: + type: object + required: [revisionNumber, title, documentType, documentStatus, authoredBy, changeSummary, createdAt] + properties: + revisionNumber: + type: integer + format: int64 + title: + type: string + documentType: + $ref: "#/components/schemas/DocumentType" + documentStatus: + $ref: "#/components/schemas/DocumentStatus" + authoredBy: + type: [string, "null"] + format: uuid + changeSummary: + type: [string, "null"] + createdAt: + type: string + format: date-time + PageRevisionDetailResponse: + type: object + required: [revisionNumber, title, markdown, renderedHtml, documentType, documentStatus, authoredBy, changeSummary, createdAt] + properties: + revisionNumber: + type: integer + format: int64 + title: + type: string + markdown: + type: string + renderedHtml: + type: string + description: >- + Server-rendered and sanitized without re-resolving internal links against + today's link table (a historical snapshot's links are not live). + documentType: + $ref: "#/components/schemas/DocumentType" + documentStatus: + $ref: "#/components/schemas/DocumentStatus" + authoredBy: + type: [string, "null"] + format: uuid + changeSummary: + type: [string, "null"] + createdAt: + type: string + format: date-time + BacklinkEntry: + type: object + required: [id, title, slug, documentType] + properties: + id: + type: string + format: uuid + title: + type: string + slug: + type: string + documentType: + $ref: "#/components/schemas/DocumentType" + BacklinksResponse: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/BacklinkEntry" + TrashPurgeResponse: + type: object + required: [purgedCount] + properties: + purgedCount: + type: integer + AttachmentDeliveryPolicy: + type: string + enum: [InlinePreviewEligible, DownloadOnly, Unknown] + AttachmentSummary: + type: object + required: [id, filename, mediaType, deliveryPolicy, byteLength, description, altText, createdAt] + properties: + id: + type: string + format: uuid + filename: + type: string + description: The sanitized, stored display filename — never necessarily identical to what the client declared. + mediaType: + type: string + description: The detected media type from the byte signature, not the caller-declared Content-Type. + deliveryPolicy: + $ref: "#/components/schemas/AttachmentDeliveryPolicy" + byteLength: + type: integer + format: int64 + description: + type: [string, "null"] + altText: + type: [string, "null"] + createdAt: + type: string + format: date-time + AttachmentListResponse: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/AttachmentSummary" + ManualOverrideFieldCatalogEntry: + type: object + required: [fieldName, displayLabel, valueKind, maxStringLength, allowedEnumValues] + properties: + fieldName: + type: string + displayLabel: + type: string + valueKind: + type: string + enum: [Text, Enum] + maxStringLength: + type: integer + description: 0 for an Enum-kind field, where length is governed by allowedEnumValues instead. + allowedEnumValues: + type: [array, "null"] + items: + type: string + ManualOverrideFieldCatalogResponse: + type: object + required: [targetKind, fields] + properties: + targetKind: + type: string + fields: + type: array + items: + $ref: "#/components/schemas/ManualOverrideFieldCatalogEntry" + FactProvenance: + type: object + required: [sourceKey, confidence, lastSnapshotId, observedAt] + properties: + sourceKey: + type: string + confidence: + type: string + enum: [Observed, Inferred, Declared, Unknown] + lastSnapshotId: + type: string + format: uuid + observedAt: + type: string + format: date-time + InventorySnapshotSummary: + type: object + required: [id, sourceKey, sourceHash, capturedAt, importedAt, hostCount, serviceCount] + properties: + id: + type: string + format: uuid + sourceKey: + type: string + sourceHash: + type: string + capturedAt: + type: string + format: date-time + importedAt: + type: string + format: date-time + hostCount: + type: integer + serviceCount: + type: integer + InfrastructureHostSummary: + type: object + required: [id, sourceKey, hostname, operatingSystem, kernelVersion, architecture, logicalCpuCount, totalMemoryBytes, presence, lastSeenAt] + properties: + id: + type: string + format: uuid + sourceKey: + type: string + hostname: + type: string + operatingSystem: + type: string + kernelVersion: + type: string + architecture: + type: string + logicalCpuCount: + type: integer + totalMemoryBytes: + type: integer + format: int64 + presence: + type: string + enum: [Present, Missing] + lastSeenAt: + type: string + format: date-time + InfrastructureOverviewResponse: + type: object + required: [latestSnapshot, hosts, serviceCount, proposedServiceCount, missingServiceCount, unhealthyServiceCount, unprotectedServiceCount] + properties: + latestSnapshot: + type: [object, "null"] + allOf: + - $ref: "#/components/schemas/InventorySnapshotSummary" + hosts: + type: array + items: + $ref: "#/components/schemas/InfrastructureHostSummary" + serviceCount: + type: integer + proposedServiceCount: + type: integer + missingServiceCount: + type: integer + unhealthyServiceCount: + type: integer + unprotectedServiceCount: + type: integer + InfrastructureServiceSummary: + type: object + required: [id, sourceKey, name, detectedName, category, summary, runtimeKind, runtimeStatus, health, presence, confidence, imageReference, projectId, projectName, backupStatus, primaryEndpointUrl, isFavorite, isHidden, componentCount, endpointCount, volumeCount, databaseCount, lastSeenAt] + properties: + id: + type: string + format: uuid + sourceKey: + type: string + name: + type: string + description: Editorial display name if set, otherwise the detected name. + detectedName: + type: string + category: + type: string + summary: + type: string + runtimeKind: + type: string + runtimeStatus: + type: string + health: + type: string + enum: [Healthy, Degraded, Unhealthy, Unknown] + presence: + type: string + enum: [Present, Proposed, Missing] + confidence: + type: string + enum: [Observed, Inferred, Declared, Unknown] + imageReference: + type: string + projectId: + type: [string, "null"] + format: uuid + projectName: + type: [string, "null"] + backupStatus: + type: string + primaryEndpointUrl: + type: [string, "null"] + isFavorite: + type: boolean + isHidden: + type: boolean + componentCount: + type: integer + endpointCount: + type: integer + volumeCount: + type: integer + databaseCount: + type: integer + lastSeenAt: + type: string + format: date-time + InfrastructureServiceListResponse: + type: object + required: [items, nextCursor, totalCount] + properties: + items: + type: array + items: + $ref: "#/components/schemas/InfrastructureServiceSummary" + nextCursor: + type: [string, "null"] + totalCount: + type: integer + description: >- + How many services match the current `search`/`status`/`projectId`/ + `includeHidden` filters in total (OUX-4). Counted server-side before + cursor pagination, so it is the size of the filtered catalog rather than + of the returned page, and stays stable while paging through it. + InfrastructureStorageRow: + type: object + required: [id, serviceId, serviceName, name, mountPath, storageClass, readOnly, sizeBytes, provenance] + properties: + id: + type: string + format: uuid + serviceId: + type: string + format: uuid + serviceName: + type: string + name: + type: string + mountPath: + type: string + storageClass: + type: string + readOnly: + type: boolean + sizeBytes: + type: [integer, "null"] + format: int64 + provenance: + $ref: "#/components/schemas/FactProvenance" + InfrastructureStorageListResponse: + type: object + required: [items, nextCursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/InfrastructureStorageRow" + nextCursor: + type: [string, "null"] + EditorialProfileResponse: + type: object + required: [id, displayName, summary, category, purpose, ownerId, projectId, repositoryUrl, liveUrl, backupStatus, monitoringUrl, maintenanceNotes, knownRisks, updateProcedure, rollbackProcedure, tags, isFavorite, isHidden, isArchived, updatedAt, etag] + properties: + id: + type: string + format: uuid + displayName: + type: string + summary: + type: string + category: + type: string + purpose: + type: string + ownerId: + type: [string, "null"] + format: uuid + projectId: + type: [string, "null"] + format: uuid + repositoryUrl: + type: string + liveUrl: + type: string + backupStatus: + type: string + monitoringUrl: + type: string + maintenanceNotes: + type: string + knownRisks: + type: string + updateProcedure: + type: string + rollbackProcedure: + type: string + tags: + type: array + items: + type: string + isFavorite: + type: boolean + isHidden: + type: boolean + isArchived: + type: boolean + updatedAt: + type: string + format: date-time + etag: + type: string + ManualOverrideResponse: + type: object + required: [id, fieldName, value, reason, createdBy, updatedAt] + properties: + id: + type: string + format: uuid + fieldName: + type: string + value: + description: The overridden value — any JSON scalar, array or object, matching the field's ManualOverrideFieldCatalogEntry.valueKind. + reason: + type: string + createdBy: + type: string + format: uuid + updatedAt: + type: string + format: date-time + ServiceComponentResponse: + type: object + required: [id, name, kind, runtimeId, status, imageReference, provenance] + properties: + id: + type: string + format: uuid + name: + type: string + kind: + type: string + runtimeId: + type: string + status: + type: string + imageReference: + type: string + provenance: + $ref: "#/components/schemas/FactProvenance" + InfrastructureEndpointResponse: + type: object + required: [id, scheme, host, publishedPort, targetPort, url, isPublic, provenance] + properties: + id: + type: string + format: uuid + scheme: + type: string + host: + type: string + publishedPort: + type: [integer, "null"] + targetPort: + type: [integer, "null"] + url: + type: string + isPublic: + type: boolean + provenance: + $ref: "#/components/schemas/FactProvenance" + InfrastructureNetworkResponse: + type: object + required: [id, name, driver, scope, internal, provenance] + properties: + id: + type: string + format: uuid + name: + type: string + driver: + type: string + scope: + type: string + internal: + type: boolean + provenance: + $ref: "#/components/schemas/FactProvenance" + InfrastructureVolumeResponse: + type: object + required: [id, name, mountPath, storageClass, readOnly, sizeBytes, provenance] + properties: + id: + type: string + format: uuid + name: + type: string + mountPath: + type: string + storageClass: + type: string + readOnly: + type: boolean + sizeBytes: + type: [integer, "null"] + format: int64 + provenance: + $ref: "#/components/schemas/FactProvenance" + InfrastructureDatabaseResponse: + type: object + required: [id, name, engine, version, host, port, hostPortPublished, provenance] + properties: + id: + type: string + format: uuid + name: + type: string + engine: + type: string + version: + type: string + host: + type: string + port: + type: [integer, "null"] + hostPortPublished: + type: boolean + provenance: + $ref: "#/components/schemas/FactProvenance" + ServiceDependencyResponse: + type: object + required: [id, dependsOnServiceId, dependsOnSourceKey, kind, provenance] + properties: + id: + type: string + format: uuid + dependsOnServiceId: + type: [string, "null"] + format: uuid + dependsOnSourceKey: + type: string + kind: + type: string + provenance: + $ref: "#/components/schemas/FactProvenance" + HealthObservationResponse: + type: object + required: [id, state, checkKind, responseTimeMilliseconds, provenance] + properties: + id: + type: string + format: uuid + state: + type: string + enum: [Healthy, Degraded, Unhealthy, Unknown] + checkKind: + type: string + responseTimeMilliseconds: + type: [integer, "null"] + provenance: + $ref: "#/components/schemas/FactProvenance" + DeploymentReferenceResponse: + type: object + required: [id, repositoryUrl, commit, imageReference, imageDigest, deployedAt, provenance] + properties: + id: + type: string + format: uuid + repositoryUrl: + type: string + commit: + type: string + imageReference: + type: string + imageDigest: + type: string + deployedAt: + type: [string, "null"] + format: date-time + provenance: + $ref: "#/components/schemas/FactProvenance" + BackupCoverageResponse: + type: object + required: [id, method, schedule, lastSuccessfulAt, restoreTested, provenance] + properties: + id: + type: string + format: uuid + method: + type: string + schedule: + type: string + lastSuccessfulAt: + type: [string, "null"] + format: date-time + restoreTested: + type: boolean + provenance: + $ref: "#/components/schemas/FactProvenance" + InfrastructureServiceDetail: + type: object + required: [service, editorialProfile, editorialETag, overrides, components, endpoints, networks, volumes, databases, dependencies, healthObservations, deployments, backups] + properties: + service: + $ref: "#/components/schemas/InfrastructureServiceSummary" + editorialProfile: + type: [object, "null"] + allOf: + - $ref: "#/components/schemas/EditorialProfileResponse" + editorialETag: + type: string + overrides: + type: array + items: + $ref: "#/components/schemas/ManualOverrideResponse" + components: + type: array + items: + $ref: "#/components/schemas/ServiceComponentResponse" + endpoints: + type: array + items: + $ref: "#/components/schemas/InfrastructureEndpointResponse" + networks: + type: array + items: + $ref: "#/components/schemas/InfrastructureNetworkResponse" + volumes: + type: array + items: + $ref: "#/components/schemas/InfrastructureVolumeResponse" + databases: + type: array + items: + $ref: "#/components/schemas/InfrastructureDatabaseResponse" + dependencies: + type: array + items: + $ref: "#/components/schemas/ServiceDependencyResponse" + healthObservations: + type: array + items: + $ref: "#/components/schemas/HealthObservationResponse" + deployments: + type: array + items: + $ref: "#/components/schemas/DeploymentReferenceResponse" + backups: + type: array + items: + $ref: "#/components/schemas/BackupCoverageResponse" + UpdateEditorialProfileRequest: + type: object + required: [isFavorite, isHidden, isArchived] + description: Every field besides the three booleans is optional — omit a field to leave it unchanged. + properties: + displayName: + type: [string, "null"] + summary: + type: [string, "null"] + category: + type: [string, "null"] + purpose: + type: [string, "null"] + ownerId: + type: [string, "null"] + format: uuid + projectId: + type: [string, "null"] + format: uuid + repositoryUrl: + type: [string, "null"] + liveUrl: + type: [string, "null"] + backupStatus: + type: [string, "null"] + monitoringUrl: + type: [string, "null"] + maintenanceNotes: + type: [string, "null"] + knownRisks: + type: [string, "null"] + updateProcedure: + type: [string, "null"] + rollbackProcedure: + type: [string, "null"] + tags: + type: [array, "null"] + items: + type: string + isFavorite: + type: boolean + isHidden: + type: boolean + isArchived: + type: boolean + SetManualOverrideRequest: + type: object + required: [fieldName, value] + properties: + fieldName: + type: string + description: Must be one of the field names GET /infrastructure/overrides/catalog returns for the target's object kind. + value: + description: Any JSON scalar, array or object, validated server-side against the field's catalog entry (valueKind, maxStringLength, allowedEnumValues). + reason: + type: [string, "null"] + SetInfrastructureOverrideRequest: + type: object + required: [targetKind, targetId, fieldName, value] + properties: + targetKind: + type: string + enum: [Host, Service, Component, Endpoint, Network, Volume, Database, Deployment, Backup] + targetId: + type: string + format: uuid + fieldName: + type: string + description: Must be one of the field names GET /infrastructure/overrides/catalog?targetKind=... returns for this targetKind. + value: + description: Any JSON scalar, array or object, validated server-side against the field's catalog entry (valueKind, maxStringLength, allowedEnumValues). + reason: + type: [string, "null"] + ReviewInfrastructureServiceRequest: + type: object + required: [decision] + properties: + decision: + type: string + enum: [Accept, Reject] + reason: + type: [string, "null"] + ReviewInfrastructureServiceResponse: + type: object + required: [serviceId, presence] + properties: + serviceId: + type: string + format: uuid + presence: + type: string + enum: [Present, Missing] + InventoryChangeResponse: + type: object + required: [id, snapshotId, targetKind, targetId, sourceKey, changeKind, changedFields, recordedAt] + properties: + id: + type: string + format: uuid + snapshotId: + type: string + format: uuid + targetKind: + type: string + targetId: + type: string + format: uuid + sourceKey: + type: string + changeKind: + type: string + enum: [Added, Updated, Missing, Unchanged] + changedFields: + type: array + items: + type: string + recordedAt: + type: string + format: date-time + InventoryHostRequest: + type: object + required: [sourceKey, hostname, logicalCpuCount, totalMemoryBytes] + properties: + sourceKey: + type: string + hostname: + type: string + operatingSystem: + type: [string, "null"] + kernelVersion: + type: [string, "null"] + architecture: + type: [string, "null"] + logicalCpuCount: + type: integer + totalMemoryBytes: + type: integer + format: int64 + InventoryFactConfidence: + type: string + enum: [Observed, Inferred, Declared, Unknown] + InventoryComponentRequest: + type: object + required: [sourceKey, name, confidence] + properties: + sourceKey: + type: string + name: + type: string + kind: + type: [string, "null"] + runtimeId: + type: [string, "null"] + status: + type: [string, "null"] + imageReference: + type: [string, "null"] + confidence: + $ref: "#/components/schemas/InventoryFactConfidence" + InventoryEndpointRequest: + type: object + required: [sourceKey, isPublic, confidence] + properties: + sourceKey: + type: string + scheme: + type: [string, "null"] + host: + type: [string, "null"] + publishedPort: + type: [integer, "null"] + targetPort: + type: [integer, "null"] + url: + type: [string, "null"] + isPublic: + type: boolean + confidence: + $ref: "#/components/schemas/InventoryFactConfidence" + InventoryNetworkRequest: + type: object + required: [sourceKey, name, internal, confidence] + properties: + sourceKey: + type: string + name: + type: string + driver: + type: [string, "null"] + scope: + type: [string, "null"] + internal: + type: boolean + confidence: + $ref: "#/components/schemas/InventoryFactConfidence" + InventoryVolumeRequest: + type: object + required: [sourceKey, name, readOnly, confidence] + properties: + sourceKey: + type: string + name: + type: string + mountPath: + type: [string, "null"] + storageClass: + type: [string, "null"] + readOnly: + type: boolean + sizeBytes: + type: [integer, "null"] + format: int64 + confidence: + $ref: "#/components/schemas/InventoryFactConfidence" + InventoryDatabaseRequest: + type: object + required: [sourceKey, name, hostPortPublished, confidence] + properties: + sourceKey: + type: string + name: + type: string + engine: + type: [string, "null"] + version: + type: [string, "null"] + host: + type: [string, "null"] + port: + type: [integer, "null"] + hostPortPublished: + type: boolean + confidence: + $ref: "#/components/schemas/InventoryFactConfidence" + InventoryDependencyRequest: + type: object + required: [sourceKey, dependsOnSourceKey, confidence] + properties: + sourceKey: + type: string + dependsOnSourceKey: + type: string + kind: + type: [string, "null"] + confidence: + $ref: "#/components/schemas/InventoryFactConfidence" + InventoryHealthRequest: + type: object + required: [sourceKey, state, confidence] + properties: + sourceKey: + type: string + state: + type: string + enum: [Healthy, Degraded, Unhealthy, Unknown] + checkKind: + type: [string, "null"] + responseTimeMilliseconds: + type: [integer, "null"] + confidence: + $ref: "#/components/schemas/InventoryFactConfidence" + InventoryDeploymentRequest: + type: object + required: [sourceKey, confidence] + properties: + sourceKey: + type: string + repositoryUrl: + type: [string, "null"] + commit: + type: [string, "null"] + imageReference: + type: [string, "null"] + imageDigest: + type: [string, "null"] + deployedAt: + type: [string, "null"] + format: date-time + confidence: + $ref: "#/components/schemas/InventoryFactConfidence" + InventoryBackupRequest: + type: object + required: [sourceKey, restoreTested, confidence] + properties: + sourceKey: + type: string + method: + type: [string, "null"] + schedule: + type: [string, "null"] + lastSuccessfulAt: + type: [string, "null"] + format: date-time + restoreTested: + type: boolean + confidence: + $ref: "#/components/schemas/InventoryFactConfidence" + InventoryServiceRequest: + type: object + required: [sourceKey, detectedName, health, readOnlyRootFilesystem, noNewPrivileges, privileged, confidence] + properties: + sourceKey: + type: string + hostSourceKey: + type: [string, "null"] + detectedName: + type: string + runtimeKind: + type: [string, "null"] + runtimeStatus: + type: [string, "null"] + health: + type: string + enum: [Healthy, Degraded, Unhealthy, Unknown] + imageReference: + type: [string, "null"] + imageDigest: + type: [string, "null"] + runtimeUser: + type: [string, "null"] + readOnlyRootFilesystem: + type: boolean + noNewPrivileges: + type: boolean + privileged: + type: boolean + confidence: + $ref: "#/components/schemas/InventoryFactConfidence" + components: + type: [array, "null"] + items: + $ref: "#/components/schemas/InventoryComponentRequest" + endpoints: + type: [array, "null"] + items: + $ref: "#/components/schemas/InventoryEndpointRequest" + networks: + type: [array, "null"] + items: + $ref: "#/components/schemas/InventoryNetworkRequest" + volumes: + type: [array, "null"] + items: + $ref: "#/components/schemas/InventoryVolumeRequest" + databases: + type: [array, "null"] + items: + $ref: "#/components/schemas/InventoryDatabaseRequest" + dependencies: + type: [array, "null"] + items: + $ref: "#/components/schemas/InventoryDependencyRequest" + healthObservations: + type: [array, "null"] + items: + $ref: "#/components/schemas/InventoryHealthRequest" + deployments: + type: [array, "null"] + items: + $ref: "#/components/schemas/InventoryDeploymentRequest" + backups: + type: [array, "null"] + items: + $ref: "#/components/schemas/InventoryBackupRequest" + SynchronizeInventoryRequest: + type: object + required: [sourceKey, sourceHash, capturedAt] + properties: + sourceKey: + type: string + sourceHash: + type: string + capturedAt: + type: string + format: date-time + hosts: + type: [array, "null"] + items: + $ref: "#/components/schemas/InventoryHostRequest" + services: + type: [array, "null"] + items: + $ref: "#/components/schemas/InventoryServiceRequest" + InventorySyncResponse: + type: object + required: [snapshotId, alreadyImported, addedServices, updatedServices, missingServices, preservedEditorialProfiles, preservedOverrides] + properties: + snapshotId: + type: string + format: uuid + alreadyImported: + type: boolean + addedServices: + type: integer + updatedServices: + type: integer + missingServices: + type: integer + preservedEditorialProfiles: + type: integer + preservedOverrides: + type: integer + CuratedServiceProfileRequest: + type: object + required: [sourceKey, reviewed, archived] + properties: + sourceKey: + type: string + displayName: + type: [string, "null"] + summary: + type: [string, "null"] + category: + type: [string, "null"] + purpose: + type: [string, "null"] + repositoryUrl: + type: [string, "null"] + liveUrl: + type: [string, "null"] + tags: + type: [array, "null"] + items: + type: string + reviewed: + type: boolean + archived: + type: boolean + ImportCuratedInventoryRequest: + type: object + required: [inventory] + properties: + inventory: + $ref: "#/components/schemas/SynchronizeInventoryRequest" + services: + type: [array, "null"] + items: + $ref: "#/components/schemas/CuratedServiceProfileRequest" + ImportCuratedInventoryResponse: + type: object + required: [inventory, createdEditorialProfiles, confirmedServices, archivedServices] + properties: + inventory: + $ref: "#/components/schemas/InventorySyncResponse" + createdEditorialProfiles: + type: integer + confirmedServices: + type: integer + archivedServices: + type: integer + ActivityItemResponse: + type: object + required: [id, actorKind, actorId, source, action, targetType, targetId, summary, occurredAt] + properties: + id: + type: integer + format: int64 + actorKind: + type: string + enum: [System, User, ServiceAccount] + actorId: + type: [string, "null"] + format: uuid + source: + type: string + action: + type: string + targetType: + type: [string, "null"] + targetId: + type: [string, "null"] + summary: + type: string + occurredAt: + type: string + format: date-time + ActivityListResponse: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/ActivityItemResponse" + WorkspaceMemberSummary: + type: object + required: [id, displayName, email] + properties: + id: + type: string + format: uuid + displayName: + type: string + email: + type: string + WorkspaceMemberListResponse: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/WorkspaceMemberSummary" + PreviewMarkdownRequest: + type: object + required: [markdown] + properties: + markdown: + type: string + PreviewMarkdownResponse: + type: object + required: [renderedHtml] + properties: + renderedHtml: + type: string + description: Sanitized HTML — safe to render directly, matching the same pipeline a published page revision uses. + StagingCleanupRunSummary: + type: object + required: [sessionsExpired, sessionsClaimed, sessionsLostRace, attachmentReferencesRemoved, blobsRemoved, warnings] + properties: + sessionsExpired: + type: integer + sessionsClaimed: + type: integer + sessionsLostRace: + type: integer + description: Expired sessions this run observed but a concurrent run claimed first — never lost, just processed elsewhere. + attachmentReferencesRemoved: + type: integer + blobsRemoved: + type: integer + warnings: + type: array + items: + type: string + StagingCleanupStatusResponse: + type: object + required: [activeSessionCount, expiredPendingCleanupCount, runInProgress, lastRunStartedAt, lastRunCompletedAt, lastRunTrigger, lastResult] + properties: + activeSessionCount: + type: integer + expiredPendingCleanupCount: + type: integer + runInProgress: + type: boolean + lastRunStartedAt: + type: [string, "null"] + format: date-time + lastRunCompletedAt: + type: [string, "null"] + format: date-time + lastRunTrigger: + type: [string, "null"] + enum: [scheduled, manual, null] + lastResult: + type: [object, "null"] + allOf: + - $ref: "#/components/schemas/StagingCleanupRunSummary" + RunStagingCleanupRequest: + type: object + required: [dryRun] + properties: + dryRun: + type: boolean + RunStagingCleanupResponse: + type: object + required: [dryRun, result] + properties: + dryRun: + type: boolean + result: + $ref: "#/components/schemas/StagingCleanupRunSummary" + PreflightImportRequest: + type: object + required: [sourceRelativePath, idempotencyKey] + properties: + sourceRelativePath: + type: string + description: Resolved beneath the operator-mounted KnowledgeHub:Paths:Imports directory — never an arbitrary filesystem path. + idempotencyKey: + type: string + ImportPlanItemResponse: + type: object + required: [sourceIdentity, kind, proposedTitle, proposedParentSourceIdentity, conflict, conflictDetail] + properties: + sourceIdentity: + type: string + kind: + type: string + enum: [Markdown, SqliteRow, Asset, Unsupported] + proposedTitle: + type: string + proposedParentSourceIdentity: + type: [string, "null"] + conflict: + type: string + enum: [NoConflict, ExactRerunDuplicate, SamePathDifferentContent, ExistingUserPage, AmbiguousLink, Unsupported] + conflictDetail: + type: [string, "null"] + ImportPreflightResponse: + type: object + required: [status, jobId, adapterName, discoveredCount, plannedCount, items, warnings, blockers] + properties: + status: + type: string + enum: [Success, NoApplicableAdapter, AlreadyPreflighted] + jobId: + type: [string, "null"] + format: uuid + adapterName: + type: [string, "null"] + discoveredCount: + type: integer + plannedCount: + type: integer + items: + type: array + items: + $ref: "#/components/schemas/ImportPlanItemResponse" + warnings: + type: array + items: + type: string + blockers: + type: array + items: + type: string + ExecuteImportResponse: + type: object + required: [status, imported, skipped, failed, sourceUnchangedAfterExecution, diagnostics] + properties: + status: + type: string + enum: [Success, JobNotFound, NotPlanReady, SourceChanged, PlanChanged] + imported: + type: integer + skipped: + type: integer + failed: + type: integer + sourceUnchangedAfterExecution: + type: boolean + description: A re-fingerprint comparison proving the source was never mutated during import (M4-A08). + diagnostics: + type: array + items: + type: string + RollbackImportResponse: + type: object + required: [status, removedPages, preservedPages, removedAttachments] + properties: + status: + type: string + enum: [Success, JobNotFound, NothingToRollBack] + removedPages: + type: integer + preservedPages: + type: integer + description: Pages this import created but a user has since edited — never deleted by rollback. + removedAttachments: + type: integer + ImportJobSummaryResponse: + type: object + required: [id, adapterName, status, discoveredCount, plannedCount, importedCount, skippedCount, failedCount, createdAt, startedAt, completedAt] + properties: + id: + type: string + format: uuid + adapterName: + type: string + status: + type: string + enum: [Discovered, PlanReady, Executing, Completed, Failed, RolledBack] + discoveredCount: + type: integer + plannedCount: + type: integer + importedCount: + type: integer + skippedCount: + type: integer + failedCount: + type: integer + createdAt: + type: string + format: date-time + startedAt: + type: [string, "null"] + format: date-time + completedAt: + type: [string, "null"] + format: date-time + SourceBatchPurgeRequest: + type: object + required: [batchId, source, pages] + properties: + batchId: + type: string + minLength: 1 + source: + type: string + pattern: "^[a-z0-9-]{1,64}$" + pages: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/SourceBatchPurgePageEvidence" + SourceBatchPurgePageEvidence: + type: object + required: [pageId, contentHash] + properties: + pageId: + type: string + format: uuid + contentHash: + type: string + pattern: "^[a-fA-F0-9]{64}$" + ExecuteSourceBatchPurgeRequest: + allOf: + - $ref: "#/components/schemas/SourceBatchPurgeRequest" + - type: object + required: [confirmationHash] + properties: + confirmationHash: + type: string + pattern: "^[a-f0-9]{64}$" + SourceBatchPurgeDryRunResponse: + type: object + required: [safe, confirmationHash, pages, descendantPages, revisions, drafts, links, attachmentUsages, attachmentReferences, orphanedBlobs, sharedBlobs, importJobs, importItems, provenanceRecords, sourceTagAssignments, searchIndexRecords, generatedSourceDerivedPages, retainedPages, blockers] + properties: + safe: + type: boolean + confirmationHash: + type: string + pages: + type: integer + descendantPages: + type: integer + revisions: + type: integer + drafts: + type: integer + links: + type: integer + attachmentUsages: + type: integer + attachmentReferences: + type: integer + orphanedBlobs: + type: integer + sharedBlobs: + type: integer + importJobs: + type: integer + importItems: + type: integer + provenanceRecords: + type: integer + sourceTagAssignments: + type: integer + searchIndexRecords: + type: integer + generatedSourceDerivedPages: + type: integer + retainedPages: + type: integer + blockers: + type: array + items: + type: string + SourceBatchPurgeResponse: + type: object + required: [status, dryRun, removedPages, removedRevisions, removedDrafts, removedLinks, removedAttachmentUsages, removedAttachmentReferences, removedImportJobs, removedImportItems, removedProvenanceRecords, removedBlobs] + properties: + status: + type: string + enum: [purged] + dryRun: + $ref: "#/components/schemas/SourceBatchPurgeDryRunResponse" + removedPages: + type: integer + removedRevisions: + type: integer + removedDrafts: + type: integer + removedLinks: + type: integer + removedAttachmentUsages: + type: integer + removedAttachmentReferences: + type: integer + removedImportJobs: + type: integer + removedImportItems: + type: integer + removedProvenanceRecords: + type: integer + removedBlobs: + type: integer + SearchResultItem: + type: object + required: [id, parentPageId, title, slug, documentType, documentStatus, tags, updatedAt, snippet] + properties: + id: + type: string + format: uuid + parentPageId: + type: [string, "null"] + format: uuid + title: + type: string + slug: + type: string + documentType: + $ref: "#/components/schemas/DocumentType" + documentStatus: + $ref: "#/components/schemas/DocumentStatus" + tags: + type: array + items: + type: string + updatedAt: + type: string + format: date-time + snippet: + type: string + description: >- + May contain the safe server-generated highlight markers `**` around + matched terms. Never raw HTML — clients must escape this text before + rendering and only then interpret the `**` markers. + SearchResponse: + type: object + required: [items, nextCursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/SearchResultItem" + nextCursor: + type: [string, "null"] + RagPublicationManifestItem: + type: object + description: >- + docs/32-rag-publication-convention.md. `markdown` and `checksum` are always + `null` when `retired` is `true`. + required: + - contentId + - documentType + - documentStatus + - title + - slug + - canonicalUrl + - markdown + - revision + - checksum + - locale + - space + - updatedAt + - publishedAt + - retired + - retiredReason + properties: + contentId: + type: string + format: uuid + documentType: + $ref: "#/components/schemas/DocumentType" + documentStatus: + $ref: "#/components/schemas/DocumentStatus" + title: + type: string + slug: + type: string + canonicalUrl: + type: string + markdown: + type: [string, "null"] + revision: + type: integer + format: int64 + checksum: + type: [string, "null"] + description: Lowercase-hex SHA-256 of `markdown` (Page.ContentHash). Null when retired. + locale: + type: string + description: >- + The page's own `locale:` tag, or the configured + `KnowledgeHub:PrimaryLocale` when it declares none. Never silently `en`. + space: + type: [string, "null"] + description: The page's own `rag:space:` tag suffix, if any. + updatedAt: + type: string + format: date-time + publishedAt: + type: [string, "null"] + format: date-time + retired: + type: boolean + retiredReason: + type: [string, "null"] + enum: [null, trashed, draft, needs-review, deprecated, archived] + RagPublicationManifestResponse: + type: object + required: [items, nextCursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/RagPublicationManifestItem" + nextCursor: + type: [string, "null"] + ProjectStatus: + type: string + description: docs/15-project-hub.md lifecycle diagram. Archived is terminal in v1 (ADR-014 decision 6). + enum: [Concept, Planned, Active, Blocked, Maintenance, Paused, Completed, Archived] + ProjectPriority: + type: string + enum: [Low, Medium, High, Critical] + ProjectDocumentRole: + type: string + enum: + - Overview + - CurrentState + - Architecture + - Deployment + - Infrastructure + - Security + - Testing + - Troubleshooting + - Release + - UserDocumentation + - Other + ProjectUpdateCategory: + type: string + enum: [Progress, Feature, Fix, Deployment, Validation, Release, Decision, Risk, Blocker, Documentation, Maintenance] + ProjectUpdateVerificationStatus: + type: string + description: The UI must distinguish reported claims from validated evidence (docs/15-project-hub.md). + enum: [Reported, Verified, Superseded] + ProjectMilestoneState: + type: string + enum: [Planned, InProgress, Completed, Cancelled] + DecisionStatus: + type: string + enum: [Proposed, Accepted, Rejected, Superseded, Withdrawn] + RunbookRiskLevel: + type: string + enum: [Low, Medium, High, Critical] + RunbookStatus: + type: string + enum: [Draft, Active, Deprecated] + ProjectSummary: + type: object + required: [id, name, slug, summary, status, priority, ownerId, repositoryUrl, liveUrl, updatedAt, iconAttachmentId] + properties: + id: { type: string, format: uuid } + name: { type: string } + slug: { type: string } + summary: { type: string } + status: { $ref: "#/components/schemas/ProjectStatus" } + priority: { $ref: "#/components/schemas/ProjectPriority" } + ownerId: { type: string, format: uuid } + repositoryUrl: { type: [string, "null"], format: uri } + liveUrl: { type: [string, "null"], format: uri } + updatedAt: { type: string, format: date-time } + iconAttachmentId: { type: [string, "null"], format: uuid, description: "Same-origin attachment content is served at /api/v1/attachments/{iconAttachmentId}/content." } + ProjectListResponse: + type: object + required: [items, nextCursor, totalCount] + properties: + items: + type: array + items: + $ref: "#/components/schemas/ProjectSummary" + nextCursor: + type: [string, "null"] + totalCount: + type: integer + minimum: 0 + description: >- + How many projects match the current filters in total (OUX-4), counted + server-side before cursor pagination. Not a substitute for + `/projects/totals`, which answers the unfiltered dashboard question. + ProjectCatalogTotalsResponse: + type: object + required: [totalCount, activeCount] + properties: + totalCount: { type: integer, minimum: 0 } + activeCount: { type: integer, minimum: 0 } + ProjectDocumentSummary: + type: object + required: [id, pageId, pageTitle, pageSlug, pageDocumentType, role] + properties: + id: { type: string, format: uuid } + pageId: { type: string, format: uuid } + pageTitle: { type: string } + pageSlug: { type: string } + pageDocumentType: { $ref: "#/components/schemas/DocumentType" } + role: { $ref: "#/components/schemas/ProjectDocumentRole" } + ProjectUpdateSummary: + type: object + required: [id, category, title, summaryMarkdown, verificationStatus, actorId, occurredAt] + properties: + id: { type: string, format: uuid } + category: { $ref: "#/components/schemas/ProjectUpdateCategory" } + title: { type: string } + summaryMarkdown: { type: string } + verificationStatus: { $ref: "#/components/schemas/ProjectUpdateVerificationStatus" } + actorId: { type: string, format: uuid } + occurredAt: { type: string, format: date-time } + commitSha: { type: [string, "null"] } + releaseTag: { type: [string, "null"] } + issueIdentifier: { type: [string, "null"] } + url: { type: [string, "null"] } + pageId: { type: [string, "null"], format: uuid } + decisionRecordId: { type: [string, "null"], format: uuid } + milestoneId: { type: [string, "null"], format: uuid } + attachmentReferenceId: { type: [string, "null"], format: uuid } + supersedesUpdateId: { type: [string, "null"], format: uuid } + ProjectUpdatesResponse: + type: object + required: [items, nextCursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/ProjectUpdateSummary" + nextCursor: + type: [string, "null"] + ProjectMilestoneSummary: + type: object + required: [id, name, state, summary, successCriteria, eTag] + properties: + id: { type: string, format: uuid } + name: { type: string } + state: { $ref: "#/components/schemas/ProjectMilestoneState" } + targetDate: { type: [string, "null"], format: date } + completionDate: { type: [string, "null"], format: date } + summary: { type: string } + successCriteria: { type: string } + eTag: + type: string + description: Also present as the response ETag header. + ProjectDecisionSummary: + type: object + required: [id, decisionNumber, status, summary] + properties: + id: { type: string, format: uuid } + decisionNumber: { type: integer } + status: { $ref: "#/components/schemas/DecisionStatus" } + summary: { type: string } + ProjectDetail: + type: object + required: + - id + - name + - slug + - summary + - status + - priority + - currentStateMarkdown + - ownerId + - technologies + - documents + - recentUpdates + - milestones + - decisions + - createdAt + - updatedAt + - eTag + - iconAttachmentId + properties: + id: { type: string, format: uuid } + name: { type: string } + slug: { type: string } + summary: { type: string } + status: { $ref: "#/components/schemas/ProjectStatus" } + priority: { $ref: "#/components/schemas/ProjectPriority" } + currentStateMarkdown: { type: string } + ownerId: { type: string, format: uuid } + startDate: { type: [string, "null"], format: date } + targetDate: { type: [string, "null"], format: date } + completionDate: { type: [string, "null"], format: date } + repositoryUrl: { type: [string, "null"] } + liveUrl: { type: [string, "null"] } + documentationUrl: { type: [string, "null"] } + issueTrackerUrl: { type: [string, "null"] } + technologies: + type: array + items: { type: string } + documents: + type: array + items: + $ref: "#/components/schemas/ProjectDocumentSummary" + recentUpdates: + type: array + items: + $ref: "#/components/schemas/ProjectUpdateSummary" + milestones: + type: array + items: + $ref: "#/components/schemas/ProjectMilestoneSummary" + decisions: + type: array + items: + $ref: "#/components/schemas/ProjectDecisionSummary" + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + eTag: + type: string + description: Also present as the response ETag header; included in the body so clients need not parse headers. + iconAttachmentId: { type: [string, "null"], format: uuid } + ProjectIconResponse: + type: object + required: [iconAttachmentId, eTag] + properties: + iconAttachmentId: { type: string, format: uuid } + eTag: { type: string, description: Current project ETag after the icon change. } + CreateProjectRequest: + type: object + required: [name, summary, priority, currentStateMarkdown, ownerId] + properties: + name: { type: string, minLength: 1, maxLength: 200 } + summary: { type: string } + priority: { $ref: "#/components/schemas/ProjectPriority" } + currentStateMarkdown: { type: string } + ownerId: { type: string, format: uuid } + UpdateProjectRequest: + type: object + required: [name, summary, priority, currentStateMarkdown, ownerId] + properties: + name: { type: string, minLength: 1, maxLength: 200 } + summary: { type: string } + priority: { $ref: "#/components/schemas/ProjectPriority" } + currentStateMarkdown: { type: string } + ownerId: { type: string, format: uuid } + startDate: { type: [string, "null"], format: date } + targetDate: { type: [string, "null"], format: date } + completionDate: { type: [string, "null"], format: date } + repositoryUrl: { type: [string, "null"] } + liveUrl: { type: [string, "null"] } + documentationUrl: { type: [string, "null"] } + issueTrackerUrl: { type: [string, "null"] } + technologies: + type: [array, "null"] + items: { type: string } + LinkProjectDocumentRequest: + type: object + required: [pageId, role] + properties: + pageId: { type: string, format: uuid } + role: { $ref: "#/components/schemas/ProjectDocumentRole" } + CreateProjectUpdateRequest: + type: object + required: [category, title, summaryMarkdown, verificationStatus] + properties: + category: { $ref: "#/components/schemas/ProjectUpdateCategory" } + title: { type: string, maxLength: 200 } + summaryMarkdown: { type: string } + verificationStatus: { $ref: "#/components/schemas/ProjectUpdateVerificationStatus" } + occurredAt: { type: [string, "null"], format: date-time } + commitSha: { type: [string, "null"] } + releaseTag: { type: [string, "null"] } + issueIdentifier: { type: [string, "null"] } + url: { type: [string, "null"] } + pageId: { type: [string, "null"], format: uuid } + decisionRecordId: { type: [string, "null"], format: uuid } + milestoneId: { type: [string, "null"], format: uuid } + attachmentReferenceId: { type: [string, "null"], format: uuid } + supersedesUpdateId: { type: [string, "null"], format: uuid } + CreateProjectMilestoneRequest: + type: object + required: [name, summary, successCriteria] + properties: + name: { type: string, minLength: 1 } + targetDate: { type: [string, "null"], format: date } + summary: { type: string } + successCriteria: { type: string } + UpdateProjectMilestoneRequest: + type: object + required: [name, summary, successCriteria, state] + properties: + name: { type: string, minLength: 1 } + targetDate: { type: [string, "null"], format: date } + summary: { type: string } + successCriteria: { type: string } + state: { $ref: "#/components/schemas/ProjectMilestoneState" } + DecisionLineageEntry: + type: object + required: [id, decisionNumber, status, summary] + properties: + id: { type: string, format: uuid } + decisionNumber: { type: integer } + status: { $ref: "#/components/schemas/DecisionStatus" } + summary: { type: string } + DecisionDetail: + type: object + required: + - id + - workspaceId + - pageId + - decisionNumber + - status + - decisionDate + - title + - markdown + - renderedHtml + - summary + - createdAt + - updatedAt + - eTag + properties: + id: { type: string, format: uuid } + workspaceId: { type: string, format: uuid } + projectId: { type: [string, "null"], format: uuid } + pageId: { type: string, format: uuid } + decisionNumber: { type: integer } + status: { $ref: "#/components/schemas/DecisionStatus" } + decisionDate: { type: string, format: date } + title: { type: string } + markdown: { type: string } + renderedHtml: + type: string + description: Server-sanitized HTML — the same rendering pipeline as an ordinary Page. + summary: { type: string } + supersedes: + oneOf: + - $ref: "#/components/schemas/DecisionLineageEntry" + - type: "null" + supersededBy: + oneOf: + - $ref: "#/components/schemas/DecisionLineageEntry" + - type: "null" + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + eTag: { type: string } + DecisionSummary: + type: object + required: [id, decisionNumber, status, title, decisionDate, summary, updatedAt] + properties: + id: { type: string, format: uuid } + projectId: { type: [string, "null"], format: uuid } + decisionNumber: { type: integer } + status: { $ref: "#/components/schemas/DecisionStatus" } + title: { type: string } + decisionDate: { type: string, format: date } + summary: { type: string } + updatedAt: { type: string, format: date-time } + DecisionListResponse: + type: object + required: [items, nextCursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/DecisionSummary" + nextCursor: + type: [string, "null"] + CreateDecisionRequest: + type: object + required: [title, summary] + properties: + parentPageId: { type: [string, "null"], format: uuid } + projectId: { type: [string, "null"], format: uuid } + title: { type: string, minLength: 1 } + markdown: { type: [string, "null"] } + summary: { type: string } + supersedesDecisionId: { type: [string, "null"], format: uuid } + RunbookDetail: + type: object + required: + - id + - pageId + - owner + - riskLevel + - status + - requiresMaintenanceWindow + - isStale + - title + - markdown + - renderedHtml + - currentRevisionNumber + - createdAt + - updatedAt + - eTag + properties: + id: { type: string, format: uuid } + pageId: { type: string, format: uuid } + owner: { type: string, format: uuid } + riskLevel: { $ref: "#/components/schemas/RunbookRiskLevel" } + status: { $ref: "#/components/schemas/RunbookStatus" } + requiresMaintenanceWindow: { type: boolean } + estimatedDurationMinutes: { type: [integer, "null"] } + reviewDueAt: { type: [string, "null"], format: date-time } + lastValidatedAt: { type: [string, "null"], format: date-time } + validatedBy: { type: [string, "null"], format: uuid } + isStale: + type: boolean + description: True whenever the linked page has a newer revision than the last validation, or was never validated. + title: { type: string } + markdown: { type: string } + renderedHtml: + type: string + description: Server-sanitized HTML — the same rendering pipeline as an ordinary Page. Never executed. + currentRevisionNumber: { type: integer, format: int64 } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + eTag: { type: string } + RunbookSummary: + type: object + required: [id, pageId, title, riskLevel, status, isStale, updatedAt] + properties: + id: { type: string, format: uuid } + pageId: { type: string, format: uuid } + title: { type: string } + riskLevel: { $ref: "#/components/schemas/RunbookRiskLevel" } + status: { $ref: "#/components/schemas/RunbookStatus" } + isStale: { type: boolean } + lastValidatedAt: { type: [string, "null"], format: date-time } + updatedAt: { type: string, format: date-time } + RunbookListResponse: + type: object + required: [items, nextCursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/RunbookSummary" + nextCursor: + type: [string, "null"] + CreateRunbookRequest: + type: object + required: [title, riskLevel] + properties: + parentPageId: { type: [string, "null"], format: uuid } + title: { type: string, minLength: 1 } + markdown: { type: [string, "null"] } + templateKey: { type: [string, "null"] } + riskLevel: { $ref: "#/components/schemas/RunbookRiskLevel" } + requiresMaintenanceWindow: { type: boolean } + estimatedDurationMinutes: { type: [integer, "null"] } + UpdateRunbookProfileRequest: + type: object + required: [owner, riskLevel, status, requiresMaintenanceWindow] + properties: + owner: { type: string, format: uuid } + riskLevel: { $ref: "#/components/schemas/RunbookRiskLevel" } + status: { $ref: "#/components/schemas/RunbookStatus" } + requiresMaintenanceWindow: { type: boolean } + estimatedDurationMinutes: { type: [integer, "null"] } + reviewDueAt: { type: [string, "null"], format: date-time } + CreateServiceAccountRequest: + type: object + required: [name, description, scopes] + properties: + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: string + maxLength: 1000 + scopes: + type: array + minItems: 1 + uniqueItems: true + items: + type: string + description: "One of the known scope constants, e.g. pages:read, pages:write, projects:read, runbooks:write (see docs/09-api-contract.md)." + IssueServiceAccountTokenRequest: + type: object + required: [scopes] + properties: + scopes: + type: array + minItems: 1 + uniqueItems: true + items: + type: string + CreatedServiceAccountToken: + type: object + description: The raw token is only ever present in this one response — it is never persisted in the clear and is not retrievable again. + required: [serviceAccountId, tokenId, rawToken] + properties: + serviceAccountId: + type: string + format: uuid + tokenId: + type: string + format: uuid + rawToken: + type: string + description: "Format ikh__. Present exactly once." + ServiceAccountTokenSummary: + type: object + required: [id, publicId, scopes, createdAt] + properties: + id: + type: string + format: uuid + publicId: + type: string + description: Safe, non-secret identifier — never the raw secret. + scopes: + type: array + items: + type: string + createdAt: + type: string + format: date-time + lastUsedAt: + type: [string, "null"] + format: date-time + expiresAt: + type: [string, "null"] + format: date-time + revokedAt: + type: [string, "null"] + format: date-time + ServiceAccountSummary: + type: object + required: [id, name, description, enabled, createdAt, tokens] + properties: + id: + type: string + format: uuid + name: + type: string + description: + type: string + enabled: + type: boolean + createdAt: + type: string + format: date-time + tokens: + type: array + items: + $ref: "#/components/schemas/ServiceAccountTokenSummary" + ServiceAccountListResponse: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/ServiceAccountSummary" + CitationResponse: + type: object + description: A stable, citable pointer to a document or decision included in a project context bundle. + required: [kind, id, title, canonicalPath, updatedAt] + properties: + kind: + type: string + enum: [Page, DecisionRecord, RunbookProfile] + id: + type: string + format: uuid + revision: + type: [integer, "null"] + format: int64 + title: + type: string + canonicalPath: + type: string + updatedAt: + type: string + format: date-time + ProjectContextProjectSummary: + type: object + required: [id, name, status, summary, currentStateMarkdown, updatedAt] + properties: + id: + type: string + format: uuid + name: + type: string + status: + type: string + summary: + type: string + currentStateMarkdown: + type: string + updatedAt: + type: string + format: date-time + ProjectContextTruncation: + type: object + required: [truncated, omittedCounts] + properties: + truncated: + type: boolean + omittedCounts: + type: object + additionalProperties: + type: integer + description: "Counts of items omitted per collection (e.g. updates, decisions), keyed by collection name." + ProjectContextResponse: + type: object + description: "A bounded, permission-filtered bundle of the current project state, suitable for a single-call context fetch (docs/09-api-contract.md 'Project context', M6-A04). Unlike the unbounded project overview, this response always reports what was truncated rather than growing unbounded." + required: [project, recentUpdates, activeMilestones, decisions, runbooks, documents, citations, truncation] + properties: + project: + $ref: "#/components/schemas/ProjectContextProjectSummary" + recentUpdates: + type: array + items: + $ref: "#/components/schemas/ProjectUpdateSummary" + activeMilestones: + type: array + items: + $ref: "#/components/schemas/ProjectMilestoneSummary" + decisions: + type: array + items: + $ref: "#/components/schemas/ProjectDecisionSummary" + runbooks: + type: array + items: + $ref: "#/components/schemas/ProjectDocumentSummary" + documents: + type: array + items: + $ref: "#/components/schemas/ProjectDocumentSummary" + citations: + type: array + items: + $ref: "#/components/schemas/CitationResponse" + truncation: + $ref: "#/components/schemas/ProjectContextTruncation" + BackupSummaryResponse: + type: object + description: Never includes a filesystem path, connection string or other private detail. + required: [backupId, createdAt, totalSizeBytes, tableCount, totalRowCount, attachmentCount, schemaVersion, productVersion] + properties: + backupId: + type: string + createdAt: + type: string + format: date-time + totalSizeBytes: + type: integer + format: int64 + tableCount: + type: integer + totalRowCount: + type: integer + format: int64 + attachmentCount: + type: integer + schemaVersion: + type: string + productVersion: + type: string + BackupListResponse: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/BackupSummaryResponse" + ProblemDetails: + type: object + required: [type, title, status, correlationId] + properties: + type: + type: string + format: uri-reference + title: + type: string + status: + type: integer + detail: + type: string + instance: + type: string + format: uri-reference + correlationId: + type: string + errors: + type: object + additionalProperties: + type: array + items: + type: string + SetupStatus: + type: object + required: [setupRequired] + properties: + setupRequired: + type: boolean + CompleteSetupRequest: + type: object + required: [workspaceName, displayName, email, password] + properties: + workspaceName: + type: string + minLength: 1 + maxLength: 200 + displayName: + type: string + minLength: 1 + maxLength: 200 + email: + type: string + format: email + password: + type: string + minLength: 12 + CompleteSetupResponse: + type: object + required: [workspaceId, userId] + properties: + workspaceId: + type: string + format: uuid + userId: + type: string + format: uuid + CsrfToken: + type: object + required: [token, headerName] + properties: + token: + type: string + headerName: + type: string + WorkspaceRole: + type: string + enum: [Reader, Editor, Administrator] + SessionResponse: + type: object + required: [authenticated, setupRequired] + properties: + authenticated: + type: boolean + setupRequired: + type: boolean + userId: + type: [string, "null"] + format: uuid + displayName: + type: [string, "null"] + email: + type: [string, "null"] + workspaceId: + type: [string, "null"] + format: uuid + role: + oneOf: + - $ref: "#/components/schemas/WorkspaceRole" + - type: "null" + LoginRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + RecoveryRedeemRequest: + type: object + required: [token, newPassword] + properties: + token: + type: string + newPassword: + type: string + minLength: 12 + UserSummary: + type: object + required: [id, displayName, email, role, enabled, createdAt] + properties: + id: + type: string + format: uuid + displayName: + type: string + email: + type: string + role: + $ref: "#/components/schemas/WorkspaceRole" + enabled: + type: boolean + createdAt: + type: string + format: date-time + lastLoginAt: + type: [string, "null"] + format: date-time + CreateUserRequest: + type: object + required: [displayName, email, password, role] + properties: + displayName: + type: string + minLength: 1 + maxLength: 200 + email: + type: string + format: email + password: + type: string + minLength: 12 + role: + $ref: "#/components/schemas/WorkspaceRole" + UpdateUserRequest: + type: object + properties: + role: + oneOf: + - $ref: "#/components/schemas/WorkspaceRole" + - type: "null" + enabled: + type: [boolean, "null"] + IssueRecoveryTokenResponse: + type: object + required: [token, expiresAt] + properties: + token: + type: string + description: Shown once, at issuance. Never retrievable again. + expiresAt: + type: string + format: date-time diff --git a/docs/assets/screenshots/02-dashboard.png b/docs/assets/screenshots/02-dashboard.png new file mode 100644 index 0000000..0469469 Binary files /dev/null and b/docs/assets/screenshots/02-dashboard.png differ diff --git a/docs/assets/screenshots/03-knowledge.png b/docs/assets/screenshots/03-knowledge.png new file mode 100644 index 0000000..70bdc2d Binary files /dev/null and b/docs/assets/screenshots/03-knowledge.png differ diff --git a/docs/guides/back-ups-herstel-en-unraid-beheer.md b/docs/guides/back-ups-herstel-en-unraid-beheer.md new file mode 100644 index 0000000..fcba059 --- /dev/null +++ b/docs/guides/back-ups-herstel-en-unraid-beheer.md @@ -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: " -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= \ + -e KnowledgeHub__Database__Name= \ + -e KnowledgeHub__Database__Username= \ + -e KnowledgeHub__Database__Password= \ + 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 :/var/lib/knowledge-hub/attachments \ + itworx/knowledge-hub:local \ + dotnet ItWorx.KnowledgeHub.Api.dll \ + --restore \ + --backup-id= \ + --target-connection="Host=;Port=5432;Database=;Username=;Password=" \ + --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. diff --git a/docs/guides/bijlagen-en-leafwiki-import.md b/docs/guides/bijlagen-en-leafwiki-import.md new file mode 100644 index 0000000..49d810c --- /dev/null +++ b/docs/guides/bijlagen-en-leafwiki-import.md @@ -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": "", "idempotencyKey": "" } + ``` + + 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= + ``` + + 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. diff --git a/docs/guides/documenten-toevoegen-en-intake.md b/docs/guides/documenten-toevoegen-en-intake.md new file mode 100644 index 0000000..dc41d3b --- /dev/null +++ b/docs/guides/documenten-toevoegen-en-intake.md @@ -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. diff --git a/docs/guides/eerste-installatie-en-aanmelden.md b/docs/guides/eerste-installatie-en-aanmelden.md new file mode 100644 index 0000000..a966e44 --- /dev/null +++ b/docs/guides/eerste-installatie-en-aanmelden.md @@ -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). diff --git a/docs/guides/operationele-runbooks-overzicht.md b/docs/guides/operationele-runbooks-overzicht.md new file mode 100644 index 0000000..944e610 --- /dev/null +++ b/docs/guides/operationele-runbooks-overzicht.md @@ -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. diff --git a/docs/guides/pagina-s-schrijven-en-beheren.md b/docs/guides/pagina-s-schrijven-en-beheren.md new file mode 100644 index 0000000..cf3176b --- /dev/null +++ b/docs/guides/pagina-s-schrijven-en-beheren.md @@ -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). diff --git a/docs/guides/projecten-beslissingen-en-runbooks.md b/docs/guides/projecten-beslissingen-en-runbooks.md new file mode 100644 index 0000000..f973051 --- /dev/null +++ b/docs/guides/projecten-beslissingen-en-runbooks.md @@ -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 + ``), nooit als echte waarde. diff --git a/docs/guides/service-accounts-en-automatisering.md b/docs/guides/service-accounts-en-automatisering.md new file mode 100644 index 0000000..2ff8cd0 --- /dev/null +++ b/docs/guides/service-accounts-en-automatisering.md @@ -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 `-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: " \ + -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__" +} +``` + +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__" +``` + +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//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//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. diff --git a/docs/guides/source-batch-removal.md b/docs/guides/source-batch-removal.md new file mode 100644 index 0000000..b1ebb4e --- /dev/null +++ b/docs/guides/source-batch-removal.md @@ -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. diff --git a/docs/guides/zoeken-geschiedenis-en-labels.md b/docs/guides/zoeken-geschiedenis-en-labels.md new file mode 100644 index 0000000..435ed8e --- /dev/null +++ b/docs/guides/zoeken-geschiedenis-en-labels.md @@ -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. diff --git a/fixtures/leafwiki/README.md b/fixtures/leafwiki/README.md new file mode 100644 index 0000000..dc488e2 --- /dev/null +++ b/fixtures/leafwiki/README.md @@ -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/`. diff --git a/fixtures/markdown-import/Projecten/Voorbeeldproject.md b/fixtures/markdown-import/Projecten/Voorbeeldproject.md new file mode 100644 index 0000000..0682879 --- /dev/null +++ b/fixtures/markdown-import/Projecten/Voorbeeldproject.md @@ -0,0 +1,10 @@ +--- +title: Voorbeeldproject +status: active +--- + +# Voorbeeldproject + +Dit is uitsluitend synthetische migratie-inhoud. + +![Architectuur](../assets/architecture.svg) diff --git a/fixtures/markdown-import/School/Linux/Bestandsrechten.md b/fixtures/markdown-import/School/Linux/Bestandsrechten.md new file mode 100644 index 0000000..9b67458 --- /dev/null +++ b/fixtures/markdown-import/School/Linux/Bestandsrechten.md @@ -0,0 +1,5 @@ +# Linux-bestandsrechten + +Bestandsrechten worden weergegeven voor eigenaar, groep en anderen. + +Terug naar [[Shell-operators]]. diff --git a/fixtures/markdown-import/School/Linux/Shell-operators.md b/fixtures/markdown-import/School/Linux/Shell-operators.md new file mode 100644 index 0000000..f164493 --- /dev/null +++ b/fixtures/markdown-import/School/Linux/Shell-operators.md @@ -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]]. diff --git a/fixtures/markdown-import/assets/architecture.svg b/fixtures/markdown-import/assets/architecture.svg new file mode 100644 index 0000000..3c2c793 --- /dev/null +++ b/fixtures/markdown-import/assets/architecture.svg @@ -0,0 +1,8 @@ + + + + + + Wiki + Project + diff --git a/fixtures/markdown-import/source-manifest.json b/fixtures/markdown-import/source-manifest.json new file mode 100644 index 0000000..c59d89f --- /dev/null +++ b/fixtures/markdown-import/source-manifest.json @@ -0,0 +1,5 @@ +{ + "fixture": "synthetic-markdown-import", + "containsRealUserData": false, + "purpose": "LeafWiki migration discovery and import tests" +} diff --git a/global.json b/global.json new file mode 100644 index 0000000..d99e6aa --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.302", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cdf6385 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..944d57e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3903 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + js-yaml: + specifier: ^4.1.0 + version: 4.3.0 + + src/frontend: + dependencies: + '@codemirror/autocomplete': + specifier: ^6.20.3 + version: 6.20.3 + '@codemirror/commands': + specifier: ^6.10.4 + version: 6.10.4 + '@codemirror/lang-markdown': + specifier: ^6.5.1 + version: 6.5.1 + '@codemirror/language': + specifier: ^6.12.4 + version: 6.12.4 + '@codemirror/search': + specifier: ^6.7.1 + version: 6.7.1 + '@codemirror/state': + specifier: ^6.7.1 + version: 6.7.1 + '@codemirror/view': + specifier: ^6.43.7 + version: 6.43.7 + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + '@lezer/highlight': + specifier: ^1.2.3 + version: 1.2.3 + '@radix-ui/react-dialog': + specifier: ^1.1.23 + version: 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.24 + version: 2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-tooltip': + specifier: ^1.2.16 + version: 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@uiw/react-codemirror': + specifier: ^4.25.11 + version: 4.25.11(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.7)(codemirror@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + lucide-react: + specifier: ^1.28.0 + version: 1.28.0(react@19.2.8) + motion: + specifier: ^12.43.0 + version: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + react-resizable-panels: + specifier: ^4.12.2 + version: 4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-router: + specifier: ^8.3.0 + version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + devDependencies: + '@eslint/js': + specifier: ^9.39.5 + version: 9.39.5 + '@testing-library/jest-dom': + specifier: ^6.8.0 + version: 6.10.0(@testing-library/dom@10.4.1) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^24.3.0 + version: 24.13.3 + '@types/react': + specifier: ^19.2.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.5 + version: 6.0.5(vite@8.2.0(@types/node@24.13.3)) + eslint: + specifier: ^9.39.5 + version: 9.39.5 + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@9.39.5) + eslint-plugin-react-refresh: + specifier: ^0.4.20 + version: 0.4.26(eslint@9.39.5) + globals: + specifier: ^16.3.0 + version: 16.5.0 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.41.0 + version: 8.65.0(eslint@9.39.5)(typescript@5.9.3) + vite: + specifier: ^8.2.0 + version: 8.2.0(@types/node@24.13.3) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)) + + tests/e2e: + devDependencies: + '@axe-core/playwright': + specifier: ^4.10.2 + version: 4.12.1(playwright-core@1.62.1) + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@types/node': + specifier: ^24.3.0 + version: 24.13.3 + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@axe-core/playwright@4.12.1': + resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==} + peerDependencies: + playwright-core: '>= 1.0.0' + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.10.4': + resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/lang-markdown@6.5.1': + resolution: {integrity: sha512-6re5avCNfyRMIoi3XNjbEfQM1vTeVD3JS3g/Fyegyso/eoANFM71Cyvbb66LDyYtQLMEcRFlzioywCqDo9SlLA==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/search@6.7.1': + resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==} + + '@codemirror/state@6.7.1': + resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.43.7': + resolution: {integrity: sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@emnapi/core@2.0.0-alpha.3': + resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} + + '@emnapi/runtime@2.0.0-alpha.3': + resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} + + '@emnapi/wasi-threads@2.0.1': + resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/css@1.3.4': + resolution: {integrity: sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@lezer/markdown@1.7.2': + resolution: {integrity: sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==} + + '@marijn/find-cluster-break@1.0.3': + resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + + '@napi-rs/wasm-runtime@1.2.1': + resolution: {integrity: sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-arrow@1.1.15': + resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.24': + resolution: {integrity: sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-menu@2.1.24': + resolution: {integrity: sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.7': + resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.19': + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tooltip@1.2.16': + resolution: {integrity: sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.4': + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.11': + resolution: {integrity: sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + + '@rolldown/binding-android-arm64@1.2.1': + resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.1': + resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.1': + resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.1': + resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.1': + resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.1': + resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.1': + resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.1': + resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.2.1': + resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.1': + resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@typescript-eslint/eslint-plugin@8.65.0': + resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.65.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.65.0': + resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.65.0': + resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.65.0': + resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.65.0': + resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.65.0': + resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.65.0': + resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.65.0': + resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@uiw/codemirror-extensions-basic-setup@4.25.11': + resolution: {integrity: sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==} + peerDependencies: + '@codemirror/autocomplete': '>=6.0.0' + '@codemirror/commands': '>=6.0.0' + '@codemirror/language': '>=6.0.0' + '@codemirror/lint': '>=6.0.0' + '@codemirror/search': '>=6.0.0' + '@codemirror/state': '>=6.0.0' + '@codemirror/view': '>=6.0.0' + + '@uiw/react-codemirror@4.25.11': + resolution: {integrity: sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==} + peerDependencies: + '@babel/runtime': '>=7.11.0' + '@codemirror/state': '>=6.0.0' + '@codemirror/theme-one-dark': '>=6.0.0' + '@codemirror/view': '>=6.0.0' + codemirror: '>=6.0.0' + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@vitejs/plugin-react@6.0.5': + resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + axe-core@4.12.1: + resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + engines: {node: '>=4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.7: + resolution: {integrity: sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + electron-to-chromium@1.5.398: + resolution: {integrity: sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + framer-motion@12.43.0: + resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.28.0: + resolution: {integrity: sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + motion-dom@12.43.0: + resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.43.0: + resolution: {integrity: sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-resizable-panels@4.12.2: + resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + react-router@8.3.0: + resolution: {integrity: sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==} + engines: {node: '>=22.22.0'} + peerDependencies: + react: '>=19.2.7' + react-dom: '>=19.2.7' + peerDependenciesMeta: + react-dom: + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + rolldown@1.2.1: + resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.65.0: + resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@axe-core/playwright@4.12.1(playwright-core@1.62.1)': + dependencies: + axe-core: 4.12.1 + playwright-core: 1.62.1 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.4': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + '@lezer/common': 1.5.2 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.4 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.4 + '@lezer/html': 1.3.13 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-markdown@6.5.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + '@lezer/common': 1.5.2 + '@lezer/markdown': 1.7.2 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + crelt: 1.0.7 + + '@codemirror/search@6.7.1': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + crelt: 1.0.7 + + '@codemirror/state@6.7.1': + dependencies: + '@marijn/find-cluster-break': 1.0.3 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.43.7': + dependencies: + '@codemirror/state': 6.7.1 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@dnd-kit/accessibility@3.1.1(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + + '@emnapi/core@2.0.0-alpha.3': + dependencies: + '@emnapi/wasi-threads': 2.0.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@2.0.0-alpha.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@2.0.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)': + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@floating-ui/utils@0.2.12': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lezer/common@1.5.2': {} + + '@lezer/css@1.3.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/markdown@1.7.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + + '@marijn/find-cluster-break@1.0.3': {} + + '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.142.0': {} + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context@1.2.2(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-direction@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-menu@2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/rect': 1.1.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.3.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-tooltip@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/rect@1.1.3': {} + + '@rolldown/binding-android-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-x64@1.2.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.2.1': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tanstack/react-table@8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@tanstack/table-core@8.21.3': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.65.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + eslint: 9.39.5 + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.65.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.65.0': {} + + '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.65.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + eslint-visitor-keys: 5.0.1 + + '@uiw/codemirror-extensions-basic-setup@4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/view@6.43.7)': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.10.4 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.1 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + + '@uiw/react-codemirror@4.25.11(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.7)(codemirror@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@codemirror/commands': 6.10.4 + '@codemirror/state': 6.7.1 + '@codemirror/theme-one-dark': 6.1.3 + '@codemirror/view': 6.43.7 + '@uiw/codemirror-extensions-basic-setup': 4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/view@6.43.7) + codemirror: 6.0.2 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@codemirror/autocomplete' + - '@codemirror/language' + - '@codemirror/lint' + - '@codemirror/search' + + '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@24.13.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.0(@types/node@24.13.3) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@24.13.3))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.0(@types/node@24.13.3) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + axe-core@4.12.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.7: {} + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.7 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.398 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001806: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.10.4 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.1 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cookie-es@3.1.1: {} + + crelt@1.0.7: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-is@0.1.4: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + electron-to-chromium@1.5.398: {} + + entities@6.0.1: {} + + es-module-lexer@2.3.1: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@9.39.5): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 9.39.5 + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.4.26(eslint@9.39.5): + dependencies: + eslint: 9.39.5 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + framer-motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + motion-dom: 12.43.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-nonce@1.0.1: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.5.0: {} + + has-flag@4.0.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.28.0(react@19.2.8): + dependencies: + react: 19.2.8 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + min-indent@1.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + motion-dom@12.43.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + framer-motion: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.51: {} + + nwsapi@2.2.24: {} + + obug@2.1.4: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.8): + dependencies: + react: 19.2.8 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.8): + dependencies: + react: 19.2.8 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.8) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + + react-resizable-panels@4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie-es: 3.1.1 + react: 19.2.8 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.8): + dependencies: + get-nonce: 1.0.1 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react@19.2.8: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + resolve-from@4.0.0: {} + + rolldown@1.2.1: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.1 + '@rolldown/binding-darwin-arm64': 1.2.1 + '@rolldown/binding-darwin-x64': 1.2.1 + '@rolldown/binding-freebsd-x64': 1.2.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 + '@rolldown/binding-linux-arm64-gnu': 1.2.1 + '@rolldown/binding-linux-arm64-musl': 1.2.1 + '@rolldown/binding-linux-ppc64-gnu': 1.2.1 + '@rolldown/binding-linux-s390x-gnu': 1.2.1 + '@rolldown/binding-linux-x64-gnu': 1.2.1 + '@rolldown/binding-linux-x64-musl': 1.2.1 + '@rolldown/binding-openharmony-arm64': 1.2.1 + '@rolldown/binding-wasm32-wasi': 1.2.1 + '@rolldown/binding-win32-arm64-msvc': 1.2.1 + '@rolldown/binding-win32-x64-msvc': 1.2.1 + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + style-mod@4.1.3: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.65.0(eslint@9.39.5)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5)(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.8): + dependencies: + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.8): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + vite@8.2.0(@types/node@24.13.3): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + + vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.0(@types/node@24.13.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + + w3c-keyname@2.2.8: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + ws@8.21.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..1fcb2ef --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +packages: + - "src/frontend" + - "tests/e2e" +minimumReleaseAgeExclude: + - '@playwright/test@1.62.1' + - playwright-core@1.62.1 + - playwright@1.62.1 diff --git a/scripts/check-openapi-coverage.mjs b/scripts/check-openapi-coverage.mjs new file mode 100644 index 0000000..9949ae2 --- /dev/null +++ b/scripts/check-openapi-coverage.mjs @@ -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.`); diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs new file mode 100644 index 0000000..503ed45 --- /dev/null +++ b/scripts/doctor.mjs @@ -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."); diff --git a/scripts/generate-release-manifest.mjs b/scripts/generate-release-manifest.mjs new file mode 100644 index 0000000..1a55070 --- /dev/null +++ b/scripts/generate-release-manifest.mjs @@ -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)); diff --git a/scripts/lib/exec.mjs b/scripts/lib/exec.mjs new file mode 100644 index 0000000..3d7dab9 --- /dev/null +++ b/scripts/lib/exec.mjs @@ -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(); +} diff --git a/scripts/validate-openapi.mjs b/scripts/validate-openapi.mjs new file mode 100644 index 0000000..d8f22b4 --- /dev/null +++ b/scripts/validate-openapi.mjs @@ -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.`); diff --git a/scripts/validate-public-source.mjs b/scripts/validate-public-source.mjs new file mode 100644 index 0000000..49b3ea4 --- /dev/null +++ b/scripts/validate-public-source.mjs @@ -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).`); diff --git a/scripts/verify.mjs b/scripts/verify.mjs new file mode 100644 index 0000000..da33f07 --- /dev/null +++ b/scripts/verify.mjs @@ -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 `_.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); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Authorization/AuthorizationPolicies.cs b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/AuthorizationPolicies.cs new file mode 100644 index 0000000..8343997 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/AuthorizationPolicies.cs @@ -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"; +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessAuthorizationHandler.cs b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessAuthorizationHandler.cs new file mode 100644 index 0000000..208b054 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessAuthorizationHandler.cs @@ -0,0 +1,58 @@ +using ItWorx.KnowledgeHub.Application.Identity; +using Microsoft.AspNetCore.Authorization; + +namespace ItWorx.KnowledgeHub.Api.Authorization; + +/// +/// 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 WorkspaceRoleAuthorizationHandler +/// already does (never from a stale role claim baked into the cookie). +/// +public sealed class ContentAccessAuthorizationHandler( + IUserAccountManager userAccounts, + IWorkspaceRepository workspaces, + IWorkspaceMembershipRepository memberships) + : AuthorizationHandler +{ + 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); + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessEndpointExtensions.cs b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessEndpointExtensions.cs new file mode 100644 index 0000000..1ab187a --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessEndpointExtensions.cs @@ -0,0 +1,26 @@ +using ItWorx.KnowledgeHub.Domain.Identity; + +namespace ItWorx.KnowledgeHub.Api.Authorization; + +public static class ContentAccessEndpointExtensions +{ + /// Requires either the given minimum cookie-session workspace role, or a + /// bearer service-token carrying — 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 + /// AuthenticationSchemes list re-authenticates using *only* those schemes + /// for that check, rather than reusing whatever app.UseAuthentication()'s + /// default-scheme pass already populated on HttpContext.User — 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. + public static TBuilder RequireContentAccess(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))); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessRequirement.cs b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessRequirement.cs new file mode 100644 index 0000000..644e065 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessRequirement.cs @@ -0,0 +1,20 @@ +using ItWorx.KnowledgeHub.Domain.Identity; +using Microsoft.AspNetCore.Authorization; + +namespace ItWorx.KnowledgeHub.Api.Authorization; + +/// +/// Satisfied by either a cookie-authenticated human whose workspace role is at least +/// (the original M1 WorkspaceRoleRequirement check), or +/// a bearer-token-authenticated service account carrying . +/// 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"). +/// +public sealed class ContentAccessRequirement(WorkspaceRole minimumRole, string requiredScope) : IAuthorizationRequirement +{ + public WorkspaceRole MinimumRole { get; } = minimumRole; + + public string RequiredScope { get; } = requiredScope; +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Authorization/ServiceTokenAuthenticationHandler.cs b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/ServiceTokenAuthenticationHandler.cs new file mode 100644 index 0000000..bb1b1ee --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/ServiceTokenAuthenticationHandler.cs @@ -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"; +} + +/// +/// Authenticates Authorization: Bearer ikh_<public-id>_<secret> +/// 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 . +/// +public sealed class ServiceTokenAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + AuthenticateServiceTokenUseCase authenticate, + ServiceTokenFailureRateLimiter failureRateLimiter) + : AuthenticationHandler(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 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 + { + 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); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Authorization/WorkspaceRoleAuthorizationHandler.cs b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/WorkspaceRoleAuthorizationHandler.cs new file mode 100644 index 0000000..448d318 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/WorkspaceRoleAuthorizationHandler.cs @@ -0,0 +1,44 @@ +using ItWorx.KnowledgeHub.Application.Identity; +using Microsoft.AspNetCore.Authorization; + +namespace ItWorx.KnowledgeHub.Api.Authorization; + +/// +/// 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. +/// +public sealed class WorkspaceRoleAuthorizationHandler( + IUserAccountManager userAccounts, + IWorkspaceRepository workspaces, + IWorkspaceMembershipRepository memberships) + : AuthorizationHandler +{ + 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); + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Authorization/WorkspaceRoleRequirement.cs b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/WorkspaceRoleRequirement.cs new file mode 100644 index 0000000..efb0629 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Authorization/WorkspaceRoleRequirement.cs @@ -0,0 +1,12 @@ +using ItWorx.KnowledgeHub.Domain.Identity; +using Microsoft.AspNetCore.Authorization; + +namespace ItWorx.KnowledgeHub.Api.Authorization; + +/// Satisfied when the signed-in user's role in the default workspace is at +/// least (docs/12-auth-security.md — enforced server-side in +/// Application/API, never only by hiding UI). +public sealed class WorkspaceRoleRequirement(WorkspaceRole minimumRole) : IAuthorizationRequirement +{ + public WorkspaceRole MinimumRole { get; } = minimumRole; +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/StagingSessionCleanupBackgroundService.cs b/src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/StagingSessionCleanupBackgroundService.cs new file mode 100644 index 0000000..cdb07fd --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/StagingSessionCleanupBackgroundService.cs @@ -0,0 +1,109 @@ +using ItWorx.KnowledgeHub.Application.Knowledge; +using ItWorx.KnowledgeHub.Application.Knowledge.UseCases; + +namespace ItWorx.KnowledgeHub.Api.BackgroundServices; + +/// +/// 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 +/// , shared with the admin +/// "run now" action, so the two paths can never diverge in behavior. +/// +public sealed partial class StagingSessionCleanupBackgroundService( + IServiceScopeFactory scopeFactory, + StagingSessionCleanupOptions options, + StagingCleanupStatusTracker statusTracker, + TimeProvider timeProvider, + ILogger 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(); + 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); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ActivityEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ActivityEndpoints.cs new file mode 100644 index 0000000..faae588 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ActivityEndpoints.cs @@ -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 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())); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AdminEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AdminEndpoints.cs new file mode 100644 index 0000000..533a2ca --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AdminEndpoints.cs @@ -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(); + + group.MapPatch("/users/{userId:guid}", UpdateUserAsync) + .AddEndpointFilter(); + + group.MapPost("/users/{userId:guid}/recovery-tokens", IssueRecoveryTokenAsync) + .AddEndpointFilter(); + + group.MapPost("/trash/purge", PurgeTrashAsync) + .AddEndpointFilter(); + + group.MapGet("/staging-cleanup/status", GetStagingCleanupStatusAsync); + + group.MapPost("/staging-cleanup/run", RunStagingCleanupAsync) + .AddEndpointFilter(); + + return app; + } + + private static async Task> 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, 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, 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>> ListUsersAsync( + ListWorkspaceUsersUseCase useCase, + CancellationToken cancellationToken) + { + var users = await useCase.ExecuteAsync(cancellationToken); + IReadOnlyList 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, 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> 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, 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)!); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AttachmentsEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AttachmentsEndpoints.cs new file mode 100644 index 0000000..1f66cd1 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AttachmentsEndpoints.cs @@ -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() + .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() + .DisableAntiforgery(); + + return app; + } + + private static async Task> 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> ListForPageAsync( + Guid pageId, + GetPageAttachmentsUseCase useCase, + CancellationToken cancellationToken) + { + var results = await useCase.ExecuteAsync(pageId, cancellationToken); + + IReadOnlyList 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, 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, 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, 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)!); + + /// Distinguishes a human (cookie-authenticated) requestor, whose workspace + /// role is resolved live from the database, from a service-account (bearer-token) + /// requestor — recognized by , + /// since deliberately reuses the + /// standard name-identifier claim for the service account's own ID, not a human + /// user's ID. + private static async Task 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); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AuthEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..c1b799f --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AuthEndpoints.cs @@ -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(); + + group.MapPost("/recovery/redeem", RedeemRecoveryAsync) + .RequireRateLimiting(RateLimitPolicies.Recovery); + + return app; + } + + private static Ok GetCsrfTokenAsync(HttpContext httpContext, IAntiforgery antiforgery) + { + var tokens = antiforgery.GetAndStoreTokens(httpContext); + return TypedResults.Ok(new CsrfTokenResponse(tokens.RequestToken!, tokens.HeaderName!)); + } + + private static async Task> GetSessionAsync( + GetSessionUseCase useCase, + IOptions ingestionOptions, + CancellationToken cancellationToken) + { + var session = await useCase.ExecuteAsync(cancellationToken); + return TypedResults.Ok(ToSessionResponse(session, ingestionOptions.Value)); + } + + private static async Task, ProblemHttpResult>> LoginAsync( + [FromBody] LoginRequest request, + LoginUseCase loginUseCase, + GetSessionUseCase sessionUseCase, + IOptions 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 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> 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); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/BackupsEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/BackupsEndpoints.cs new file mode 100644 index 0000000..d15438c --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/BackupsEndpoints.cs @@ -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; + +/// +/// 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 RestoreModeRunner and ADR-016 decision 4). +/// +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(); + + return app; + } + + private static async Task> ListAsync(ListBackupsUseCase useCase, CancellationToken cancellationToken) + { + var backups = await useCase.ExecuteAsync(cancellationToken); + return TypedResults.Ok(new BackupListResponse(backups.Select(ToResponse).ToList())); + } + + private static async Task> 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); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DecisionsEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DecisionsEndpoints.cs new file mode 100644 index 0000000..f91c0c1 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DecisionsEndpoints.cs @@ -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() + .AddEndpointFilter(); + + group.MapPost("/{decisionId:guid}/accept", AcceptAsync) + .RequireAuthorization(AuthorizationPolicies.RequireEditor) + .AddEndpointFilter(); + + group.MapPost("/{decisionId:guid}/reject", RejectAsync) + .RequireAuthorization(AuthorizationPolicies.RequireEditor) + .AddEndpointFilter(); + + group.MapPost("/{decisionId:guid}/withdraw", WithdrawAsync) + .RequireAuthorization(AuthorizationPolicies.RequireEditor) + .AddEndpointFilter(); + + return app; + } + + private static async Task> 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(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, 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, 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, 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, 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, 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, ProblemHttpResult>> ApplyTransitionAsync( + Guid decisionId, + HttpContext httpContext, + Func> 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)!); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DraftsEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DraftsEndpoints.cs new file mode 100644 index 0000000..ce66a4e --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DraftsEndpoints.cs @@ -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; + +/// Draft autosave — private per user, never silently overwrites a newer +/// published revision (docs/27-acceptance-matrix.md, M2-A04). +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(); + + group.MapDelete("/{draftKey}", DiscardDraftAsync) + .AddEndpointFilter(); + + return app; + } + + private static async Task, 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> 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> 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)!); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ImportsEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ImportsEndpoints.cs new file mode 100644 index 0000000..12c9d04 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ImportsEndpoints.cs @@ -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; + +/// +/// 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. +/// +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(); + + group.MapGet("/{jobId:guid}", GetJobAsync) + .RequireContentAccess(WorkspaceRole.Administrator, Scopes.ImportsRead); + + group.MapPost("/{jobId:guid}/execute", ExecuteAsync) + .RequireContentAccess(WorkspaceRole.Administrator, Scopes.ImportsExecute) + .AddEndpointFilter(); + + group.MapPost("/{jobId:guid}/rollback", RollbackAsync) + .RequireContentAccess(WorkspaceRole.Administrator, Scopes.ImportsExecute) + .AddEndpointFilter(); + + return app; + } + + private static async Task, 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 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, 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, 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, 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)!); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/InfrastructureEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/InfrastructureEndpoints.cs new file mode 100644 index 0000000..17e412e --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/InfrastructureEndpoints.cs @@ -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(); + group.MapPost("/services/{serviceId:guid}/overrides", SetOverrideAsync) + .RequireContentAccess(WorkspaceRole.Editor, Scopes.InfrastructureWrite) + .AddEndpointFilter(); + // 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(); + group.MapPost("/services/{serviceId:guid}/review", ReviewServiceAsync) + .RequireContentAccess(WorkspaceRole.Administrator, Scopes.InfrastructureWrite) + .AddEndpointFilter(); + group.MapDelete("/overrides/{overrideId:guid}", RemoveOverrideAsync) + .RequireContentAccess(WorkspaceRole.Editor, Scopes.InfrastructureWrite) + .AddEndpointFilter(); + group.MapPost("/inventory/snapshots", SynchronizeAsync) + .RequireContentAccess(WorkspaceRole.Administrator, Scopes.InfrastructureWrite) + .AddEndpointFilter() + .AddEndpointFilter(); + group.MapPost("/inventory/curated-package", ImportCuratedPackageAsync) + .RequireContentAccess(WorkspaceRole.Administrator, Scopes.InfrastructureWrite) + .AddEndpointFilter() + .AddEndpointFilter(); + + return app; + } + + private static async Task 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 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 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 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 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()); + } + + 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(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 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 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 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(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 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 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(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 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 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(service.Health, "health"), service.ImageReference, service.ImageDigest, service.RuntimeUser, + service.ReadOnlyRootFilesystem, service.NoNewPrivileges, service.Privileged, + ParseEnum(service.Confidence, "confidence"), + (service.Components ?? []).Select(item => new InventoryComponentInput( + item.SourceKey, item.Name, item.Kind, item.RuntimeId, item.Status, item.ImageReference, + ParseEnum(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(item.Confidence, "endpoint confidence"))).ToArray(), + (service.Networks ?? []).Select(item => new InventoryNetworkInput( + item.SourceKey, item.Name, item.Driver, item.Scope, item.Internal, + ParseEnum(item.Confidence, "network confidence"))).ToArray(), + (service.Volumes ?? []).Select(item => new InventoryVolumeInput( + item.SourceKey, item.Name, item.MountPath, item.StorageClass, item.ReadOnly, item.SizeBytes, + ParseEnum(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(item.Confidence, "database confidence"))).ToArray(), + (service.Dependencies ?? []).Select(item => new InventoryDependencyInput( + item.SourceKey, item.DependsOnSourceKey, item.Kind, + ParseEnum(item.Confidence, "dependency confidence"))).ToArray(), + (service.HealthObservations ?? []).Select(item => new InventoryHealthInput( + item.SourceKey, ParseEnum(item.State, "health state"), item.CheckKind, + item.ResponseTimeMilliseconds, ParseEnum(item.Confidence, "health confidence"))).ToArray(), + (service.Deployments ?? []).Select(item => new InventoryDeploymentInput( + item.SourceKey, item.RepositoryUrl, item.Commit, item.ImageReference, item.ImageDigest, item.DeployedAt, + ParseEnum(item.Confidence, "deployment confidence"))).ToArray(), + (service.Backups ?? []).Select(item => new InventoryBackupInput( + item.SourceKey, item.Method, item.Schedule, item.LastSuccessfulAt, item.RestoreTested, + ParseEnum(item.Confidence, "backup confidence"))).ToArray()); + + private static T ParseEnum(string value, string field) where T : struct, Enum + { + if (!Enum.TryParse(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(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 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; +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/IngestionProposalsEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/IngestionProposalsEndpoints.cs new file mode 100644 index 0000000..23ea029 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/IngestionProposalsEndpoints.cs @@ -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().AddEndpointFilter(); + group.MapPost("/{proposalId:guid}/sources", UploadSourceAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite) + .RequireRateLimiting(RateLimitPolicies.SourceUpload) + .AddEndpointFilter().AddEndpointFilter().DisableAntiforgery(); + group.MapPost("/{proposalId:guid}/submit", SubmitManualAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite) + .AddEndpointFilter(); + 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(); + group.MapPost("/{proposalId:guid}/request-changes", RequestChangesAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview) + .AddEndpointFilter(); + group.MapPost("/{proposalId:guid}/approve", ApproveAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview) + .AddEndpointFilter(); + group.MapPost("/{proposalId:guid}/reject", RejectAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview) + .AddEndpointFilter(); + group.MapPost("/{proposalId:guid}/retry", RetryAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite) + .AddEndpointFilter().AddEndpointFilter(); + group.MapPost("/{proposalId:guid}/publish", PublishAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview) + .AddEndpointFilter().AddEndpointFilter(); + return app; + } + + private static async Task CreateAsync([FromBody] CreateIngestionProposalRequest request, IWorkspaceRepository workspaces, + CreateIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IngestionMetrics metrics, IOptions 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(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 ListAsync(IWorkspaceRepository workspaces, IWorkspaceMembershipRepository memberships, + IIngestionProposalRepository proposals, + IOptions 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 GetAsync(Guid proposalId, IWorkspaceRepository workspaces, IWorkspaceMembershipRepository memberships, + IIngestionProposalRepository proposals, + IOptions 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 PatchAsync(Guid proposalId, [FromBody] PatchIngestionProposalRequest request, IWorkspaceRepository workspaces, + PatchIngestionProposalUseCase useCase, ApplyIngestionProposalAnalysisUseCase analysisUseCase, + IWorkspaceMembershipRepository memberships, IIngestionProposalRepository proposals, IOptions 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 RequestChangesAsync(Guid proposalId, [FromBody] IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces, + DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) => + DecideAsync(IngestionDecision.RequestChanges, proposalId, request, workspaces, useCase, proposals, options, principal, context, ct); + private static Task ApproveAsync(Guid proposalId, [FromBody] IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces, + DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) => + DecideAsync(IngestionDecision.Approve, proposalId, request, workspaces, useCase, proposals, options, principal, context, ct); + private static Task RejectAsync(Guid proposalId, [FromBody] IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces, + DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) => + DecideAsync(IngestionDecision.Reject, proposalId, request, workspaces, useCase, proposals, options, principal, context, ct); + + private static async Task DecideAsync(IngestionDecision decision, Guid proposalId, IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces, + DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions 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().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 RetryAsync(Guid proposalId, IWorkspaceRepository workspaces, RetryIngestionProposalUseCase useCase, + IIngestionProposalRepository proposals, IOptions 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 PublishAsync(Guid proposalId, IWorkspaceRepository workspaces, + PublishIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IngestionMetrics metrics, + IOptions 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 UploadSourceAsync(Guid proposalId, IFormFile file, IWorkspaceRepository workspaces, + StageIngestionProposalSourceUseCase useCase, IIngestionProposalRepository proposals, IngestionMetrics metrics, + IOptions 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 SubmitManualAsync(Guid proposalId, IWorkspaceRepository workspaces, + SubmitManualIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, + IOptions 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 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 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 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(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"; +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/PagesEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/PagesEndpoints.cs new file mode 100644 index 0000000..ba24c38 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/PagesEndpoints.cs @@ -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() + .AddEndpointFilter(); + + // 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() + .AddEndpointFilter(); + + group.MapPost("/staged/{sessionId:guid}/commit", CommitPageCreationSessionAsync) + .RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite) + .AddEndpointFilter() + .AddEndpointFilter(); + + group.MapPost("/staged/{sessionId:guid}/discard", DiscardPageCreationSessionAsync) + .RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite) + .AddEndpointFilter() + .AddEndpointFilter(); + + group.MapPut("/{pageId:guid}", UpdatePageAsync) + .RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite) + .AddEndpointFilter() + .AddEndpointFilter(); + + group.MapPost("/{pageId:guid}/move", MovePageAsync) + .RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite) + .AddEndpointFilter() + .AddEndpointFilter(); + + group.MapPost("/{pageId:guid}/trash", TrashPageAsync) + .RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite) + .AddEndpointFilter(); + + group.MapPost("/{pageId:guid}/restore", RestorePageAsync) + .RequireAuthorization(AuthorizationPolicies.RequireEditor) + .AddEndpointFilter(); + + group.MapPost("/{pageId:guid}/revisions/{revisionNumber:long}/restore", RestoreRevisionAsync) + .RequireAuthorization(AuthorizationPolicies.RequireEditor) + .AddEndpointFilter(); + + return app; + } + + private static async Task> 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()))); + } + + 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>, 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 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, 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, 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(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, 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 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, 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, 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, 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, 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(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, 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, 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(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, 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, 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(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, 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(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> 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(result.Status, result.Errors); + } + + private static async Task> 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(result.Status, result.Errors); + } + + private static ProblemHttpResult ToProblem(PageOperationStatus status, IReadOnlyList 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)!); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ProjectsEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ProjectsEndpoints.cs new file mode 100644 index 0000000..3d7487e --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ProjectsEndpoints.cs @@ -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() + .AddEndpointFilter(); + + group.MapPut("/{projectId:guid}", UpdateAsync) + .RequireContentAccess(WorkspaceRole.Editor, Scopes.ProjectsWrite) + .AddEndpointFilter() + .AddEndpointFilter(); + + group.MapPost("/{projectId:guid}/icon", SetIconAsync) + .RequireContentAccess(WorkspaceRole.Editor, Scopes.ProjectsWrite) + .AddEndpointFilter() + .DisableAntiforgery() + .WithMetadata(new RequestSizeLimitAttribute(SetProjectIconUseCase.MaximumIconBytes + 64 * 1024)); + + group.MapPost("/{projectId:guid}/status", ChangeStatusAsync) + .RequireAuthorization(AuthorizationPolicies.RequireEditor) + .AddEndpointFilter(); + + group.MapPost("/{projectId:guid}/documents", LinkDocumentAsync) + .RequireAuthorization(AuthorizationPolicies.RequireEditor) + .AddEndpointFilter(); + + group.MapDelete("/documents/{documentId:guid}", UnlinkDocumentAsync) + .RequireAuthorization(AuthorizationPolicies.RequireEditor) + .AddEndpointFilter(); + + group.MapPost("/{projectId:guid}/updates", CreateUpdateAsync) + .RequireContentAccess(WorkspaceRole.Editor, Scopes.ProjectUpdatesAppend) + .AddEndpointFilter() + .AddEndpointFilter(); + + group.MapPost("/{projectId:guid}/milestones", CreateMilestoneAsync) + .RequireAuthorization(AuthorizationPolicies.RequireEditor) + .AddEndpointFilter(); + + group.MapPut("/milestones/{milestoneId:guid}", UpdateMilestoneAsync) + .RequireAuthorization(AuthorizationPolicies.RequireEditor) + .AddEndpointFilter(); + + return app; + } + + private static async Task> 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> 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, 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, 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, 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(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, 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, 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(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, 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(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, 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(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> 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(result.Status, result.Errors); + } + + private static async Task> 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> 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> 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>> GetMilestonesAsync( + Guid projectId, GetProjectMilestonesUseCase useCase, IProjectRepository projects, CancellationToken cancellationToken) + { + var milestones = await useCase.ExecuteAsync(projectId, cancellationToken); + var summaries = new List(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>(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, 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(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(OperationStatus status, IReadOnlyList 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 Items, string? NextCursor); diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/RagPublicationEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/RagPublicationEndpoints.cs new file mode 100644 index 0000000..97dc53f --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/RagPublicationEndpoints.cs @@ -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; + +/// +/// A single, deliberately narrow read-only endpoint for RAGcore's n8n sync +/// (docs/32-rag-publication-convention.md) — reuses the existing Reader/ +/// pages:read content-access policy, no new scope. Not a general +/// GET /pages enumeration: only pages explicitly opted in via the +/// rag:publish tag are ever returned. +/// +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, 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 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)); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/RunbooksEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/RunbooksEndpoints.cs new file mode 100644 index 0000000..82beaf0 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/RunbooksEndpoints.cs @@ -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() + .AddEndpointFilter(); + + group.MapPost("/{runbookId:guid}/validate", ValidateAsync) + .RequireAuthorization(AuthorizationPolicies.RequireEditor) + .AddEndpointFilter(); + + group.MapPut("/{runbookId:guid}/profile", UpdateProfileAsync) + .RequireAuthorization(AuthorizationPolicies.RequireEditor) + .AddEndpointFilter(); + + return app; + } + + private static async Task> 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(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, 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, 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, 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, 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 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)!); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SearchEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SearchEndpoints.cs new file mode 100644 index 0000000..d9db901 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SearchEndpoints.cs @@ -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, 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 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)); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ServiceAccountsEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ServiceAccountsEndpoints.cs new file mode 100644 index 0000000..a5f304e --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ServiceAccountsEndpoints.cs @@ -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; + +/// +/// 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. +/// +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(); + + group.MapPost("/{serviceAccountId:guid}/tokens", IssueTokenAsync) + .AddEndpointFilter(); + + group.MapPost("/tokens/{tokenId:guid}/revoke", RevokeTokenAsync) + .AddEndpointFilter(); + + return app; + } + + private static async Task> 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, 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, 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> 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)!); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SetupEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SetupEndpoints.cs new file mode 100644 index 0000000..39ec34d --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SetupEndpoints.cs @@ -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() + .RequireRateLimiting(RateLimitPolicies.Setup); + + return app; + } + + private static async Task> GetStatusAsync( + IInstallationRepository installations, + CancellationToken cancellationToken) + { + var complete = await installations.IsSetupCompleteAsync(cancellationToken); + return TypedResults.Ok(new SetupStatusResponse(!complete)); + } + + private static async Task, 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)) + }; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SourceBatchPurgeEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SourceBatchPurgeEndpoints.cs new file mode 100644 index 0000000..25423db --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SourceBatchPurgeEndpoints.cs @@ -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; + +/// +/// 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. +/// +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(); + group.MapPost("/execute", ExecuteAsync).AddEndpointFilter(); + return app; + } + + private static async Task> 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, 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); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/TemplatesEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/TemplatesEndpoints.cs new file mode 100644 index 0000000..6ac93ce --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/TemplatesEndpoints.cs @@ -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> ListTemplates(ITemplateProvider templates) + { + IReadOnlyList response = templates.ListTemplates() + .Select(template => new TemplateSummary( + template.Key, template.Name, template.DocumentType.ToString(), template.Description)) + .ToList(); + + return TypedResults.Ok(response); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/WorkspaceEndpoints.cs b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/WorkspaceEndpoints.cs new file mode 100644 index 0000000..bcaba44 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Endpoints/WorkspaceEndpoints.cs @@ -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; + +/// 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). +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> 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())); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj b/src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj new file mode 100644 index 0000000..35869ec --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj @@ -0,0 +1,12 @@ + + + ItWorx.KnowledgeHub.Api + ItWorx.KnowledgeHub.Api + false + + + + + + + diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Knowledge/ETagHelper.cs b/src/backend/ItWorx.KnowledgeHub.Api/Knowledge/ETagHelper.cs new file mode 100644 index 0000000..4489d24 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Knowledge/ETagHelper.cs @@ -0,0 +1,13 @@ +namespace ItWorx.KnowledgeHub.Api.Knowledge; + +/// HTTP ETags are quoted strings; the concurrency token we compare against +/// (PostgreSQL's xmin) is a bare number. This centralizes the quoting/unquoting +/// so every endpoint agrees on the wire format (docs/09-api-contract.md — "strong ETag +/// plus If-Match"). +public static class ETagHelper +{ + public static string ToHeaderValue(string etag) => $"\"{etag}\""; + + public static string? FromHeaderValue(string? headerValue) => + string.IsNullOrEmpty(headerValue) ? null : headerValue.Trim().Trim('"'); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Operations/RestoreModeRunner.cs b/src/backend/ItWorx.KnowledgeHub.Api/Operations/RestoreModeRunner.cs new file mode 100644 index 0000000..27023fa --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Operations/RestoreModeRunner.cs @@ -0,0 +1,69 @@ +using ItWorx.KnowledgeHub.Application.Operations; +using Microsoft.Extensions.DependencyInjection; + +namespace ItWorx.KnowledgeHub.Api.Operations; + +/// +/// Restore runs the same deployable image in a distinct maintenance-mode invocation +/// (dotnet ItWorx.KnowledgeHub.Api.dll --restore ...), never as an HTTP endpoint +/// on the live, traffic-serving application (docs/18-backup-restore.md: "Restore is +/// deliberately separate from normal application startup"; ADR-016 decision 4). The +/// core logic here takes no direct dependency on Environment.Exit/process +/// termination so it stays directly unit-testable; only Program.cs's thin top-level +/// wrapper turns the returned exit code into an actual process exit. +/// +public static class RestoreModeRunner +{ + public const string Flag = "--restore"; + + public static bool IsRestoreMode(string[] args) => args.Contains(Flag, StringComparer.Ordinal); + + public static async Task RunAsync(string[] args, IServiceProvider services, TextWriter output, CancellationToken cancellationToken) + { + var backupId = GetArgValue(args, "--backup-id"); + var targetConnection = GetArgValue(args, "--target-connection"); + var targetAttachments = GetArgValue(args, "--target-attachments"); + + if (backupId is null || targetConnection is null || targetAttachments is null) + { + await output.WriteLineAsync( + "Gebruik: --restore --backup-id= --target-connection= --target-attachments="); + return 2; + } + + var restoreService = services.GetRequiredService(); + RestoreReport report; + try + { + report = await restoreService.RestoreAsync( + backupId, new RestoreTarget(targetConnection, targetAttachments), cancellationToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + await output.WriteLineAsync($"Herstel mislukt: {exception.Message}"); + return 1; + } + + await output.WriteLineAsync($"Herstel van back-up '{report.BackupId}': {(report.Success ? "GESLAAGD" : "MISLUKT")}"); + foreach (var table in report.Tables) + { + await output.WriteLineAsync( + $" {table.TableName}: {table.RestoredRowCount}/{table.ExpectedRowCount} rijen{(table.Matches ? string.Empty : " (AFWIJKING)")}"); + } + + await output.WriteLineAsync($" bijlagen: {report.AttachmentsRestored}/{report.AttachmentsExpected}"); + foreach (var warning in report.Warnings) + { + await output.WriteLineAsync($" WAARSCHUWING: {warning}"); + } + + return report.Success ? 0 : 1; + } + + private static string? GetArgValue(string[] args, string name) + { + var prefix = name + "="; + var match = args.FirstOrDefault(argument => argument.StartsWith(prefix, StringComparison.Ordinal)); + return match?[prefix.Length..]; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Program.cs b/src/backend/ItWorx.KnowledgeHub.Api/Program.cs new file mode 100644 index 0000000..30ff961 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Program.cs @@ -0,0 +1,608 @@ +using System.Diagnostics.Metrics; +using System.Reflection; +using System.Threading.RateLimiting; +using ItWorx.KnowledgeHub.Api.Authorization; +using ItWorx.KnowledgeHub.Api.Endpoints; +using ItWorx.KnowledgeHub.Api.Operations; +using ItWorx.KnowledgeHub.Api.Security; +using ItWorx.KnowledgeHub.Application.Attachments.UseCases; +using ItWorx.KnowledgeHub.Application.Automation.UseCases; +using ItWorx.KnowledgeHub.Application.Decisions.UseCases; +using ItWorx.KnowledgeHub.Application.Identity; +using ItWorx.KnowledgeHub.Application.Identity.UseCases; +using ItWorx.KnowledgeHub.Application.Infrastructure.UseCases; +using ItWorx.KnowledgeHub.Application.Ingestion; +using ItWorx.KnowledgeHub.Application.Ingestion.UseCases; +using ItWorx.KnowledgeHub.Application.Knowledge.UseCases; +using ItWorx.KnowledgeHub.Application.Migration.UseCases; +using ItWorx.KnowledgeHub.Application.Operations.UseCases; +using ItWorx.KnowledgeHub.Application.Projects.UseCases; +using ItWorx.KnowledgeHub.Application.Runbooks.UseCases; +using ItWorx.KnowledgeHub.Contracts.SystemInfo; +using ItWorx.KnowledgeHub.Domain.Automation; +using ItWorx.KnowledgeHub.Domain.Identity; +using ItWorx.KnowledgeHub.Infrastructure; +using ItWorx.KnowledgeHub.Infrastructure.Operations; +using ItWorx.KnowledgeHub.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; + +var timeProvider = TimeProvider.System; +var startedAt = timeProvider.GetUtcNow(); +var builder = WebApplication.CreateBuilder(args); + +// The attachment use case accepts files up to 100 MiB. Kestrel's default request +// limit is only about 30 MB, which would terminate valid multipart uploads before +// they reach that policy. Keep the transport bounded while reserving one MiB for +// multipart headers and framing around the maximum-size file. +builder.WebHost.ConfigureKestrel(options => + options.Limits.MaxRequestBodySize = 101L * 1024 * 1024); + +// Structured JSON logs to stdout/stderr in production for container log collection +// (docs/19-observability.md "Logging"). IncludeScopes surfaces the correlation ID +// CorrelationIdMiddleware attaches to every request. Framework categories are +// deliberately quieted relative to the app's own (Information stays default for +// everything else) — Microsoft.AspNetCore/EF Core's per-request/per-query chatter at +// Information level would otherwise dominate the log stream without adding +// operator value beyond what the correlation-scoped app logs already provide. +if (builder.Environment.IsProduction()) +{ + builder.Logging.ClearProviders(); + builder.Logging.AddJsonConsole(options => options.IncludeScopes = true); + builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning); + builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning); +} + +builder.Services.AddSingleton(timeProvider); +builder.Services.AddSingleton(_ => new Meter("ItWorx.KnowledgeHub.Ingestion")); +builder.Services.AddSingleton(); +builder.Services.AddProblemDetails(options => +{ + options.CustomizeProblemDetails = context => + { + if (context.HttpContext.Items.TryGetValue(CorrelationIdMiddleware.ItemKey, out var correlationId) + && correlationId is string correlationIdValue) + { + context.ProblemDetails.Extensions["correlationId"] = correlationIdValue; + } + }; +}); + +// Health checks (DatabaseHealthCheck, StorageHealthCheck) are registered inside +// AddKnowledgeHubInfrastructure, since both depend on Infrastructure-layer types. +builder.Services.AddKnowledgeHubInfrastructure(builder.Configuration); +builder.Services.AddOptions() + .BindConfiguration(IngestionProposalOptions.SectionName); + +// Without this, ASP.NET Core falls back to an ephemeral in-memory key repository +// (logged as a warning), which silently invalidates every session cookie, CSRF token +// and password-reset token on every container restart/redeploy. Keys persist to a +// dedicated mounted volume, outside the read-only application root +// (docs/12-auth-security.md — "data-protection keys persist outside the read-only +// root filesystem"; see docker-compose.yml's knowledge-hub-keys volume). +var keysPath = builder.Configuration["KnowledgeHub:Paths:Keys"] + ?? Path.Combine(AppContext.BaseDirectory, "keys-data"); +Directory.CreateDirectory(keysPath); +builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(keysPath)); + +// --- Authentication (cookie) ------------------------------------------------------- +var authenticationBuilder = builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme; + options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ApplicationScheme; +}); + +// AddIdentityCookies (rather than a single AddCookie for only the application scheme) +// also registers the External/TwoFactorRememberMe/TwoFactorUserId cookie handlers that +// ASP.NET Core Identity's SignInManager.SignOutAsync() unconditionally signs out of — +// even though this product does not use external logins or two-factor authentication — +// so that a security-stamp-triggered forced sign-out does not itself throw. +authenticationBuilder.AddIdentityCookies(identityCookies => identityCookies.ApplicationCookie!.Configure(options => +{ + options.Cookie.Name = "ikh_auth"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + // Upgraded to effectively "Always" once trusted reverse-proxy forwarded headers + // mark the request as HTTPS (see ForwardedHeaders configuration below). + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + // Configurable only so integration tests can prove the expiry behavior + // deterministically and quickly, without waiting out the real 12-hour production + // lifetime; the value is not intended to be tuned per deployment. + var expireSeconds = builder.Configuration.GetValue("KnowledgeHub:Auth:CookieExpireSeconds"); + options.ExpireTimeSpan = expireSeconds is { } seconds + ? TimeSpan.FromSeconds(seconds) + : TimeSpan.FromHours(12); + options.SlidingExpiration = false; + options.Events.OnRedirectToLogin = context => + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + }; + options.Events.OnRedirectToAccessDenied = context => + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + }; + // Without this, ASP.NET Core Identity's security-stamp rotation (on password reset + // or SetEnabledAsync's UpdateSecurityStampAsync call) would only be checked lazily + // — this wires it into the cookie pipeline so it actually runs, combined with + // ValidationInterval = TimeSpan.Zero below for "every request" enforcement. + options.Events.OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync; +})); + +// A second, additional scheme for scoped service-account bearer tokens +// (docs/09-api-contract.md "Service account") — registered alongside, never instead +// of, the cookie schemes above; endpoints opt in to accepting it via +// RequireContentAccess (ContentAccessEndpointExtensions), never as the default. +authenticationBuilder.AddScheme( + ServiceTokenAuthenticationDefaults.Scheme, _ => { }); + +// Validate the security stamp on every request (not only every N minutes) so disabling +// a user or resetting their password invalidates existing sessions immediately. +builder.Services.Configure(options => +{ + options.ValidationInterval = TimeSpan.Zero; +}); + +// --- Authorization ------------------------------------------------------------------- +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddAuthorizationBuilder() + .AddPolicy(AuthorizationPolicies.RequireReader, policy => + policy.Requirements.Add(new WorkspaceRoleRequirement(WorkspaceRole.Reader))) + .AddPolicy(AuthorizationPolicies.RequireEditor, policy => + policy.Requirements.Add(new WorkspaceRoleRequirement(WorkspaceRole.Editor))) + .AddPolicy(AuthorizationPolicies.RequireAdministrator, policy => + policy.Requirements.Add(new WorkspaceRoleRequirement(WorkspaceRole.Administrator))) + .AddPolicy(AuthorizationPolicies.IngestionWrite, policy => + { + policy.AddAuthenticationSchemes( + IdentityConstants.ApplicationScheme, + ServiceTokenAuthenticationDefaults.Scheme); + policy.RequireAuthenticatedUser(); + policy.Requirements.Add(new ContentAccessRequirement(WorkspaceRole.Editor, Scopes.IngestionWrite)); + }) + .AddPolicy(AuthorizationPolicies.IngestionReview, policy => + { + // Authenticate both supported schemes so a valid service token is denied with + // 403 rather than being mistaken for an anonymous caller. Review itself is + // still reserved for a human cookie session, including when a principal ever + // contains claims from multiple identities. + policy.AddAuthenticationSchemes( + IdentityConstants.ApplicationScheme, + ServiceTokenAuthenticationDefaults.Scheme); + policy.RequireAuthenticatedUser(); + policy.RequireAssertion(context => !context.User.HasClaim( + claim => claim.Type == ServiceTokenAuthenticationHandler.ServiceAccountClaimType)); + policy.Requirements.Add(new WorkspaceRoleRequirement(WorkspaceRole.Editor)); + }); + +// --- Anti-forgery (CSRF) --------------------------------------------------------------- +builder.Services.AddAntiforgery(options => +{ + options.HeaderName = "X-CSRF-Token"; + options.Cookie.Name = "ikh_csrf"; + // Must be readable by the SPA so it can echo the value back in the header; the + // value is not a session credential by itself (double-submit pattern). + options.Cookie.HttpOnly = false; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; +}); + +// --- Rate limiting --------------------------------------------------------------------- +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + options.AddPolicy(RateLimitPolicies.Login, context => + CreateRemoteIpPartition(context, permitLimit: 10)); + options.AddPolicy(RateLimitPolicies.Setup, context => + CreateRemoteIpPartition(context, permitLimit: 5)); + options.AddPolicy(RateLimitPolicies.Recovery, context => + CreateRemoteIpPartition(context, permitLimit: 10)); + options.AddPolicy(RateLimitPolicies.SourceUpload, context => + CreateRemoteIpPartition(context, permitLimit: 5)); +}); + +// --- Use cases (composition root) ------------------------------------------------------- +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// --- Forwarded headers (reverse proxy) --------------------------------------------------- +// Deliberately a single semicolon-delimited string, matching the existing AllowedHosts +// convention in this same configuration surface, rather than a bound string[] array — a +// single KnowledgeHub__TrustedProxies environment variable is far easier to wire through +// Docker Compose than N indexed KnowledgeHub__TrustedProxies__0/__1/... entries, and no +// production topology needs more than a couple of entries (docs/23-deployment-unraid.md +// "Reverse proxy"). Left unset, this defaults to empty and no forwarded header is ever +// honored — see EVIDENCE.md "Production configuration" (item 22). +// +// Resolved via AddOptions().Configure(...) — a lazy DI factory +// evaluated at options-resolution time — rather than reading builder.Configuration[...] +// eagerly into a captured local here. A prior milestone hit the exact same class of bug +// for DatabaseConnectionOptions (docs/adr/ADR-016-m7-operations-backup-and-unraid.md; +// EVIDENCE.md "M7 — real defects"): WebApplicationFactory-based test configuration +// overrides only attach to IConfiguration once the host is actually built, which happens +// *after* top-level Program.cs statements like a plain eager read already ran — so an +// eager read here would silently keep using the pre-override (empty) value in every +// ForwardedHeadersTrustTests case, exactly like that prior bug did. +builder.Services.AddOptions() + .Configure((options, configuration) => + { + var trustedProxies = (configuration["KnowledgeHub:TrustedProxies"] ?? string.Empty) + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(entry => System.Net.IPAddress.TryParse(entry, out var address) ? address : null) + .Where(address => address is not null) + .Select(address => address!) + .ToArray(); + + // ASP.NET Core's ForwardedHeadersMiddleware only enforces its KnownProxies/ + // KnownIPNetworks allow-list when that list is non-empty ("checkKnownIps"); an + // *empty* list does not mean "trust nobody", it means "skip the IP check entirely + // and trust every caller" — the opposite of what clearing the built-in + // loopback-only default looks like it should do. Found by a real integration test + // (ForwardedHeadersTrustTests) asserting the untrusted case, not by inspection: an + // unconfigured deployment (every deployment before today, since this environment + // variable did not previously exist) was silently honoring a self-reported + // X-Forwarded-For/X-Forwarded-Proto from *any* direct caller — letting a client + // spoof its own audited/rate-limited IP with no reverse proxy involved at all. + // Fixed by disabling the middleware outright (ForwardedHeaders.None) whenever no + // trusted proxy is configured, instead of relying on an empty allow-list. + options.ForwardedHeaders = trustedProxies.Length > 0 + ? Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor + | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto + : Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.None; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); + + foreach (var address in trustedProxies) + { + options.KnownProxies.Add(address); + } + }); + +var app = builder.Build(); + +// Maintenance-mode restore invocation of the same deployable image — never reachable +// through the normal HTTP pipeline (docs/18-backup-restore.md; ADR-016 decision 4). +// Exits immediately, before any web host, migration or bootstrap logic runs. +if (RestoreModeRunner.IsRestoreMode(args)) +{ + var restoreExitCode = await RestoreModeRunner.RunAsync(args, app.Services, Console.Out, CancellationToken.None); + Environment.Exit(restoreExitCode); +} + +// A second maintenance-mode invocation: apply only the schema-drift guard and +// pending migrations against a restore target, deliberately WITHOUT the ordinary +// startup's installation-row side effect below. docs/18-backup-restore.md +// step 6 is "Start the recorded/current compatible application in maintenance mode" +// specifically so the target is migrated but otherwise untouched before restore's own +// "clean target" check runs — a normal startup lazily creates the singleton +// installation row as a side effect of IInstallationRepository.IsSetupCompleteAsync, +// which would then make every restore rehearsal fail that check. Found via an actual +// restore rehearsal against a real freshly-migrated target, not by inspection. +if (args.Contains("--migrate-only", StringComparer.Ordinal)) +{ + using (var migrateScope = app.Services.CreateScope()) + { + var migrateDb = migrateScope.ServiceProvider.GetRequiredService(); + await SchemaGuard.EnsureCompatibleAsync(migrateDb.Database, CancellationToken.None); + await migrateDb.Database.MigrateAsync(); + } + + Console.Out.WriteLine("Migraties toegepast."); + Environment.Exit(0); +} + +if (!app.Environment.IsEnvironment("Test")) +{ + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // M7-A02: refuse to start against a database whose applied migration history is + // ahead of what this build knows about, before ever attempting MigrateAsync. + await SchemaGuard.EnsureCompatibleAsync(db.Database, CancellationToken.None); + await db.Database.MigrateAsync(); + + +} + +if (app.Environment.IsEnvironment("Test") || app.Environment.IsEnvironment("IntegrationTest")) +{ + // TestServer never simulates a real socket, so Connection.RemoteIpAddress is null + // unless something sets it. This lets integration tests provide a deterministic + // source address for trusted-proxy and client-scoped rate-limit checks. It is only + // enabled in test environments. + app.Use(async (context, next) => + { + if (context.Request.Headers.TryGetValue("X-Test-Remote-Ip", out var testIp) + && System.Net.IPAddress.TryParse(testIp.ToString(), out var parsedIp)) + { + context.Connection.RemoteIpAddress = parsedIp; + } + + await next(); + }); +} + +app.UseForwardedHeaders(); +app.UseExceptionHandler(); +app.UseMiddleware(); +app.UseMiddleware(); + +app.UseDefaultFiles(); +app.UseStaticFiles(); + +app.UseRateLimiter(); + +// Must run before model binding ever reads the request body, so IdempotencyEndpointFilter +// (which re-reads the body after argument binding has already happened) can seek back to +// the start — EnableBuffering only makes the *first* read seekable if called before that +// first read occurs. Guarded to requests that actually carry the header, so ordinary +// requests are never buffered into memory unnecessarily. +app.Use(async (context, next) => +{ + if (context.Request.Headers.ContainsKey("Idempotency-Key")) + { + context.Request.EnableBuffering(); + } + + await next(); +}); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapGet( + "/health/live", + () => Results.Ok(new + { + status = "healthy", + product = "ItWorx Knowledge Hub" + })); + +// Deliberately no custom ResponseWriter: the default health check middleware writes +// only the plain-text overall status ("Healthy"/"Unhealthy"), never individual check +// descriptions or exception detail (docs/19-observability.md "/health/ready": "Return +// minimal public details. Detailed diagnostics require administrator +// authentication.") — see the admin-only /api/v1/system/info below for the +// authenticated diagnostic surface. +// Readiness follows only the Knowledge Hub's required local dependencies. Trusted +// intake is optional, and its separate MCP deployment must not make ordinary page +// operations unready when unavailable. +app.MapHealthChecks("/health/ready", new HealthCheckOptions +{ + Predicate = check => check.Name is "database" or "storage" +}); + +// Permissioned information (docs/19-observability.md "/api/v1/system/info") — unlike +// /health/live and /health/ready (which must stay anonymous so an orchestrator can +// probe them), this reports product/schema/version detail and therefore requires +// administrator authentication (docs/23-deployment-unraid.md "Operational checks" +// separately lists "authenticated system info" from the two health endpoints). +app.MapGet( + "/api/v1/system/info", + async ( + IHostEnvironment environment, + IConfiguration configuration, + ReleaseManifestReader releaseManifestReader, + KnowledgeHubDbContext db, + CancellationToken cancellationToken) => + { + var assembly = Assembly.GetExecutingAssembly(); + var version = assembly.GetName().Version?.ToString() ?? "0.0.0"; + var commit = configuration["Build:Commit"] ?? "development"; + var locale = configuration["KnowledgeHub:PrimaryLocale"] ?? "nl-BE"; + var timeZone = configuration["KnowledgeHub:TimeZone"] ?? "Europe/Brussels"; + + var uptimeSeconds = Math.Max( + 0, + (long)(timeProvider.GetUtcNow() - startedAt).TotalSeconds); + + // The database migration range is the one release-identity field only the + // *running instance itself* can know (it depends on which database it is + // actually talking to, not on how the image was built) — computed live, + // never baked into the static build-time manifest + // (docs/adr/ADR-029-v2-r5-final-hardening.md). + string? migrationRange = null; + try + { + var applied = (await db.Database.GetAppliedMigrationsAsync(cancellationToken)).ToList(); + if (applied.Count > 0) + { + migrationRange = applied.Count == 1 ? applied[0] : $"{applied[0]}..{applied[^1]}"; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Never let a migration-history query failure break the whole endpoint — + // the rest of the release identity is still worth returning. + } + + var manifest = releaseManifestReader.Read(); + var release = new ReleaseIdentity( + manifest?.SemanticVersion, + manifest?.ReleaseTag, + manifest?.SourceCommit ?? (commit == "development" ? null : commit), + manifest?.ImageRepository, + manifest?.ImageTag, + manifest?.ImageId, + manifest?.ImageDigest, + manifest?.BuildTimestamp, + manifest?.DeploymentTimestamp, + manifest?.OpenApiVersion, + manifest?.OpenApiHash, + manifest?.FrontendBuildHash, + migrationRange, + manifest?.InventorySnapshotId, + manifest?.BackupId); + + return Results.Ok( + new SystemInfoResponse( + "ItWorx Knowledge Hub", + version, + commit, + "m7", + environment.EnvironmentName, + locale, + timeZone, + uptimeSeconds, + release)); + }) + .RequireAuthorization(AuthorizationPolicies.RequireAdministrator); + +app.MapSetupEndpoints(); +app.MapAuthEndpoints(); +app.MapAdminEndpoints(); +app.MapPagesEndpoints(); +app.MapDraftsEndpoints(); +app.MapTemplatesEndpoints(); +app.MapSearchEndpoints(); +app.MapRagPublicationEndpoints(); +app.MapAttachmentsEndpoints(); +app.MapIngestionProposalsEndpoints(); +app.MapImportsEndpoints(); +app.MapSourceBatchPurgeEndpoints(); +app.MapProjectsEndpoints(); +app.MapDecisionsEndpoints(); +app.MapRunbooksEndpoints(); +app.MapServiceAccountsEndpoints(); +app.MapBackupsEndpoints(); +app.MapInfrastructureEndpoints(); +app.MapActivityEndpoints(); +app.MapWorkspaceEndpoints(); + +app.MapFallbackToFile("index.html"); + +app.Run(); + +static RateLimitPartition CreateRemoteIpPartition(HttpContext context, int permitLimit) +{ + // Use the effective remote IP after UseForwardedHeaders has applied only headers + // from configured trusted proxies. Never read X-Forwarded-For directly here. + var partitionKey = context.Connection.RemoteIpAddress?.MapToIPv6().ToString() ?? "unknown"; + + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey, + _ => new FixedWindowRateLimiterOptions + { + AutoReplenishment = true, + PermitLimit = permitLimit, + QueueLimit = 0, + Window = TimeSpan.FromMinutes(1) + }); +} + +public partial class Program +{ +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Properties/launchSettings.json b/src/backend/ItWorx.KnowledgeHub.Api/Properties/launchSettings.json new file mode 100644 index 0000000..542a616 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "http://localhost:5240", + "applicationUrl": "http://localhost:5240", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Security/AntiforgeryEndpointFilter.cs b/src/backend/ItWorx.KnowledgeHub.Api/Security/AntiforgeryEndpointFilter.cs new file mode 100644 index 0000000..95930b6 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Security/AntiforgeryEndpointFilter.cs @@ -0,0 +1,38 @@ +using ItWorx.KnowledgeHub.Contracts.Errors; +using Microsoft.AspNetCore.Antiforgery; + +namespace ItWorx.KnowledgeHub.Api.Security; + +/// +/// Requires a valid anti-forgery token on cookie-authenticated mutations +/// (docs/12-auth-security.md). Requests authenticated with a bearer token (future +/// service-account API clients) never use cookies and are exempt, matching +/// "Bearer-token calls do not use cookies" in docs/09-api-contract.md. +/// +public sealed class AntiforgeryEndpointFilter(IAntiforgery antiforgery) : IEndpointFilter +{ + public async ValueTask InvokeAsync( + EndpointFilterInvocationContext context, + EndpointFilterDelegate next) + { + var httpContext = context.HttpContext; + + if (httpContext.Request.Headers.Authorization.Count == 0) + { + try + { + await antiforgery.ValidateRequestAsync(httpContext); + } + catch (AntiforgeryValidationException) + { + return Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + type: ProblemTypes.CsrfValidationFailed, + title: "CSRF-validatie mislukt.", + detail: "De aanvraag ontbreekt een geldig CSRF-token."); + } + } + + return await next(context); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Security/CorrelationIdMiddleware.cs b/src/backend/ItWorx.KnowledgeHub.Api/Security/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..2b8533d --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Security/CorrelationIdMiddleware.cs @@ -0,0 +1,27 @@ +namespace ItWorx.KnowledgeHub.Api.Security; + +/// Accepts/generates X-Correlation-ID per docs/09-api-contract.md and +/// makes it available to downstream code (audit events, problem details) via +/// , and to every structured log line written while +/// handling this request via an scope +/// (docs/19-observability.md "Logging": "correlation/request ID" is a core log +/// property). +public sealed class CorrelationIdMiddleware(RequestDelegate next, ILogger logger) +{ + public const string HeaderName = "X-Correlation-ID"; + public const string ItemKey = "CorrelationId"; + + public async Task InvokeAsync(HttpContext context) + { + var correlationId = context.Request.Headers.TryGetValue(HeaderName, out var provided) + && !string.IsNullOrWhiteSpace(provided) + ? provided.ToString() + : Guid.NewGuid().ToString(); + + context.Items[ItemKey] = correlationId; + context.Response.Headers[HeaderName] = correlationId; + + using var scope = logger.BeginScope(new Dictionary { ["CorrelationId"] = correlationId }); + await next(context); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Security/IdempotencyEndpointFilter.cs b/src/backend/ItWorx.KnowledgeHub.Api/Security/IdempotencyEndpointFilter.cs new file mode 100644 index 0000000..67454dd --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Security/IdempotencyEndpointFilter.cs @@ -0,0 +1,175 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ItWorx.KnowledgeHub.Application.Automation; +using ItWorx.KnowledgeHub.Contracts.Errors; +using ItWorx.KnowledgeHub.Domain.Automation; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace ItWorx.KnowledgeHub.Api.Security; + +/// +/// Honors an optional Idempotency-Key header on retryable create/append +/// endpoints (docs/09-api-contract.md). A retried request with the same key from the +/// same caller replays the original response verbatim instead of re-executing the +/// mutation a second time. A reused key with genuinely different request content is a +/// real conflict, not a safe retry, and is rejected. +/// +/// Deliberately does *not* take as a constructor +/// parameter: .AddEndpointFilter<T>() constructs a DI-injected filter +/// once, from the *application* root service provider, at route-table-build time — +/// not per request. A scoped service (this store wraps the per-request +/// DbContext) captured that way becomes a captive dependency, silently reused +/// and corrupted across unrelated requests (found via real integration tests failing +/// unpredictably once multiple tests ran in the same process, not by inspection). +/// Resolving it from HttpContext.RequestServices inside +/// instead gets the correct, per-request-scoped instance every time. +/// +public sealed class IdempotencyEndpointFilter(TimeProvider timeProvider) : IEndpointFilter +{ + private static readonly TimeSpan Retention = TimeSpan.FromHours(24); + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var httpContext = context.HttpContext; + if (!httpContext.Request.Headers.TryGetValue("Idempotency-Key", out var keyValues) || keyValues.Count == 0) + { + return await next(context); + } + + var key = keyValues.ToString(); + if (string.IsNullOrWhiteSpace(key) || key.Length > 200) + { + return Results.Problem(statusCode: StatusCodes.Status400BadRequest, detail: "Ongeldige Idempotency-Key."); + } + + var callerId = ResolveCallerId(httpContext.User); + if (callerId is null) + { + return await next(context); + } + + var store = httpContext.RequestServices.GetRequiredService(); + + // Minimal API argument binding ([FromBody]) already fully consumed the request + // body by the time any endpoint filter runs — the body stream's position is at + // the end. Program.cs's early EnableBuffering() (before routing) is what makes + // this stream seekable at all; rewinding to the start here, *before* this + // filter's own read, is what actually recovers the original content (a real + // bug found via IdempotencyTests: every reused key was hashing an empty body, + // so a conflicting retry was silently treated as an identical replay instead + // of being rejected). + var fingerprint = await FingerprintAsync(httpContext.Request, httpContext.RequestAborted); + + var existing = await store.FindAsync(callerId.Value, key, httpContext.RequestAborted); + if (existing is not null) + { + if (existing.RequestFingerprint != fingerprint) + { + return Results.Problem( + statusCode: StatusCodes.Status409Conflict, + type: ProblemTypes.IdempotencyKeyConflict, + title: "Idempotency-Key hergebruikt met een andere aanvraag.", + detail: "Deze Idempotency-Key is eerder gebruikt voor een andere aanvraag-inhoud."); + } + + return TypedResults.Json( + JsonSerializer.Deserialize(existing.ResponseBodyJson), statusCode: existing.ResponseStatusCode); + } + + var result = await next(context); + + // An endpoint that returns a union type (`Results, ProblemHttpResult>`, + // used by every V2-R1 staging endpoint and — found by this fix — the + // pre-existing CreatePageAsync/UpdatePageAsync) hands this filter the *union + // wrapper* instance, which itself does NOT implement IStatusCodeHttpResult — + // confirmed empirically, not assumed, via a real integration test retrying a + // staged-commit request: the wrapper's status/value surface only exists on the + // concrete result it wraps, reachable through INestedHttpResult.Result. Without + // unwrapping first, this filter silently never cached a response for any + // union-typed endpoint, so a retried mutation could re-execute for real instead + // of replaying the original response — no exception, no test failure anywhere + // else, just quietly-absent idempotency. + var concreteResult = result is INestedHttpResult nested ? nested.Result : result; + + if (concreteResult is IStatusCodeHttpResult { StatusCode: >= 200 and < 300 } statusResult + && TryExtractBody(concreteResult, out var bodyJson)) + { + var record = IdempotencyRecord.Create( + callerId.Value, key, fingerprint, statusResult.StatusCode ?? StatusCodes.Status200OK, bodyJson, + timeProvider.GetUtcNow(), Retention); + await store.SaveAsync(record, httpContext.RequestAborted); + } + + return result; + } + + private static Guid? ResolveCallerId(ClaimsPrincipal principal) + { + var raw = principal.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(raw, out var id) ? id : null; + } + + /// Reflects for a public Value property on an already-unwrapped + /// concrete result (e.g. Ok<T> — the caller unwraps any + /// union first; see its own comment for why that + /// matters). + private static bool TryExtractBody(object? concrete, out string bodyJson) + { + bodyJson = string.Empty; + var valueProperty = concrete?.GetType().GetProperty("Value"); + var value = valueProperty?.GetValue(concrete); + if (value is null) + { + return false; + } + + bodyJson = JsonSerializer.Serialize(value); + return true; + } + + private static async Task FingerprintAsync(HttpRequest request, CancellationToken cancellationToken) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + void Append(string value) => hash.AppendData(Encoding.UTF8.GetBytes(value)); + Append(request.Method + " " + request.Path + "\n"); + + // Multipart boundaries vary between retries. Hash the bound file metadata and + // bytes instead, in chunks, so a 100 MB source never becomes several 100 MB + // strings and an otherwise identical retry has the same fingerprint. + if (request.HasFormContentType) + { + var form = await request.ReadFormAsync(cancellationToken); + foreach (var field in form.OrderBy(entry => entry.Key, StringComparer.Ordinal)) + { + Append("field\n" + field.Key + "\n" + field.Value + "\n"); + } + foreach (var file in form.Files.OrderBy(file => file.Name, StringComparer.Ordinal).ThenBy(file => file.FileName, StringComparer.Ordinal)) + { + Append("file\n" + file.Name + "\n" + file.FileName + "\n" + file.ContentType + "\n" + file.Length + "\n"); + await using var fileStream = file.OpenReadStream(); + var buffer = new byte[64 * 1024]; + int count; + while ((count = await fileStream.ReadAsync(buffer, cancellationToken)) > 0) + { + hash.AppendData(buffer, 0, count); + } + } + } + else + { + request.EnableBuffering(); + request.Body.Position = 0; + var buffer = new byte[64 * 1024]; + int count; + while ((count = await request.Body.ReadAsync(buffer, cancellationToken)) > 0) + { + hash.AppendData(buffer, 0, count); + } + request.Body.Position = 0; + } + + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Security/RateLimitPolicies.cs b/src/backend/ItWorx.KnowledgeHub.Api/Security/RateLimitPolicies.cs new file mode 100644 index 0000000..a181bfd --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Security/RateLimitPolicies.cs @@ -0,0 +1,11 @@ +namespace ItWorx.KnowledgeHub.Api.Security; + +/// Named rate-limiting policies (docs/12-auth-security.md — separate isolated +/// policies per sensitive area). +public static class RateLimitPolicies +{ + public const string Setup = "setup"; + public const string Login = "login"; + public const string Recovery = "recovery"; + public const string SourceUpload = "source-upload"; +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Security/SecurityHeadersMiddleware.cs b/src/backend/ItWorx.KnowledgeHub.Api/Security/SecurityHeadersMiddleware.cs new file mode 100644 index 0000000..ae8e7bc --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Security/SecurityHeadersMiddleware.cs @@ -0,0 +1,30 @@ +namespace ItWorx.KnowledgeHub.Api.Security; + +/// Applies the baseline production security headers from +/// docs/12-auth-security.md to every response. HSTS is intentionally left to the +/// reverse proxy, which terminates TLS (see docs/23-deployment-unraid.md). +public sealed class SecurityHeadersMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context) + { + var headers = context.Response.Headers; + + headers["X-Content-Type-Options"] = "nosniff"; + headers["Referrer-Policy"] = "same-origin"; + headers["X-Frame-Options"] = "DENY"; + headers["Permissions-Policy"] = + "camera=(), microphone=(), geolocation=(), interest-cohort=()"; + headers["Content-Security-Policy"] = + "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; " + + "font-src 'self'; " + + "object-src 'none'; " + + "base-uri 'self'; " + + "form-action 'self'; " + + "frame-ancestors 'none'"; + + await next(context); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/Security/ServiceTokenFailureRateLimiter.cs b/src/backend/ItWorx.KnowledgeHub.Api/Security/ServiceTokenFailureRateLimiter.cs new file mode 100644 index 0000000..e977020 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/Security/ServiceTokenFailureRateLimiter.cs @@ -0,0 +1,27 @@ +using System.Threading.RateLimiting; + +namespace ItWorx.KnowledgeHub.Api.Security; + +/// +/// A dedicated, isolated rate-limit policy for service-token authentication failures +/// (docs/09-api-contract.md "Rate limits": "service-token authentication failures" is +/// its own named policy, separate from the general API limit) — keyed per client IP so +/// one caller repeatedly presenting an invalid/revoked token cannot exhaust a shared +/// budget for every other legitimate caller. +/// +public sealed class ServiceTokenFailureRateLimiter : IDisposable +{ + private readonly PartitionedRateLimiter _limiter = PartitionedRateLimiter.Create( + clientIp => RateLimitPartition.GetFixedWindowLimiter( + clientIp, + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 20, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + + public bool TryAcquireOnFailure(string clientIp) => _limiter.AttemptAcquire(clientIp).IsAcquired; + + public void Dispose() => _limiter.Dispose(); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/appsettings.Development.json b/src/backend/ItWorx.KnowledgeHub.Api/appsettings.Development.json new file mode 100644 index 0000000..45823ff --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "AllowedHosts": "*", + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information" + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/appsettings.json b/src/backend/ItWorx.KnowledgeHub.Api/appsettings.json new file mode 100644 index 0000000..2ea3ff4 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/appsettings.json @@ -0,0 +1,17 @@ +{ + "KnowledgeHub": { + "PrimaryLocale": "nl-BE", + "TimeZone": "Europe/Brussels", + "BaseUrl": "http://localhost:1240" + }, + "Build": { + "Commit": "development" + }, + "AllowedHosts": "localhost;127.0.0.1;[::1]", + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Api/wwwroot/index.html b/src/backend/ItWorx.KnowledgeHub.Api/wwwroot/index.html new file mode 100644 index 0000000..95c3d67 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Api/wwwroot/index.html @@ -0,0 +1,30 @@ + + + + + + + ItWorx Knowledge Hub + + + +
+
+

ItWorx

+

Knowledge Hub

+

De technische baseline draait. Claude bouwt vanaf M1 de volledige applicatie-interface.

+

Controle: /health/live en /api/v1/system/info.

+
+
+ + diff --git a/src/backend/ItWorx.KnowledgeHub.Application/AssemblyMarker.cs b/src/backend/ItWorx.KnowledgeHub.Application/AssemblyMarker.cs new file mode 100644 index 0000000..bf60711 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/AssemblyMarker.cs @@ -0,0 +1,5 @@ +namespace ItWorx.KnowledgeHub.Application; + +public static class AssemblyMarker +{ +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Attachments/AttachmentContentLock.cs b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/AttachmentContentLock.cs new file mode 100644 index 0000000..ab119e2 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/AttachmentContentLock.cs @@ -0,0 +1,91 @@ +namespace ItWorx.KnowledgeHub.Application.Attachments; + +/// +/// One process-wide, per-content-hash mutual-exclusion lock, shared by every writer of +/// the content-addressed blob store — UploadAttachmentUseCase (commit a new blob +/// or discover an existing one) and OrphanedBlobGarbageCollector (delete a blob +/// no reference points to any more). Registered as a singleton so both use cases, +/// resolved from different per-request scopes, serialize against the very same lock +/// table. +/// +/// This is what makes blob garbage collection safe against a concurrent upload of +/// byte-identical content: without it, a GC pass could delete a blob's database row and +/// physical file in between an upload's own "does this hash already exist?" check and +/// its own write, corrupting the upload's result. With both critical sections holding +/// the same per-hash lock, one of two outcomes is always consistent — either GC +/// completes first (the upload then behaves exactly like any fresh upload of new +/// content), or the upload completes first (GC's own re-check-under-lock then correctly +/// finds a live reference and skips deletion). +/// +public sealed class AttachmentContentLock +{ + private readonly object gate = new(); + private readonly Dictionary locks = []; + + public async Task AcquireAsync(string sha256, CancellationToken cancellationToken) + { + Entry entry; + lock (gate) + { + if (!locks.TryGetValue(sha256, out entry!)) + { + entry = new Entry(); + locks.Add(sha256, entry); + } + + entry.ReferenceCount += 1; + } + + try + { + await entry.Semaphore.WaitAsync(cancellationToken); + return new Lease(sha256, entry, this); + } + catch + { + Release(sha256, entry, acquired: false); + throw; + } + } + + private void Release(string sha256, Entry entry, bool acquired) + { + if (acquired) + { + entry.Semaphore.Release(); + } + + lock (gate) + { + entry.ReferenceCount -= 1; + if (entry.ReferenceCount == 0 && locks.TryGetValue(sha256, out var current) && ReferenceEquals(current, entry)) + { + locks.Remove(sha256); + entry.Semaphore.Dispose(); + } + } + } + + private sealed class Entry + { + public SemaphoreSlim Semaphore { get; } = new(1, 1); + + public int ReferenceCount { get; set; } + } + + private sealed class Lease(string sha256, Entry entry, AttachmentContentLock owner) : IDisposable + { + private bool disposed; + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + owner.Release(sha256, entry, acquired: true); + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Attachments/IAttachmentBlobStore.cs b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/IAttachmentBlobStore.cs new file mode 100644 index 0000000..a4828ac --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/IAttachmentBlobStore.cs @@ -0,0 +1,41 @@ +namespace ItWorx.KnowledgeHub.Application.Attachments; + +public sealed record QuarantinedUpload(string QuarantineToken, string Sha256, long ByteLength); + +/// +/// Physical content-addressed storage for attachment blobs +/// (docs/13-attachments.md — quarantine-then-atomic-move upload pipeline). The +/// physical path is always derived from the content hash alone, never from any +/// caller-supplied filename. +/// +public interface IAttachmentBlobStore +{ + /// Streams once into a same-filesystem + /// quarantine location while computing its SHA-256 and byte length, so a + /// subsequent commit can be a fast atomic move rather than a second full copy. + /// + Task WriteToQuarantineAsync(Stream content, CancellationToken cancellationToken); + + /// Reads a bounded prefix of the quarantined file for signature + /// detection, without loading the whole file into memory. + Task ReadQuarantinePrefixAsync(string quarantineToken, int maxBytes, CancellationToken cancellationToken); + + /// True when a blob with this hash is already stored — the caller should + /// discard the quarantined duplicate rather than move it + /// (docs/13-attachments.md — deduplication). + Task BlobExistsAsync(string sha256, CancellationToken cancellationToken); + + /// Atomically moves a quarantined file into its final content-addressed + /// location. The caller has already confirmed no blob with this hash exists. + /// + Task CommitAsync(string quarantineToken, string sha256, CancellationToken cancellationToken); + + /// Removes a quarantined file that will not be committed — every + /// failure/cancellation path must call this (docs/13-attachments.md). + Task DiscardQuarantineAsync(string quarantineToken, CancellationToken cancellationToken); + + Task OpenReadAsync(string sha256, CancellationToken cancellationToken); + + /// Verifies physical bytes against their content address and removes the blob only on a match. + Task DeleteVerifiedAsync(string sha256, CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Attachments/IAttachmentRepository.cs b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/IAttachmentRepository.cs new file mode 100644 index 0000000..e926b3f --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/IAttachmentRepository.cs @@ -0,0 +1,105 @@ +using ItWorx.KnowledgeHub.Application.Ingestion; +using ItWorx.KnowledgeHub.Domain.Attachments; + +namespace ItWorx.KnowledgeHub.Application.Attachments; + +/// Result of orphan-removal for one staging session's attachment usages +/// (see ). +/// are the content hashes of every +/// row removed by that call — each is a *candidate* +/// for physical blob garbage collection only, not yet confirmed unreferenced +/// workspace-wide (a second, possibly identical, upload elsewhere may still hold a +/// live reference to the same hash) — see OrphanedBlobGarbageCollector. +public sealed record StagingAttachmentCleanupResult(int ReferencesRemoved, IReadOnlyList CandidateBlobHashes) +{ + public static readonly StagingAttachmentCleanupResult None = new(0, []); +} + +public sealed record ProposalSourceAttachmentCleanupResult(bool UsageRemoved, bool ReferenceRemoved) +{ + public static readonly ProposalSourceAttachmentCleanupResult None = new(false, false); +} + +public interface IAttachmentRepository +{ + Task FindBlobAsync(string sha256, CancellationToken cancellationToken); + + void AddBlob(AttachmentBlob blob); + + Task FindReferenceAsync(Guid referenceId, CancellationToken cancellationToken); + + void AddReference(AttachmentReference reference); + + void AddUsage(AttachmentUsage usage); + + /// Project-owned icon usages, tracked so icon rotation can retire prior + /// references in the same unit of work as the project pointer change. + Task> GetUsagesForProjectAsync(Guid projectId, CancellationToken cancellationToken) => + Task.FromResult>([]); + + Task HasOtherUsagesAsync(Guid attachmentReferenceId, Guid excludingUsageId, CancellationToken cancellationToken) => + Task.FromResult(false); + + void RemoveUsage(AttachmentUsage usage) => throw new NotSupportedException(); + + void RemoveReference(AttachmentReference reference) => throw new NotSupportedException(); + + Task> GetReferencesForPageAsync(Guid pageId, CancellationToken cancellationToken); + + Task> FindBlobsAsync(IReadOnlyCollection sha256Hashes, CancellationToken cancellationToken); + + /// The single owning usage row for a logical reference (page/staging-session + /// ownership is always exactly one row per reference — see ). + /// Used by GetAttachmentContentUseCase to decide whether a staged (not yet + /// published) attachment's stricter authorization applies. null for an + /// orphaned reference with no usage at all (never served). + Task FindUsageForReferenceAsync(Guid attachmentReferenceId, CancellationToken cancellationToken); + + /// Of the given candidate content hashes, the subset with zero remaining + /// rows anywhere in the system — safe to physically + /// garbage-collect. Always re-check immediately before deleting (never cache), and + /// always under AttachmentContentLock for the hash, to close the + /// concurrent-upload-of-identical-content race. + Task> FindUnreferencedBlobHashesAsync(IReadOnlyCollection candidateHashes, CancellationToken cancellationToken); + + /// Removes the given rows. The caller is + /// responsible for having already confirmed (under lock) that no reference remains + /// and for the corresponding physical-file deletion. + Task RemoveBlobRecordsAsync(IReadOnlyCollection sha256Hashes, CancellationToken cancellationToken); + + /// Every usage still owned by an open page-creation session, *tracked* + /// (not AsNoTracking) so CommitPageCreationSessionUseCase can call + /// on each and have the resulting + /// UPDATEs ride the same SaveChangesAsync transaction as the new page's own + /// INSERT — the atomicity guarantee at the center of the staged-creation design + /// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md). + Task> GetUsagesForStagingSessionAsync(Guid stagingSessionId, CancellationToken cancellationToken); + + /// Stages the ownership transfer of every source belonging to a published + /// proposal. The caller's aggregate save commits these tracked changes atomically + /// with the page, revision, proposal, audit record and outbox event. + Task TransferProposalUsagesToPageAsync(Guid ingestionProposalId, Guid pageId, CancellationToken cancellationToken); + + /// Stages deletion of one stale, still unpublished proposal-owned usage. + /// The repository rechecks every ownership predicate at execution time and removes + /// its reference only if no other usage remains. It never removes physical blobs. + Task RemoveExpiredProposalSourceUsageAsync( + IngestionSourceRetentionCandidate source, + CancellationToken cancellationToken) => Task.FromResult(ProposalSourceAttachmentCleanupResult.None); + + /// Compensation for a discarded (or expired) staging session: removes + /// every usage still pointing at it, then removes any attachment reference left + /// with no remaining usage at all — the same two-step orphan-removal shape as + /// IPageRollbackRepository.RemoveOrphanedImportedAttachmentsAsync. Executes + /// immediately (bulk ExecuteDeleteAsync), independent of the caller's own + /// — deliberately not required to be atomic with + /// marking the session discarded (an orphaned-but-undiscarded session is safely + /// retryable and never a data-loss risk; the physical blob is never touched here, + /// matching AttachmentUsage's "orphan cleanup follows retention + /// separately" — see OrphanedBlobGarbageCollector for the actual physical + /// cleanup, driven by the returned ). + /// + Task RemoveOrphanedStagingAttachmentsAsync(Guid stagingSessionId, CancellationToken cancellationToken); + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Attachments/IContentSignatureDetector.cs b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/IContentSignatureDetector.cs new file mode 100644 index 0000000..6572894 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/IContentSignatureDetector.cs @@ -0,0 +1,19 @@ +using ItWorx.KnowledgeHub.Domain.Attachments; + +namespace ItWorx.KnowledgeHub.Application.Attachments; + +public sealed record SignatureDetectionResult( + string DetectedMediaType, + AttachmentDeliveryPolicy DeliveryPolicy, + bool DeclaredTypeMismatch); + +/// +/// Detects a file's real content type from its byte signature rather than trusting the +/// caller-declared MIME type or file extension alone +/// (docs/13-attachments.md — "detect known signatures", "compare declared type, +/// extension and detected type"). +/// +public interface IContentSignatureDetector +{ + SignatureDetectionResult Detect(ReadOnlySpan prefix, string? declaredContentType, string? extension); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Attachments/OrphanedBlobGarbageCollector.cs b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/OrphanedBlobGarbageCollector.cs new file mode 100644 index 0000000..085252a --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/OrphanedBlobGarbageCollector.cs @@ -0,0 +1,58 @@ +namespace ItWorx.KnowledgeHub.Application.Attachments; + +/// +/// Safe, lock-serialized physical deletion of attachment blobs that have become +/// unreferenced — used by both the explicit "discard a staging session" flow +/// (DiscardPageCreationSessionUseCase) and the automatic expired-session cleanup +/// worker (CleanupExpiredPageCreationSessionsUseCase). Never touches a blob that +/// might still be shared by another, unrelated AttachmentReference — the whole +/// point of content-addressed deduplication is that one physical file can back many +/// logical references, so "this session's staged reference is gone" is only ever a +/// *candidate* for deletion, re-verified here under +/// immediately before acting. +/// +public sealed class OrphanedBlobGarbageCollector( + IAttachmentRepository attachments, IAttachmentBlobStore blobStore, AttachmentContentLock contentLock) +{ + /// Returns the number of blobs actually physically removed. A blob whose + /// file was already missing on disk still has its now-pointless database row + /// removed (treated as removed for reporting purposes) — never left dangling + /// forever just because an earlier partial run already deleted the bytes. + public async Task CollectAsync(IReadOnlyCollection candidateHashes, CancellationToken cancellationToken) + { + if (candidateHashes.Count == 0) + { + return 0; + } + + var removed = 0; + foreach (var hash in candidateHashes.Distinct(StringComparer.Ordinal)) + { + using var lease = await contentLock.AcquireAsync(hash, cancellationToken); + + // Re-check under the lock: the candidate list was computed before this lock + // was acquired, and a concurrent upload of the exact same bytes may have + // created a brand-new live reference to this hash in the meantime. + var stillUnreferenced = await attachments.FindUnreferencedBlobHashesAsync([hash], cancellationToken); + if (stillUnreferenced.Count == 0) + { + continue; + } + + // Database truth first (matches the established PurgeSourceBatchUseCase + // ordering): if the process crashes between these two calls, the worst case + // is an orphaned file on disk with no database row pointing at it — safe, + // recoverable by a future disk-level sweep, never a database row pointing at + // bytes that no longer exist. + await attachments.RemoveBlobRecordsAsync([hash], cancellationToken); + + // Tolerates an already-missing file (a previous run's crash-after-DB-delete, + // or the file was never actually written) — DeleteVerifiedAsync returns + // false rather than throwing. + await blobStore.DeleteVerifiedAsync(hash, cancellationToken); + removed++; + } + + return removed; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/GetAttachmentContentUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/GetAttachmentContentUseCase.cs new file mode 100644 index 0000000..d0a681f --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/GetAttachmentContentUseCase.cs @@ -0,0 +1,137 @@ +using ItWorx.KnowledgeHub.Application.Ingestion; +using ItWorx.KnowledgeHub.Application.Knowledge; +using ItWorx.KnowledgeHub.Domain.Attachments; +using ItWorx.KnowledgeHub.Domain.Identity; +using ItWorx.KnowledgeHub.Domain.Ingestion; + +namespace ItWorx.KnowledgeHub.Application.Attachments.UseCases; + +public sealed record AttachmentContentResult( + AttachmentReference Reference, + AttachmentBlob Blob, + Stream Content); + +/// Who is asking for an attachment's content — resolved once by the endpoint +/// (from the authenticated principal already required by RequireContentAccess) +/// and passed in, so this use case's authorization decision is independently testable +/// from the HTTP layer and does not silently trust endpoint routing alone. +public sealed record AttachmentRequestor(Guid? UserId, WorkspaceRole? Role) +{ + /// A service-account (bearer-token) caller. Service accounts are never + /// granted access to content held by an open page-creation session or an unpublished + /// ingestion proposal. Ordinary, already-published attachments remain reachable + /// exactly as before. + public bool IsAdministrator => Role == WorkspaceRole.Administrator; + + public static readonly AttachmentRequestor ServiceAccount = new(null, null); +} + +/// +/// Every content request re-authorizes the logical reference +/// (docs/13-attachments.md — "serving"): always invoked behind the same Reader +/// authorization as every other read endpoint, refuses a trashed reference, and — since +/// V2-R5 — refuses a reference that is still owned by an open page-creation staging +/// session unless the requestor is that session's own creator or a workspace +/// Administrator. An unpublished ingestion source is private to its submitter and +/// administrators while it is Received or ChangesRequested, and is readable by +/// Editors+ during review. Before the staging-session fix, a workspace Reader who +/// merely learned or guessed a staged attachment's GUID could read it before the page +/// it belongs to was published (docs/adr/ADR-029-v2-r5-final-hardening.md finding +/// V2-R5-H1) — a content hash or reference GUID alone is never sufficient permission +/// to retrieve a blob. +/// +public sealed class GetAttachmentContentUseCase( + IAttachmentRepository attachments, + IAttachmentBlobStore blobStore, + IPageCreationSessionRepository stagingSessions, + IIngestionProposalRepository proposals, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync( + Guid workspaceId, Guid referenceId, AttachmentRequestor requestor, CancellationToken cancellationToken) + { + var reference = await attachments.FindReferenceAsync(referenceId, cancellationToken); + if (reference is null || reference.IsTrashed || reference.WorkspaceId != workspaceId) + { + return null; + } + + var usage = await attachments.FindUsageForReferenceAsync(referenceId, cancellationToken); + if (usage is null) + { + // An orphaned reference with no owner at all should never exist in steady + // state, but never serve one if it somehow does. + return null; + } + + if (usage.StagingSessionId is { } stagingSessionId && !await IsAuthorizedForStagedAttachmentAsync( + workspaceId, stagingSessionId, requestor, cancellationToken)) + { + // Deliberately the same 404 shape as "does not exist" — an unauthorized + // caller learns nothing about whether a staged attachment with this GUID + // exists at all. + return null; + } + + if (usage.IngestionProposalId is { } proposalId && !await IsAuthorizedForProposalAttachmentAsync( + workspaceId, proposalId, requestor, cancellationToken)) + { + return null; + } + + var blob = await attachments.FindBlobAsync(reference.BlobSha256, cancellationToken); + if (blob is null || blob.StorageState is AttachmentStorageState.Missing or AttachmentStorageState.Corrupt) + { + return null; + } + + var stream = await blobStore.OpenReadAsync(blob.Sha256, cancellationToken); + return new AttachmentContentResult(reference, blob, stream); + } + + private async Task IsAuthorizedForStagedAttachmentAsync( + Guid workspaceId, Guid stagingSessionId, AttachmentRequestor requestor, CancellationToken cancellationToken) + { + if (requestor.UserId is null) + { + return false; + } + + var session = await stagingSessions.FindAsync(workspaceId, stagingSessionId, cancellationToken); + if (session is null || !session.IsOpen || session.IsExpired(timeProvider.GetUtcNow())) + { + // The owning session was since discarded/committed, or has expired but the + // background cleanup worker has not yet reclaimed it — treated the same as + // "closed" either way (matching CommitPageCreationSessionUseCase's own + // !session.IsOpen || session.IsExpired(now) check), never served. + return false; + } + + return session.CreatedBy == requestor.UserId.Value || requestor.IsAdministrator; + } + + private async Task IsAuthorizedForProposalAttachmentAsync( + Guid workspaceId, Guid proposalId, AttachmentRequestor requestor, CancellationToken cancellationToken) + { + if (requestor.UserId is null || requestor.Role is null) + { + // Service tokens have attachment-read scope for published content, but + // unpublished proposal sources remain human-controlled review material. + return false; + } + + var proposal = await proposals.FindAsync(workspaceId, proposalId, cancellationToken); + if (proposal is null) + { + // Never serve an attachment whose proposal owner cannot be confirmed. + return false; + } + + if (proposal.Status is IngestionProposalStatus.Received or IngestionProposalStatus.ChangesRequested) + { + return requestor.UserId == proposal.SubmittedByUserId || requestor.IsAdministrator; + } + + return requestor.Role is WorkspaceRole.Editor or WorkspaceRole.Administrator; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/GetPageAttachmentsUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/GetPageAttachmentsUseCase.cs new file mode 100644 index 0000000..dd38291 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/GetPageAttachmentsUseCase.cs @@ -0,0 +1,19 @@ +using ItWorx.KnowledgeHub.Domain.Attachments; + +namespace ItWorx.KnowledgeHub.Application.Attachments.UseCases; + +public sealed record PageAttachmentResult(AttachmentReference Reference, AttachmentBlob? Blob); + +public sealed class GetPageAttachmentsUseCase(IAttachmentRepository attachments) +{ + public async Task> ExecuteAsync(Guid pageId, CancellationToken cancellationToken) + { + var references = await attachments.GetReferencesForPageAsync(pageId, cancellationToken); + var blobs = await attachments.FindBlobsAsync( + references.Select(reference => reference.BlobSha256).Distinct().ToList(), cancellationToken); + + return references + .Select(reference => new PageAttachmentResult(reference, blobs.GetValueOrDefault(reference.BlobSha256))) + .ToList(); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/UploadAttachmentUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/UploadAttachmentUseCase.cs new file mode 100644 index 0000000..d6f8a68 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/UploadAttachmentUseCase.cs @@ -0,0 +1,245 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Application.Ingestion; +using ItWorx.KnowledgeHub.Application.Knowledge; +using ItWorx.KnowledgeHub.Application.Projects; +using ItWorx.KnowledgeHub.Domain.Attachments; + +namespace ItWorx.KnowledgeHub.Application.Attachments.UseCases; + +public enum UploadAttachmentStatus +{ + Success, + PageNotFound, + StagingSessionNotFound, + IngestionProposalNotFound, + ProjectNotFound, + TooLarge, + EmptyFile, + InvalidFilename, + Rejected +} + +public enum AttachmentOwnerKind +{ + Page, + StagingSession, + IngestionProposal, + Project +} + +/// An attachment upload targets exactly one owner: either an existing, +/// already-published page, an open page-creation staging session, an ingestion +/// proposal awaiting review, or a project icon +/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md). Both paths share the same +/// quarantine/hash/signature/dedup/commit pipeline below — only which +/// gets created differs. +public sealed record AttachmentOwner(AttachmentOwnerKind Kind, Guid Id) +{ + public static AttachmentOwner ForPage(Guid pageId) => new(AttachmentOwnerKind.Page, pageId); + + public static AttachmentOwner ForStagingSession(Guid stagingSessionId) => new(AttachmentOwnerKind.StagingSession, stagingSessionId); + + public static AttachmentOwner ForIngestionProposal(Guid proposalId) => new(AttachmentOwnerKind.IngestionProposal, proposalId); + + public static AttachmentOwner ForProject(Guid projectId) => new(AttachmentOwnerKind.Project, projectId); +} + +public sealed record UploadAttachmentCommand( + Guid WorkspaceId, + AttachmentOwner Owner, + string DeclaredFilename, + string? DeclaredContentType, + Stream Content, + Guid ActorId, + string? CorrelationId, + bool DeferPersistence = false, + long? MaximumUploadBytes = null, + IReadOnlySet? AllowedMediaTypes = null); + +public sealed record UploadAttachmentResult( + UploadAttachmentStatus Status, + Guid? ReferenceId, + string? StoredFilename, + string? DetectedMediaType, + AttachmentDeliveryPolicy? DeliveryPolicy, + long? ByteLength, + string? BlobSha256, + bool NewBlobCommitted, + string? Detail) +{ + public static UploadAttachmentResult Success( + Guid referenceId, string storedFilename, string detectedMediaType, AttachmentDeliveryPolicy deliveryPolicy, long byteLength, + string blobSha256, bool newBlobCommitted) => + new(UploadAttachmentStatus.Success, referenceId, storedFilename, detectedMediaType, deliveryPolicy, byteLength, + blobSha256, newBlobCommitted, null); + + public static UploadAttachmentResult Failure(UploadAttachmentStatus status, string? detail = null) => + new(status, null, null, null, null, null, null, false, detail); +} + +/// +/// The upload pipeline from docs/13-attachments.md: stream once to quarantine while +/// hashing, verify size, detect the real signature, apply delivery policy, then +/// atomically commit the blob before creating the logical reference and page usage in +/// one transaction. Every failure path discards the quarantined file +/// (docs/13-attachments.md — "remove quarantine data on every failure/cancellation"). +/// +public sealed class UploadAttachmentUseCase( + IPageRepository pages, + IPageCreationSessionRepository stagingSessions, + IIngestionProposalRepository ingestionProposals, + IProjectRepository projects, + IAttachmentRepository attachments, + IAttachmentBlobStore blobStore, + IContentSignatureDetector signatureDetector, + IAuditEventRecorder auditEvents, + AttachmentContentLock contentLock, + TimeProvider timeProvider, + long maximumUploadBytes = 100 * 1024 * 1024) +{ + public async Task ExecuteAsync(UploadAttachmentCommand command, CancellationToken cancellationToken) + { + if (command.Owner.Kind == AttachmentOwnerKind.Page) + { + var page = await pages.FindAsync(command.Owner.Id, cancellationToken); + if (page is null) + { + return UploadAttachmentResult.Failure(UploadAttachmentStatus.PageNotFound); + } + } + else if (command.Owner.Kind == AttachmentOwnerKind.StagingSession) + { + var now = timeProvider.GetUtcNow(); + var session = await stagingSessions.FindAsync(command.WorkspaceId, command.Owner.Id, cancellationToken); + if (session is null || !session.IsOpen || session.IsExpired(now)) + { + return UploadAttachmentResult.Failure(UploadAttachmentStatus.StagingSessionNotFound); + } + } + else if (command.Owner.Kind == AttachmentOwnerKind.IngestionProposal) + { + var proposal = await ingestionProposals.FindAsync(command.WorkspaceId, command.Owner.Id, cancellationToken); + if (proposal is null) + { + return UploadAttachmentResult.Failure(UploadAttachmentStatus.IngestionProposalNotFound); + } + } + else + { + var project = await projects.FindAsync(command.Owner.Id, cancellationToken); + if (project is null || project.WorkspaceId != command.WorkspaceId) + { + return UploadAttachmentResult.Failure(UploadAttachmentStatus.ProjectNotFound); + } + } + + SafeFilename filename; + try + { + filename = SafeFilename.Create(command.DeclaredFilename); + } + catch (ArgumentException ex) + { + return UploadAttachmentResult.Failure(UploadAttachmentStatus.InvalidFilename, ex.Message); + } + + var quarantined = await blobStore.WriteToQuarantineAsync(command.Content, cancellationToken); + var committed = false; + + try + { + if (quarantined.ByteLength == 0) + { + return UploadAttachmentResult.Failure(UploadAttachmentStatus.EmptyFile); + } + + var uploadLimit = Math.Min(maximumUploadBytes, command.MaximumUploadBytes ?? maximumUploadBytes); + if (quarantined.ByteLength > uploadLimit) + { + return UploadAttachmentResult.Failure( + UploadAttachmentStatus.TooLarge, + $"Bestand is groter dan de toegestane {uploadLimit / (1024 * 1024)} MiB."); + } + + var prefix = await blobStore.ReadQuarantinePrefixAsync(quarantined.QuarantineToken, 4096, cancellationToken); + var signature = signatureDetector.Detect(prefix, command.DeclaredContentType, filename.LastExtension()); + + if (signature.DeliveryPolicy == AttachmentDeliveryPolicy.Rejected) + { + return UploadAttachmentResult.Failure( + UploadAttachmentStatus.Rejected, + $"Bestandstype '{signature.DetectedMediaType}' wordt niet toegestaan als bijlage."); + } + + if (command.AllowedMediaTypes is { } allowedMediaTypes && !allowedMediaTypes.Contains(signature.DetectedMediaType)) + { + return UploadAttachmentResult.Failure( + UploadAttachmentStatus.Rejected, + $"Bestandstype '{signature.DetectedMediaType}' wordt voor dit gebruik niet toegestaan."); + } + + // Deduplication spans both the filesystem commit and the database blob + // row. Serialize that small critical section per content hash so two + // concurrent requests cannot both observe "missing", race the atomic + // rename and then race the attachment_blobs primary key. Unrelated hashes + // remain fully concurrent; idle lock entries are removed safely. + using var contentLease = await contentLock.AcquireAsync(quarantined.Sha256, cancellationToken); + var now = timeProvider.GetUtcNow(); + var existingBlob = await attachments.FindBlobAsync(quarantined.Sha256, cancellationToken); + + if (existingBlob is null) + { + await blobStore.CommitAsync(quarantined.QuarantineToken, quarantined.Sha256, cancellationToken); + committed = true; + attachments.AddBlob(AttachmentBlob.Create( + quarantined.Sha256, quarantined.ByteLength, signature.DetectedMediaType, signature.DeliveryPolicy, now)); + } + else + { + // Deduplication (docs/13-attachments.md): reuse the existing blob, + // never store the same content twice — the quarantined duplicate is + // discarded in the `finally` block below like any other non-committed + // quarantine file. + } + + var reference = AttachmentReference.Create(command.WorkspaceId, quarantined.Sha256, filename.Value, command.ActorId, now); + attachments.AddReference(reference); + attachments.AddUsage(command.Owner.Kind switch + { + AttachmentOwnerKind.Page => AttachmentUsage.ForPage(reference.Id, command.Owner.Id, now), + AttachmentOwnerKind.StagingSession => AttachmentUsage.ForStagingSession(reference.Id, command.Owner.Id, now), + AttachmentOwnerKind.IngestionProposal => AttachmentUsage.ForIngestionProposal(reference.Id, command.Owner.Id, now), + AttachmentOwnerKind.Project => AttachmentUsage.ForProject(reference.Id, command.Owner.Id, now), + _ => throw new ArgumentOutOfRangeException(nameof(command)) + }); + + if (!command.DeferPersistence) + { + await attachments.SaveChangesAsync(cancellationToken); + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, + command.ActorId, + Source: "web", + Action: "attachment.uploaded", + TargetType: "attachment_reference", + TargetId: reference.Id.ToString(), + Summary: $"Bijlage '{filename.Value}' geüpload ({quarantined.ByteLength} bytes).", + command.CorrelationId, + now), + cancellationToken); + } + + return UploadAttachmentResult.Success(reference.Id, filename.Value, signature.DetectedMediaType, signature.DeliveryPolicy, + quarantined.ByteLength, quarantined.Sha256, committed); + } + finally + { + if (!committed) + { + await blobStore.DiscardQuarantineAsync(quarantined.QuarantineToken, cancellationToken); + } + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Audit/AuditEventRecord.cs b/src/backend/ItWorx.KnowledgeHub.Application/Audit/AuditEventRecord.cs new file mode 100644 index 0000000..272c9a3 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Audit/AuditEventRecord.cs @@ -0,0 +1,24 @@ +namespace ItWorx.KnowledgeHub.Application.Audit; + +public enum AuditActorKind +{ + System, + User, + ServiceAccount +} + +/// +/// An append-only security/content action record. Never carries passwords, raw tokens, +/// session identifiers or page bodies (see docs/12-auth-security.md, "Logging and +/// privacy"). +/// +public sealed record AuditEventRecord( + AuditActorKind ActorKind, + Guid? ActorId, + string Source, + string Action, + string? TargetType, + string? TargetId, + string Summary, + string? CorrelationId, + DateTimeOffset OccurredAt); diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventReader.cs b/src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventReader.cs new file mode 100644 index 0000000..ac2df89 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventReader.cs @@ -0,0 +1,10 @@ +namespace ItWorx.KnowledgeHub.Application.Audit; + +public sealed record AuditEventView( + long Id, string ActorKind, Guid? ActorId, string Source, string Action, + string? TargetType, string? TargetId, string Summary, DateTimeOffset OccurredAt); + +public interface IAuditEventReader +{ + Task> ListRecentAsync(int limit, CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventRecorder.cs b/src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventRecorder.cs new file mode 100644 index 0000000..da48db8 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventRecorder.cs @@ -0,0 +1,11 @@ +namespace ItWorx.KnowledgeHub.Application.Audit; + +public interface IAuditEventRecorder +{ + /// Adds an audit event to the current unit of work without saving it. + /// Callers that need one atomic commit with their domain mutation use this rather + /// than . + void Add(AuditEventRecord auditEvent); + + Task RecordAsync(AuditEventRecord auditEvent, CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Automation/IIdempotencyStore.cs b/src/backend/ItWorx.KnowledgeHub.Application/Automation/IIdempotencyStore.cs new file mode 100644 index 0000000..d1a25b9 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Automation/IIdempotencyStore.cs @@ -0,0 +1,10 @@ +using ItWorx.KnowledgeHub.Domain.Automation; + +namespace ItWorx.KnowledgeHub.Application.Automation; + +public interface IIdempotencyStore +{ + Task FindAsync(Guid callerId, string key, CancellationToken cancellationToken); + + Task SaveAsync(IdempotencyRecord record, CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Automation/IOutboxRepository.cs b/src/backend/ItWorx.KnowledgeHub.Application/Automation/IOutboxRepository.cs new file mode 100644 index 0000000..7b6f7d5 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Automation/IOutboxRepository.cs @@ -0,0 +1,19 @@ +using ItWorx.KnowledgeHub.Domain.Automation; + +namespace ItWorx.KnowledgeHub.Application.Automation; + +/// +/// Stages an outbox row on the same unit-of-work (DbContext) as the calling use +/// case's own aggregate repository, so the eventual SaveChangesAsync call — +/// made by that aggregate repository, not this one — commits both atomically +/// (docs/24-mcp-readiness.md "Outbox events": "preserve transactionally correct event +/// records"). This port deliberately has no SaveChangesAsync of its own. +/// +public interface IOutboxRepository +{ + void Add(OutboxMessage message); + + /// For the outbox-recovery test and any future dispatcher: every + /// not-yet-processed message, oldest first. + Task> GetUnprocessedAsync(int limit, CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Automation/IServiceAccountRepository.cs b/src/backend/ItWorx.KnowledgeHub.Application/Automation/IServiceAccountRepository.cs new file mode 100644 index 0000000..4a872c6 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Automation/IServiceAccountRepository.cs @@ -0,0 +1,27 @@ +using ItWorx.KnowledgeHub.Domain.Automation; + +namespace ItWorx.KnowledgeHub.Application.Automation; + +public sealed record ServiceAccountWithTokens(ServiceAccount Account, IReadOnlyList Tokens); + +public interface IServiceAccountRepository +{ + Task FindAsync(Guid serviceAccountId, CancellationToken cancellationToken); + + Task> ListAsync(Guid workspaceId, CancellationToken cancellationToken); + + Task> GetTokensAsync(Guid serviceAccountId, CancellationToken cancellationToken); + + Task FindTokenAsync(Guid tokenId, CancellationToken cancellationToken); + + /// Looks up an active token by its public ID, together with its owning + /// account — the hot path for every bearer-authenticated request, so this is a + /// single indexed lookup, never a table scan. + Task<(ServiceAccount Account, ServiceAccountToken Token)?> FindByPublicIdAsync(string publicId, CancellationToken cancellationToken); + + void Add(ServiceAccount account); + + void AddToken(ServiceAccountToken token); + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Automation/IServiceAccountTokenIssuer.cs b/src/backend/ItWorx.KnowledgeHub.Application/Automation/IServiceAccountTokenIssuer.cs new file mode 100644 index 0000000..f6ea775 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Automation/IServiceAccountTokenIssuer.cs @@ -0,0 +1,20 @@ +namespace ItWorx.KnowledgeHub.Application.Automation; + +public sealed record IssuedToken(string PublicId, string SecretHash, string RawToken); + +/// +/// Generates and verifies ikh_<public-id>_<secret> bearer tokens +/// (docs/09-api-contract.md "Service account"). The raw secret is never persisted — +/// only its hash. +/// +public interface IServiceAccountTokenIssuer +{ + IssuedToken IssueNewToken(); + + /// Parses a presented Authorization: Bearer value into its public + /// ID and secret, or returns false if it is not well-formed — never throws on + /// attacker-controlled input. + bool TryParse(string presentedToken, out string publicId, out string secret); + + string HashSecret(string secret); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/AuthenticateServiceTokenUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/AuthenticateServiceTokenUseCase.cs new file mode 100644 index 0000000..00745d2 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/AuthenticateServiceTokenUseCase.cs @@ -0,0 +1,53 @@ +using ItWorx.KnowledgeHub.Domain.Automation; + +namespace ItWorx.KnowledgeHub.Application.Automation.UseCases; + +public sealed record AuthenticatedServiceToken(ServiceAccount Account, ServiceAccountToken Token); + +/// +/// Verifies a presented Authorization: Bearer value against the database on +/// every request — never a cached "still valid" assumption, so revocation and +/// expiry take effect on the very next call (docs/09-api-contract.md). +/// +public sealed class AuthenticateServiceTokenUseCase( + IServiceAccountRepository serviceAccounts, + IServiceAccountTokenIssuer tokenIssuer, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync(string presentedToken, CancellationToken cancellationToken) + { + if (!tokenIssuer.TryParse(presentedToken, out var publicId, out var secret)) + { + return null; + } + + var found = await serviceAccounts.FindByPublicIdAsync(publicId, cancellationToken); + if (found is not { } pair) + { + return null; + } + + var (account, token) = pair; + var now = timeProvider.GetUtcNow(); + + if (!account.Enabled || !token.IsActive(now)) + { + return null; + } + + // Constant-time-in-effect: the hash comparison itself is a straightforward + // equality check, but the secret is compared only after an indexed lookup by + // the *public* ID — the caller-supplied secret is never used to search the + // table by value, so there is no timing side-channel that reveals whether the + // public ID exists independent of the secret being correct. + if (tokenIssuer.HashSecret(secret) != token.SecretHash) + { + return null; + } + + token.MarkUsed(now); + await serviceAccounts.SaveChangesAsync(cancellationToken); + + return new AuthenticatedServiceToken(account, token); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/CreateServiceAccountUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/CreateServiceAccountUseCase.cs new file mode 100644 index 0000000..88d1191 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/CreateServiceAccountUseCase.cs @@ -0,0 +1,42 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Domain.Automation; + +namespace ItWorx.KnowledgeHub.Application.Automation.UseCases; + +public sealed record CreateServiceAccountCommand( + Guid WorkspaceId, string Name, string Description, IReadOnlyList Scopes, Guid ActorId, string? CorrelationId); + +public sealed record CreateServiceAccountResult(Guid ServiceAccountId, Guid TokenId, string RawToken); + +/// Creates a service account together with its first token, in one step — +/// there is no useful account without at least one credential. The raw token is +/// returned exactly once; it cannot be retrieved again after this call +/// (docs/09-api-contract.md: "The full token is shown once."). +public sealed class CreateServiceAccountUseCase( + IServiceAccountRepository serviceAccounts, + IServiceAccountTokenIssuer tokenIssuer, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync(CreateServiceAccountCommand command, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var account = ServiceAccount.Create(command.WorkspaceId, command.Name, command.Description, command.ActorId, now); + + var issued = tokenIssuer.IssueNewToken(); + var token = ServiceAccountToken.Create(account.Id, issued.PublicId, issued.SecretHash, command.Scopes, expiresAt: null, now); + + serviceAccounts.Add(account); + serviceAccounts.AddToken(token); + await serviceAccounts.SaveChangesAsync(cancellationToken); + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, command.ActorId, Source: "web", Action: "service_account.created", + TargetType: "service_account", TargetId: account.Id.ToString(), + Summary: $"Service account '{account.Name}' aangemaakt.", command.CorrelationId, now), + cancellationToken); + + return new CreateServiceAccountResult(account.Id, token.Id, issued.RawToken); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/IssueServiceAccountTokenUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/IssueServiceAccountTokenUseCase.cs new file mode 100644 index 0000000..65d7d21 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/IssueServiceAccountTokenUseCase.cs @@ -0,0 +1,45 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Domain.Automation; + +namespace ItWorx.KnowledgeHub.Application.Automation.UseCases; + +public sealed record IssueServiceAccountTokenCommand( + Guid ServiceAccountId, IReadOnlyList Scopes, Guid ActorId, string? CorrelationId); + +public sealed record IssueServiceAccountTokenResult(Guid? TokenId, string? RawToken); + +/// Adds a new token to an existing account — the rotation pattern is +/// "issue a new token, update the client, then revoke the old one" rather than an +/// in-place secret replacement, so a credential is never briefly unusable +/// (docs/09-api-contract.md "rotate"). +public sealed class IssueServiceAccountTokenUseCase( + IServiceAccountRepository serviceAccounts, + IServiceAccountTokenIssuer tokenIssuer, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync(IssueServiceAccountTokenCommand command, CancellationToken cancellationToken) + { + var account = await serviceAccounts.FindAsync(command.ServiceAccountId, cancellationToken); + if (account is null) + { + return new IssueServiceAccountTokenResult(null, null); + } + + var now = timeProvider.GetUtcNow(); + var issued = tokenIssuer.IssueNewToken(); + var token = ServiceAccountToken.Create(account.Id, issued.PublicId, issued.SecretHash, command.Scopes, expiresAt: null, now); + + serviceAccounts.AddToken(token); + await serviceAccounts.SaveChangesAsync(cancellationToken); + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, command.ActorId, Source: "web", Action: "service_account.token_issued", + TargetType: "service_account", TargetId: account.Id.ToString(), + Summary: $"Nieuw token uitgegeven voor service account '{account.Name}'.", command.CorrelationId, now), + cancellationToken); + + return new IssueServiceAccountTokenResult(token.Id, issued.RawToken); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/ListServiceAccountsUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/ListServiceAccountsUseCase.cs new file mode 100644 index 0000000..8ddd451 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/ListServiceAccountsUseCase.cs @@ -0,0 +1,21 @@ +using ItWorx.KnowledgeHub.Domain.Automation; + +namespace ItWorx.KnowledgeHub.Application.Automation.UseCases; + +public sealed record ServiceAccountView(ServiceAccount Account, IReadOnlyList Tokens); + +public sealed class ListServiceAccountsUseCase(IServiceAccountRepository serviceAccounts) +{ + public async Task> ExecuteAsync(Guid workspaceId, CancellationToken cancellationToken) + { + var accounts = await serviceAccounts.ListAsync(workspaceId, cancellationToken); + var views = new List(accounts.Count); + foreach (var account in accounts) + { + var tokens = await serviceAccounts.GetTokensAsync(account.Id, cancellationToken); + views.Add(new ServiceAccountView(account, tokens)); + } + + return views; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/RevokeServiceAccountTokenUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/RevokeServiceAccountTokenUseCase.cs new file mode 100644 index 0000000..8f1da21 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/RevokeServiceAccountTokenUseCase.cs @@ -0,0 +1,36 @@ +using ItWorx.KnowledgeHub.Application.Audit; + +namespace ItWorx.KnowledgeHub.Application.Automation.UseCases; + +public sealed record RevokeServiceAccountTokenCommand(Guid TokenId, Guid ActorId, string? CorrelationId); + +/// Revocation takes effect immediately for the very next request — tokens are +/// checked live against the database on every call, never cached as "valid" for the +/// lifetime of a process (docs/09-api-contract.md "can be revoked"). +public sealed class RevokeServiceAccountTokenUseCase( + IServiceAccountRepository serviceAccounts, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync(RevokeServiceAccountTokenCommand command, CancellationToken cancellationToken) + { + var token = await serviceAccounts.FindTokenAsync(command.TokenId, cancellationToken); + if (token is null) + { + return false; + } + + var now = timeProvider.GetUtcNow(); + token.Revoke(now); + await serviceAccounts.SaveChangesAsync(cancellationToken); + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, command.ActorId, Source: "web", Action: "service_account.token_revoked", + TargetType: "service_account_token", TargetId: token.Id.ToString(), + Summary: "Service-accounttoken ingetrokken.", command.CorrelationId, now), + cancellationToken); + + return true; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Common/OperationResult.cs b/src/backend/ItWorx.KnowledgeHub.Application/Common/OperationResult.cs new file mode 100644 index 0000000..faf1b17 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Common/OperationResult.cs @@ -0,0 +1,46 @@ +namespace ItWorx.KnowledgeHub.Application.Common; + +/// Shared outcome shape for Projects/Decisions/Runbooks use cases — mirrors +/// Knowledge.UseCases.PageOperationResult's established pattern without +/// duplicating it per module. +public enum OperationStatus +{ + Success, + NotFound, + ConcurrencyConflict, + ValidationFailed, + InvalidTransition +} + +public sealed record OperationResult(OperationStatus Status, string? ETag, IReadOnlyList Errors) +{ + public static OperationResult Success(string? etag = null) => new(OperationStatus.Success, etag, []); + + public static OperationResult Failure(OperationStatus status, IReadOnlyList? errors = null) => + new(status, null, errors ?? []); +} + +/// A result that also carries a created/loaded value (e.g. a new entity's ID) +/// on success. Factory methods live on the non-generic +/// helper, not on this generic type itself, since CA1000 forbids static members on +/// generic types; call-site type inference (OperationResultValue.Success(value)) +/// reads the same as a static member would. +public sealed record OperationResultValue(OperationStatus Status, T? Value, string? ETag, IReadOnlyList Errors); + +public static class OperationResultValue +{ + public static OperationResultValue Success(T value, string? etag = null) => + new(OperationStatus.Success, value, etag, []); + + public static OperationResultValue Failure(OperationStatus status, IReadOnlyList? errors = null) => + new(status, default, null, errors ?? []); +} + +/// Thrown by a repository's SaveChangesAsync when an entity loaded for +/// update via an expected ETag changed underneath the current unit of work — the same +/// pattern as Knowledge.IPageRepository.ConcurrencyConflictException, shared here +/// across the Projects/Decisions/Runbooks repositories. +public sealed class ConcurrencyConflictException(Guid entityId) : Exception($"Entity {entityId} was modified by another request.") +{ + public Guid EntityId { get; } = entityId; +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Common/SafeUrlValidator.cs b/src/backend/ItWorx.KnowledgeHub.Application/Common/SafeUrlValidator.cs new file mode 100644 index 0000000..b9bd56e --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Common/SafeUrlValidator.cs @@ -0,0 +1,46 @@ +namespace ItWorx.KnowledgeHub.Application.Common; + +/// +/// Shared "is this URL safe to store and later render as a link" check +/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H2). Only +/// `http`/`https` are accepted by default — no `javascript:`, `data:`, `file:` or any +/// other scheme, regardless of how a client encodes it, and the value is never +/// silently rewritten (no normalization that could change its meaning): a caller +/// either gets back exactly the trimmed input it sent, or an explicit validation +/// failure it can react to. +/// +public static class SafeUrlValidator +{ + private static readonly string[] DefaultAllowedSchemes = ["http", "https"]; + + /// Returns null when is empty (an + /// optional URL field left blank is always valid) or a well-formed absolute URL + /// using an allowed scheme; otherwise a single human-readable Dutch validation + /// message naming . + public static string? Validate(string? value, string fieldLabel, int maxLength, IReadOnlyCollection? allowedSchemes = null) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + if (trimmed.Length > maxLength) + { + return $"{fieldLabel} mag maximaal {maxLength} tekens bevatten."; + } + + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + { + return $"{fieldLabel} moet een geldige, absolute URL zijn."; + } + + var schemes = allowedSchemes ?? DefaultAllowedSchemes; + if (!schemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase)) + { + return $"{fieldLabel} moet beginnen met {string.Join(" of ", schemes)}."; + } + + return null; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Decisions/IDecisionRepository.cs b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/IDecisionRepository.cs new file mode 100644 index 0000000..5cf41a7 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/IDecisionRepository.cs @@ -0,0 +1,31 @@ +using ItWorx.KnowledgeHub.Domain.Decisions; + +namespace ItWorx.KnowledgeHub.Application.Decisions; + +public sealed record DecisionListPage(IReadOnlyList Items, string? NextCursor); + +/// Application-facing persistence port for Architecture Decision Records. +/// Concurrency uses the same xmin-as-opaque-ETag pattern as +/// Knowledge.IPageRepository. +public interface IDecisionRepository +{ + Task FindAsync(Guid decisionId, CancellationToken cancellationToken); + + Task FindByPageIdAsync(Guid pageId, CancellationToken cancellationToken); + + Task GetETagAsync(Guid decisionId, CancellationToken cancellationToken); + + Task FindForUpdateAsync(Guid decisionId, string expectedETag, CancellationToken cancellationToken); + + /// The next decision number to assign within the given scope (workspace, + /// and project when not null) — computed as one more than the current maximum, + /// inside the same transaction as the record's creation. + Task GetNextDecisionNumberAsync(Guid workspaceId, Guid? projectId, CancellationToken cancellationToken); + + Task ListAsync( + Guid workspaceId, Guid? projectId, string? status, string? cursor, int limit, CancellationToken cancellationToken); + + void Add(DecisionRecord decision); + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/AcceptDecisionUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/AcceptDecisionUseCase.cs new file mode 100644 index 0000000..a10b1c8 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/AcceptDecisionUseCase.cs @@ -0,0 +1,68 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Application.Common; + +namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases; + +public sealed record AcceptDecisionCommand(Guid DecisionId, string ExpectedETag, Guid ActorId, string? CorrelationId); + +/// Accepting a decision that itself names an earlier one to supersede +/// (docs/16-runbooks-and-adrs.md — "An accepted ADR can be superseded only by another +/// accepted ADR") atomically flips that earlier, currently-accepted decision to +/// Superseded and links both records bidirectionally. +public sealed class AcceptDecisionUseCase( + IDecisionRepository decisions, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync(AcceptDecisionCommand command, CancellationToken cancellationToken) + { + var decision = await decisions.FindForUpdateAsync(command.DecisionId, command.ExpectedETag, cancellationToken); + if (decision is null) + { + return OperationResult.Failure(OperationStatus.NotFound); + } + + var now = timeProvider.GetUtcNow(); + + try + { + decision.Accept(now); + } + catch (InvalidOperationException ex) + { + return OperationResult.Failure(OperationStatus.InvalidTransition, [ex.Message]); + } + + if (decision.SupersedesDecisionId is { } supersedesId) + { + var target = await decisions.FindAsync(supersedesId, cancellationToken); + if (target is null || target.Status != Domain.Decisions.DecisionStatus.Accepted) + { + return OperationResult.Failure( + OperationStatus.ValidationFailed, + ["De te vervangen beslissing moet momenteel geaccepteerd zijn."]); + } + + target.MarkSupersededBy(decision.Id, now); + } + + try + { + await decisions.SaveChangesAsync(cancellationToken); + } + catch (Common.ConcurrencyConflictException) + { + return OperationResult.Failure(OperationStatus.ConcurrencyConflict); + } + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, command.ActorId, Source: "web", Action: "decision.accepted", + TargetType: "decision", TargetId: decision.Id.ToString(), + Summary: $"ADR-{decision.DecisionNumber} geaccepteerd.", command.CorrelationId, now), + cancellationToken); + + var etag = await decisions.GetETagAsync(decision.Id, cancellationToken); + return OperationResult.Success(etag); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/CreateDecisionUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/CreateDecisionUseCase.cs new file mode 100644 index 0000000..0a481e7 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/CreateDecisionUseCase.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Application.Automation; +using ItWorx.KnowledgeHub.Application.Common; +using ItWorx.KnowledgeHub.Application.Knowledge; +using ItWorx.KnowledgeHub.Application.Knowledge.UseCases; +using ItWorx.KnowledgeHub.Domain.Automation; +using ItWorx.KnowledgeHub.Domain.Decisions; +using ItWorx.KnowledgeHub.Domain.Knowledge; + +namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases; + +public sealed record CreateDecisionCommand( + Guid WorkspaceId, + Guid? ParentPageId, + Guid? ProjectId, + string Title, + string? Markdown, + string Summary, + Guid? SupersedesDecisionId, + Guid ActorId, + string? CorrelationId); + +/// +/// Creates an ADR as one transactional-in-intent step: a Page (documentType +/// ArchitectureDecision, so it gets the full Markdown editor, revisions and +/// search like any other page) plus its lifecycle profile +/// (docs/16-runbooks-and-adrs.md — "unique page profile"). The two steps commit +/// sequentially rather than in one database transaction (ADR-014); if profile creation +/// fails after the page succeeded, the page is best-effort trashed so no orphaned, +/// profile-less ArchitectureDecision page is left visible. +/// +public sealed class CreateDecisionUseCase( + CreatePageUseCase createPage, + IPageRepository pages, + TrashPageUseCase trashPage, + IDecisionRepository decisions, + IAuditEventRecorder auditEvents, + IOutboxRepository outbox, + TimeProvider timeProvider) +{ + public async Task> ExecuteAsync(CreateDecisionCommand command, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(command.Title)) + { + return OperationResultValue.Failure(OperationStatus.ValidationFailed, ["Titel is verplicht."]); + } + + DecisionRecord? supersedes = null; + if (command.SupersedesDecisionId is { } supersedesId) + { + supersedes = await decisions.FindAsync(supersedesId, cancellationToken); + if (supersedes is null) + { + return OperationResultValue.Failure(OperationStatus.ValidationFailed, ["De te vervangen beslissing bestaat niet."]); + } + } + + var pageResult = await createPage.ExecuteAsync( + new CreatePageCommand( + command.WorkspaceId, command.ParentPageId, command.Title, DocumentType.ArchitectureDecision.ToString(), + command.Markdown, TemplateKey: null, Tags: null, command.ActorId, command.CorrelationId), + cancellationToken); + + if (pageResult.Status != PageOperationStatus.Success) + { + return OperationResultValue.Failure(OperationStatus.ValidationFailed, pageResult.Errors); + } + + var now = timeProvider.GetUtcNow(); + + try + { + var number = await decisions.GetNextDecisionNumberAsync(command.WorkspaceId, command.ProjectId, cancellationToken); + var decision = DecisionRecord.Create( + command.WorkspaceId, command.ProjectId, pageResult.PageId!.Value, number, + DateOnly.FromDateTime(now.UtcDateTime), command.Summary, command.SupersedesDecisionId, + command.ActorId, now); + + decisions.Add(decision); + + outbox.Add(OutboxMessage.Create( + "decision.created.v1", "decision", decision.Id.ToString(), + JsonSerializer.Serialize(new { decisionId = decision.Id, pageId = decision.PageId, decisionNumber = number }), + now)); + + await decisions.SaveChangesAsync(cancellationToken); + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, command.ActorId, Source: "web", Action: "decision.created", + TargetType: "decision", TargetId: decision.Id.ToString(), + Summary: $"ADR-{number} '{command.Title}' aangemaakt.", command.CorrelationId, now), + cancellationToken); + + return OperationResultValue.Success(decision.Id); + } + catch (Exception) + { + var page = await pages.FindAsync(pageResult.PageId!.Value, cancellationToken); + var etag = await pages.GetETagAsync(pageResult.PageId!.Value, cancellationToken); + if (page is not null && etag is not null) + { + await trashPage.ExecuteAsync( + new TrashPageCommand(pageResult.PageId!.Value, etag, command.ActorId, command.CorrelationId), + cancellationToken); + } + + throw; + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/GetDecisionUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/GetDecisionUseCase.cs new file mode 100644 index 0000000..81003a6 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/GetDecisionUseCase.cs @@ -0,0 +1,43 @@ +using ItWorx.KnowledgeHub.Application.Knowledge.UseCases; +using ItWorx.KnowledgeHub.Domain.Decisions; + +namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases; + +public sealed record DecisionDetailResult( + DecisionRecord Decision, + PageDetailResult Page, + string ETag, + DecisionRecord? Supersedes, + DecisionRecord? SupersededBy); + +/// Assembles one ADR's full lineage view: its own lifecycle metadata, the +/// linked page's rendered content, and — resolved by one extra lookup each, never a +/// duplicated copy — the decision it supersedes and/or is superseded by +/// (docs/16-runbooks-and-adrs.md: "Links are bidirectional"). +public sealed class GetDecisionUseCase(IDecisionRepository decisions, GetPageUseCase getPage) +{ + public async Task ExecuteAsync(Guid decisionId, CancellationToken cancellationToken) + { + var decision = await decisions.FindAsync(decisionId, cancellationToken); + if (decision is null) + { + return null; + } + + var page = await getPage.ExecuteAsync(decision.PageId, cancellationToken); + if (page is null) + { + return null; + } + + var etag = await decisions.GetETagAsync(decisionId, cancellationToken) ?? string.Empty; + var supersedes = decision.SupersedesDecisionId is { } supersedesId + ? await decisions.FindAsync(supersedesId, cancellationToken) + : null; + var supersededBy = decision.SupersededByDecisionId is { } supersededById + ? await decisions.FindAsync(supersededById, cancellationToken) + : null; + + return new DecisionDetailResult(decision, page, etag, supersedes, supersededBy); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/ListDecisionsUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/ListDecisionsUseCase.cs new file mode 100644 index 0000000..f6e7fee --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/ListDecisionsUseCase.cs @@ -0,0 +1,8 @@ +namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases; + +public sealed class ListDecisionsUseCase(IDecisionRepository decisions) +{ + public Task ExecuteAsync( + Guid workspaceId, Guid? projectId, string? status, string? cursor, int limit, CancellationToken cancellationToken) => + decisions.ListAsync(workspaceId, projectId, status, cursor, limit, cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/RejectDecisionUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/RejectDecisionUseCase.cs new file mode 100644 index 0000000..d8b16ad --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/RejectDecisionUseCase.cs @@ -0,0 +1,51 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Application.Common; + +namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases; + +public sealed record RejectDecisionCommand(Guid DecisionId, string ExpectedETag, Guid ActorId, string? CorrelationId); + +public sealed class RejectDecisionUseCase( + IDecisionRepository decisions, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync(RejectDecisionCommand command, CancellationToken cancellationToken) + { + var decision = await decisions.FindForUpdateAsync(command.DecisionId, command.ExpectedETag, cancellationToken); + if (decision is null) + { + return OperationResult.Failure(OperationStatus.NotFound); + } + + var now = timeProvider.GetUtcNow(); + + try + { + decision.Reject(now); + } + catch (InvalidOperationException ex) + { + return OperationResult.Failure(OperationStatus.InvalidTransition, [ex.Message]); + } + + try + { + await decisions.SaveChangesAsync(cancellationToken); + } + catch (Common.ConcurrencyConflictException) + { + return OperationResult.Failure(OperationStatus.ConcurrencyConflict); + } + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, command.ActorId, Source: "web", Action: "decision.rejected", + TargetType: "decision", TargetId: decision.Id.ToString(), + Summary: $"ADR-{decision.DecisionNumber} afgewezen.", command.CorrelationId, now), + cancellationToken); + + var etag = await decisions.GetETagAsync(decision.Id, cancellationToken); + return OperationResult.Success(etag); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/WithdrawDecisionUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/WithdrawDecisionUseCase.cs new file mode 100644 index 0000000..36812f9 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/WithdrawDecisionUseCase.cs @@ -0,0 +1,51 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Application.Common; + +namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases; + +public sealed record WithdrawDecisionCommand(Guid DecisionId, string ExpectedETag, Guid ActorId, string? CorrelationId); + +public sealed class WithdrawDecisionUseCase( + IDecisionRepository decisions, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync(WithdrawDecisionCommand command, CancellationToken cancellationToken) + { + var decision = await decisions.FindForUpdateAsync(command.DecisionId, command.ExpectedETag, cancellationToken); + if (decision is null) + { + return OperationResult.Failure(OperationStatus.NotFound); + } + + var now = timeProvider.GetUtcNow(); + + try + { + decision.Withdraw(now); + } + catch (InvalidOperationException ex) + { + return OperationResult.Failure(OperationStatus.InvalidTransition, [ex.Message]); + } + + try + { + await decisions.SaveChangesAsync(cancellationToken); + } + catch (Common.ConcurrencyConflictException) + { + return OperationResult.Failure(OperationStatus.ConcurrencyConflict); + } + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, command.ActorId, Source: "web", Action: "decision.withdrawn", + TargetType: "decision", TargetId: decision.Id.ToString(), + Summary: $"ADR-{decision.DecisionNumber} ingetrokken.", command.CorrelationId, now), + cancellationToken); + + var etag = await decisions.GetETagAsync(decision.Id, cancellationToken); + return OperationResult.Success(etag); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/IInstallationRepository.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/IInstallationRepository.cs new file mode 100644 index 0000000..bce7eb2 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/IInstallationRepository.cs @@ -0,0 +1,14 @@ +namespace ItWorx.KnowledgeHub.Application.Identity; + +public interface IInstallationRepository +{ + Task IsSetupCompleteAsync(CancellationToken cancellationToken); + + /// + /// Marks setup complete. Throws if it was + /// already complete — callers must treat that as "setup already ran", never as a + /// silent success, to guarantee the first-run flow is genuinely single-use even + /// under a concurrent double submit. + /// + Task CompleteSetupAsync(DateTimeOffset completedAt, CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/IRecoveryTokenService.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/IRecoveryTokenService.cs new file mode 100644 index 0000000..2fe4cda --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/IRecoveryTokenService.cs @@ -0,0 +1,18 @@ +namespace ItWorx.KnowledgeHub.Application.Identity; + +public sealed record IssuedRecoveryToken(string RawToken, DateTimeOffset ExpiresAt); + +/// +/// Operator-issued single-use password-recovery tokens (see docs/12-auth-security.md, +/// "Operator-issued recovery"). Only a non-reversible verifier is ever persisted; the raw +/// token is returned once, at issuance, for the administrator to deliver out of band. +/// +public interface IRecoveryTokenService +{ + Task IssueAsync(Guid userId, Guid issuedByUserId, CancellationToken cancellationToken); + + /// Validates and consumes a token atomically. Returns the associated user + /// ID, or when the token is missing, expired or already + /// used. + Task RedeemAsync(string rawToken, CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/IUserAccountManager.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/IUserAccountManager.cs new file mode 100644 index 0000000..db1a9a3 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/IUserAccountManager.cs @@ -0,0 +1,53 @@ +using ItWorx.KnowledgeHub.Domain.Identity; + +namespace ItWorx.KnowledgeHub.Application.Identity; + +/// +/// Application-facing port over the interactive-account store and sign-in mechanism. +/// Infrastructure implements this using ASP.NET Core Identity so password hashing, +/// lockout, security-stamp invalidation and cookie sign-in stay on a reviewed +/// implementation rather than custom crypto (see docs/12-auth-security.md). +/// +public interface IUserAccountManager +{ + Task CreateAsync( + DisplayName displayName, + EmailAddress email, + string password, + CancellationToken cancellationToken); + + /// + /// Verifies credentials and, on success, signs the caller in with a browser cookie + /// session. Applies lockout after repeated failures and rejects disabled accounts + /// without confirming whether the account exists. + /// + Task PasswordSignInAsync( + EmailAddress email, + string password, + CancellationToken cancellationToken); + + Task SignOutAsync(CancellationToken cancellationToken); + + /// Resolves the account for the current authenticated HTTP request, or + /// when there is none. + Task GetSignedInAccountAsync(CancellationToken cancellationToken); + + Task FindByIdAsync(Guid userId, CancellationToken cancellationToken); + + Task FindByEmailAsync(EmailAddress email, CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + /// Enables/disables an account. Disabling immediately invalidates existing + /// sessions by rotating the security stamp. + Task SetEnabledAsync(Guid userId, bool enabled, CancellationToken cancellationToken); + + /// Sets a new password directly, bypassing Identity's own reset-token flow, + /// because the caller (recovery redemption) has already validated a separately + /// stored, single-use recovery token. Rotates the security stamp so any other + /// existing session is invalidated. + Task> SetPasswordDirectAsync( + Guid userId, + string newPassword, + CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/IWorkspaceMembershipRepository.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/IWorkspaceMembershipRepository.cs new file mode 100644 index 0000000..798ace6 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/IWorkspaceMembershipRepository.cs @@ -0,0 +1,26 @@ +using ItWorx.KnowledgeHub.Domain.Identity; + +namespace ItWorx.KnowledgeHub.Application.Identity; + +public sealed record MembershipSummary(Guid UserId, WorkspaceRole Role); + +public interface IWorkspaceMembershipRepository +{ + Task AddAsync( + Guid workspaceId, + Guid userId, + WorkspaceRole role, + DateTimeOffset createdAt, + CancellationToken cancellationToken); + + Task GetRoleAsync(Guid workspaceId, Guid userId, CancellationToken cancellationToken); + + Task> ListAsync(Guid workspaceId, CancellationToken cancellationToken); + + Task ChangeRoleAsync( + Guid workspaceId, + Guid userId, + WorkspaceRole role, + DateTimeOffset updatedAt, + CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/IWorkspaceRepository.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/IWorkspaceRepository.cs new file mode 100644 index 0000000..7540b40 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/IWorkspaceRepository.cs @@ -0,0 +1,10 @@ +using ItWorx.KnowledgeHub.Domain.Identity; + +namespace ItWorx.KnowledgeHub.Application.Identity; + +public interface IWorkspaceRepository +{ + Task GetDefaultWorkspaceIdAsync(CancellationToken cancellationToken); + + Task CreateAsync(WorkspaceName name, DateTimeOffset createdAt, CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/PasswordSignInOutcome.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/PasswordSignInOutcome.cs new file mode 100644 index 0000000..05415e1 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/PasswordSignInOutcome.cs @@ -0,0 +1,24 @@ +namespace ItWorx.KnowledgeHub.Application.Identity; + +public enum PasswordSignInStatus +{ + Success, + InvalidCredentials, + LockedOut, + AccountDisabled +} + +public sealed record PasswordSignInOutcome(PasswordSignInStatus Status, UserAccount? Account) +{ + public static PasswordSignInOutcome Success(UserAccount account) => + new(PasswordSignInStatus.Success, account); + + public static readonly PasswordSignInOutcome InvalidCredentials = + new(PasswordSignInStatus.InvalidCredentials, null); + + public static readonly PasswordSignInOutcome LockedOut = + new(PasswordSignInStatus.LockedOut, null); + + public static readonly PasswordSignInOutcome AccountDisabled = + new(PasswordSignInStatus.AccountDisabled, null); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/CompleteFirstRunSetupUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/CompleteFirstRunSetupUseCase.cs new file mode 100644 index 0000000..0222363 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/CompleteFirstRunSetupUseCase.cs @@ -0,0 +1,106 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Domain.Identity; + +namespace ItWorx.KnowledgeHub.Application.Identity.UseCases; + +public enum CompleteFirstRunSetupStatus +{ + Success, + AlreadyCompleted, + EmailAlreadyInUse, + PasswordRejected +} + +public sealed record CompleteFirstRunSetupResult( + CompleteFirstRunSetupStatus Status, + Guid? WorkspaceId, + Guid? UserId, + IReadOnlyList Errors) +{ + public static CompleteFirstRunSetupResult Success(Guid workspaceId, Guid userId) => + new(CompleteFirstRunSetupStatus.Success, workspaceId, userId, []); + + public static CompleteFirstRunSetupResult Failure( + CompleteFirstRunSetupStatus status, + IReadOnlyList? errors = null) => + new(status, null, null, errors ?? []); +} + +public sealed record CompleteFirstRunSetupCommand( + string WorkspaceName, + string DisplayName, + string Email, + string Password, + string? CorrelationId); + +/// +/// Atomically creates the installation's default workspace, owner account and +/// administrator membership. Setup remains single-use and is protected by CSRF and +/// rate limiting. See docs/02-personas-and-user-journeys.md, journey J1. +/// +public sealed class CompleteFirstRunSetupUseCase( + IInstallationRepository installations, + IWorkspaceRepository workspaces, + IWorkspaceMembershipRepository memberships, + IUserAccountManager userAccounts, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync( + CompleteFirstRunSetupCommand command, + CancellationToken cancellationToken) + { + if (await installations.IsSetupCompleteAsync(cancellationToken)) + { + return CompleteFirstRunSetupResult.Failure(CompleteFirstRunSetupStatus.AlreadyCompleted); + } + + + if (!EmailAddress.TryCreate(command.Email, out var email)) + { + return CompleteFirstRunSetupResult.Failure( + CompleteFirstRunSetupStatus.PasswordRejected, + ["Het e-mailadres heeft geen geldige vorm."]); + } + + var displayName = DisplayName.Create(command.DisplayName); + var workspaceName = WorkspaceName.Create(command.WorkspaceName); + var now = timeProvider.GetUtcNow(); + + var creation = await userAccounts.CreateAsync(displayName, email!, command.Password, cancellationToken); + if (creation.Status == UserAccountCreationStatus.EmailAlreadyInUse) + { + return CompleteFirstRunSetupResult.Failure(CompleteFirstRunSetupStatus.EmailAlreadyInUse); + } + + if (creation.Status == UserAccountCreationStatus.PasswordRejected) + { + return CompleteFirstRunSetupResult.Failure( + CompleteFirstRunSetupStatus.PasswordRejected, + creation.Errors); + } + + var workspaceId = await workspaces.CreateAsync(workspaceName, now, cancellationToken); + await memberships.AddAsync(workspaceId, creation.Account!.Id, WorkspaceRole.Administrator, now, cancellationToken); + + // Setup completion happens only after the workspace, owner and membership + // exist, so a failure earlier in this method leaves setup retryable rather than + // half-completed with no way back in. + await installations.CompleteSetupAsync(now, cancellationToken); + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, + creation.Account.Id, + Source: "web", + Action: "installation.setup_completed", + TargetType: "workspace", + TargetId: workspaceId.ToString(), + Summary: "Eerste installatie voltooid; eigenaar aangemaakt.", + command.CorrelationId, + now), + cancellationToken); + + return CompleteFirstRunSetupResult.Success(workspaceId, creation.Account.Id); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/CreateWorkspaceUserUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/CreateWorkspaceUserUseCase.cs new file mode 100644 index 0000000..f7c1464 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/CreateWorkspaceUserUseCase.cs @@ -0,0 +1,109 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Domain.Identity; + +namespace ItWorx.KnowledgeHub.Application.Identity.UseCases; + +public enum CreateWorkspaceUserStatus +{ + Success, + EmailAlreadyInUse, + PasswordRejected, + ValidationFailed, + WorkspaceNotFound +} + +public sealed record CreateWorkspaceUserResult( + CreateWorkspaceUserStatus Status, + UserAccount? Account, + WorkspaceRole? Role, + IReadOnlyList Errors) +{ + public static CreateWorkspaceUserResult Success(UserAccount account, WorkspaceRole role) => + new(CreateWorkspaceUserStatus.Success, account, role, []); + + public static CreateWorkspaceUserResult Failure( + CreateWorkspaceUserStatus status, + IReadOnlyList? errors = null) => + new(status, null, null, errors ?? []); +} + +/// Administrator-only onboarding of another local account. There is no invite +/// email; the administrator sets an initial password and communicates it out of band, +/// consistent with the "no email provider required" design (docs/12-auth-security.md). +/// +public sealed class CreateWorkspaceUserUseCase( + IWorkspaceRepository workspaces, + IWorkspaceMembershipRepository memberships, + IUserAccountManager userAccounts, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync( + string displayName, + string email, + string password, + string role, + Guid createdByUserId, + string? correlationId, + CancellationToken cancellationToken) + { + if (!Enum.TryParse(role, ignoreCase: true, out var parsedRole)) + { + return CreateWorkspaceUserResult.Failure( + CreateWorkspaceUserStatus.ValidationFailed, + ["Onbekende rol."]); + } + + if (!EmailAddress.TryCreate(email, out var emailAddress)) + { + return CreateWorkspaceUserResult.Failure( + CreateWorkspaceUserStatus.ValidationFailed, + ["Het e-mailadres heeft geen geldige vorm."]); + } + + DisplayName parsedDisplayName; + try + { + parsedDisplayName = DisplayName.Create(displayName); + } + catch (ArgumentException ex) + { + return CreateWorkspaceUserResult.Failure(CreateWorkspaceUserStatus.ValidationFailed, [ex.Message]); + } + + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspaceId is null) + { + return CreateWorkspaceUserResult.Failure(CreateWorkspaceUserStatus.WorkspaceNotFound); + } + + var creation = await userAccounts.CreateAsync(parsedDisplayName, emailAddress!, password, cancellationToken); + if (creation.Status == UserAccountCreationStatus.EmailAlreadyInUse) + { + return CreateWorkspaceUserResult.Failure(CreateWorkspaceUserStatus.EmailAlreadyInUse); + } + + if (creation.Status == UserAccountCreationStatus.PasswordRejected) + { + return CreateWorkspaceUserResult.Failure(CreateWorkspaceUserStatus.PasswordRejected, creation.Errors); + } + + var now = timeProvider.GetUtcNow(); + await memberships.AddAsync(workspaceId.Value, creation.Account!.Id, parsedRole, now, cancellationToken); + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, + createdByUserId, + Source: "web", + Action: "user.created", + TargetType: "user", + TargetId: creation.Account.Id.ToString(), + Summary: $"Gebruiker aangemaakt met rol {parsedRole}.", + correlationId, + now), + cancellationToken); + + return CreateWorkspaceUserResult.Success(creation.Account, parsedRole); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/GetSessionUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/GetSessionUseCase.cs new file mode 100644 index 0000000..d1987f6 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/GetSessionUseCase.cs @@ -0,0 +1,44 @@ +using ItWorx.KnowledgeHub.Domain.Identity; + +namespace ItWorx.KnowledgeHub.Application.Identity.UseCases; + +public sealed record SessionInfo( + bool Authenticated, + bool SetupRequired, + Guid? UserId, + string? DisplayName, + string? Email, + Guid? WorkspaceId, + WorkspaceRole? Role); + +public sealed class GetSessionUseCase( + IInstallationRepository installations, + IUserAccountManager userAccounts, + IWorkspaceRepository workspaces, + IWorkspaceMembershipRepository memberships) +{ + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + var setupComplete = await installations.IsSetupCompleteAsync(cancellationToken); + var account = await userAccounts.GetSignedInAccountAsync(cancellationToken); + + if (account is null) + { + return new SessionInfo(false, !setupComplete, null, null, null, null, null); + } + + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + WorkspaceRole? role = workspaceId is null + ? null + : await memberships.GetRoleAsync(workspaceId.Value, account.Id, cancellationToken); + + return new SessionInfo( + true, + !setupComplete, + account.Id, + account.DisplayName, + account.Email, + workspaceId, + role); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/IssueRecoveryTokenUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/IssueRecoveryTokenUseCase.cs new file mode 100644 index 0000000..8ee81c4 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/IssueRecoveryTokenUseCase.cs @@ -0,0 +1,58 @@ +using ItWorx.KnowledgeHub.Application.Audit; + +namespace ItWorx.KnowledgeHub.Application.Identity.UseCases; + +public enum IssueRecoveryTokenStatus +{ + Success, + UserNotFound +} + +public sealed record IssueRecoveryTokenResult( + IssueRecoveryTokenStatus Status, + string? RawToken, + DateTimeOffset? ExpiresAt); + +/// Administrator-only: issues a single-use recovery token for another local +/// account, satisfying the "no email provider required" operator-issued recovery +/// journey (docs/12-auth-security.md). +public sealed class IssueRecoveryTokenUseCase( + IUserAccountManager userAccounts, + IRecoveryTokenService recoveryTokens, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync( + Guid targetUserId, + Guid issuedByUserId, + string? correlationId, + CancellationToken cancellationToken) + { + var account = await userAccounts.FindByIdAsync(targetUserId, cancellationToken); + if (account is null) + { + return new IssueRecoveryTokenResult(IssueRecoveryTokenStatus.UserNotFound, null, null); + } + + var issued = await recoveryTokens.IssueAsync(targetUserId, issuedByUserId, cancellationToken); + + // Deliberately never include the raw token in the audit summary. + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, + issuedByUserId, + Source: "web", + Action: "auth.recovery_token_issued", + TargetType: "user", + TargetId: targetUserId.ToString(), + Summary: "Herstelcode uitgegeven door beheerder.", + correlationId, + timeProvider.GetUtcNow()), + cancellationToken); + + return new IssueRecoveryTokenResult( + IssueRecoveryTokenStatus.Success, + issued.RawToken, + issued.ExpiresAt); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/ListWorkspaceUsersUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/ListWorkspaceUsersUseCase.cs new file mode 100644 index 0000000..265caad --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/ListWorkspaceUsersUseCase.cs @@ -0,0 +1,37 @@ +using ItWorx.KnowledgeHub.Domain.Identity; + +namespace ItWorx.KnowledgeHub.Application.Identity.UseCases; + +public sealed record WorkspaceUserSummary(UserAccount Account, WorkspaceRole Role); + +public sealed class ListWorkspaceUsersUseCase( + IWorkspaceRepository workspaces, + IWorkspaceMembershipRepository memberships, + IUserAccountManager userAccounts) +{ + public async Task> ExecuteAsync(CancellationToken cancellationToken) + { + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspaceId is null) + { + return []; + } + + var membershipList = await memberships.ListAsync(workspaceId.Value, cancellationToken); + var accounts = await userAccounts.ListAsync(cancellationToken); + var accountsById = accounts.ToDictionary(account => account.Id); + + var result = new List(membershipList.Count); + foreach (var membership in membershipList) + { + if (accountsById.TryGetValue(membership.UserId, out var account)) + { + result.Add(new WorkspaceUserSummary(account, membership.Role)); + } + } + + return result + .OrderBy(summary => summary.Account.DisplayName, StringComparer.Ordinal) + .ToList(); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/LoginUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/LoginUseCase.cs new file mode 100644 index 0000000..f143dd3 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/LoginUseCase.cs @@ -0,0 +1,58 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Domain.Identity; + +namespace ItWorx.KnowledgeHub.Application.Identity.UseCases; + +public sealed record LoginCommand(string Email, string Password, string? CorrelationId); + +public sealed class LoginUseCase( + IUserAccountManager userAccounts, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync(LoginCommand command, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + + // An invalid email shape is treated identically to "invalid credentials" so the + // response never confirms whether an account with that address exists. + if (!EmailAddress.TryCreate(command.Email, out var email)) + { + await RecordAsync(null, "auth.login_failed", "Ongeldige aanmeldpoging.", command.CorrelationId, now, cancellationToken); + return PasswordSignInOutcome.InvalidCredentials; + } + + var outcome = await userAccounts.PasswordSignInAsync(email!, command.Password, cancellationToken); + + var (action, summary) = outcome.Status switch + { + PasswordSignInStatus.Success => ("auth.login_succeeded", "Aanmelding geslaagd."), + PasswordSignInStatus.LockedOut => ("auth.login_locked_out", "Account tijdelijk vergrendeld na herhaalde mislukte pogingen."), + PasswordSignInStatus.AccountDisabled => ("auth.login_failed", "Aanmeldpoging voor uitgeschakeld account."), + _ => ("auth.login_failed", "Ongeldige aanmeldpoging.") + }; + + await RecordAsync(outcome.Account?.Id, action, summary, command.CorrelationId, now, cancellationToken); + return outcome; + } + + private Task RecordAsync( + Guid? userId, + string action, + string summary, + string? correlationId, + DateTimeOffset now, + CancellationToken cancellationToken) => + auditEvents.RecordAsync( + new AuditEventRecord( + userId is null ? AuditActorKind.System : AuditActorKind.User, + userId, + Source: "web", + Action: action, + TargetType: userId is null ? null : "user", + TargetId: userId?.ToString(), + Summary: summary, + correlationId, + now), + cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/LogoutUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/LogoutUseCase.cs new file mode 100644 index 0000000..e4f8887 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/LogoutUseCase.cs @@ -0,0 +1,31 @@ +using ItWorx.KnowledgeHub.Application.Audit; + +namespace ItWorx.KnowledgeHub.Application.Identity.UseCases; + +public sealed class LogoutUseCase( + IUserAccountManager userAccounts, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync(string? correlationId, CancellationToken cancellationToken) + { + var account = await userAccounts.GetSignedInAccountAsync(cancellationToken); + await userAccounts.SignOutAsync(cancellationToken); + + if (account is not null) + { + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, + account.Id, + Source: "web", + Action: "auth.logout", + TargetType: "user", + TargetId: account.Id.ToString(), + Summary: "Afgemeld.", + correlationId, + timeProvider.GetUtcNow()), + cancellationToken); + } + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/RedeemRecoveryTokenUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/RedeemRecoveryTokenUseCase.cs new file mode 100644 index 0000000..34e85d4 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/RedeemRecoveryTokenUseCase.cs @@ -0,0 +1,70 @@ +using ItWorx.KnowledgeHub.Application.Audit; + +namespace ItWorx.KnowledgeHub.Application.Identity.UseCases; + +public enum RedeemRecoveryTokenStatus +{ + Success, + InvalidOrExpiredToken, + PasswordRejected +} + +public sealed record RedeemRecoveryTokenResult( + RedeemRecoveryTokenStatus Status, + IReadOnlyList Errors) +{ + public static readonly RedeemRecoveryTokenResult Success = + new(RedeemRecoveryTokenStatus.Success, []); + + public static readonly RedeemRecoveryTokenResult InvalidOrExpiredToken = + new(RedeemRecoveryTokenStatus.InvalidOrExpiredToken, []); + + public static RedeemRecoveryTokenResult PasswordRejected(IReadOnlyList errors) => + new(RedeemRecoveryTokenStatus.PasswordRejected, errors); +} + +public sealed class RedeemRecoveryTokenUseCase( + IRecoveryTokenService recoveryTokens, + IUserAccountManager userAccounts, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync( + string rawToken, + string newPassword, + string? correlationId, + CancellationToken cancellationToken) + { + // The token is consumed atomically on lookup, before the new password is + // validated. This is a deliberate trade-off: it prevents a single leaked/guessed + // token from being replayed against multiple candidate passwords, at the cost of + // requiring the operator to issue a fresh token if the first attempted password + // is rejected. + var userId = await recoveryTokens.RedeemAsync(rawToken, cancellationToken); + if (userId is null) + { + return RedeemRecoveryTokenResult.InvalidOrExpiredToken; + } + + var errors = await userAccounts.SetPasswordDirectAsync(userId.Value, newPassword, cancellationToken); + if (errors.Count > 0) + { + return RedeemRecoveryTokenResult.PasswordRejected(errors); + } + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, + userId, + Source: "web", + Action: "auth.recovery_token_redeemed", + TargetType: "user", + TargetId: userId.Value.ToString(), + Summary: "Wachtwoord hersteld via eenmalige herstelcode.", + correlationId, + timeProvider.GetUtcNow()), + cancellationToken); + + return RedeemRecoveryTokenResult.Success; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/SearchWorkspaceMembersUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/SearchWorkspaceMembersUseCase.cs new file mode 100644 index 0000000..6e38b4a --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/SearchWorkspaceMembersUseCase.cs @@ -0,0 +1,54 @@ +namespace ItWorx.KnowledgeHub.Application.Identity.UseCases; + +public sealed record WorkspaceMemberSearchResult(Guid Id, string DisplayName, string Email); + +/// +/// Backs the accessible owner picker (docs/adr/ADR-029-v2-r5-final-hardening.md, +/// brief Phase 7) — replaces manual `OwnerId`/GUID entry in editorial-profile and +/// project forms. Deliberately narrower than ListWorkspaceUsersUseCase (the +/// Administrator-only `GET /admin/users`): only enabled members of the current +/// workspace, only the three fields a picker needs, and reachable by any Reader — +/// never a way to enumerate accounts outside the workspace or disabled accounts. +/// +public sealed class SearchWorkspaceMembersUseCase( + IWorkspaceRepository workspaces, IWorkspaceMembershipRepository memberships, IUserAccountManager userAccounts) +{ + private const int MaxResults = 25; + + public async Task> ExecuteAsync( + string? query, CancellationToken cancellationToken) + { + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + if (workspaceId is null) + { + return []; + } + + var membershipList = await memberships.ListAsync(workspaceId.Value, cancellationToken); + if (membershipList.Count == 0) + { + return []; + } + + var accounts = await userAccounts.ListAsync(cancellationToken); + var accountsById = accounts.ToDictionary(account => account.Id); + + IEnumerable candidates = membershipList + .Select(membership => accountsById.GetValueOrDefault(membership.UserId)) + .Where(account => account is not null && account.Enabled)!; + + var trimmedQuery = query?.Trim(); + if (!string.IsNullOrEmpty(trimmedQuery)) + { + candidates = candidates.Where(account => + account.DisplayName.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase) + || account.Email.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase)); + } + + return candidates + .OrderBy(account => account.DisplayName, StringComparer.Ordinal) + .Take(MaxResults) + .Select(account => new WorkspaceMemberSearchResult(account.Id, account.DisplayName, account.Email)) + .ToList(); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/UpdateWorkspaceUserUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/UpdateWorkspaceUserUseCase.cs new file mode 100644 index 0000000..4ff5bbf --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/UpdateWorkspaceUserUseCase.cs @@ -0,0 +1,135 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Domain.Identity; + +namespace ItWorx.KnowledgeHub.Application.Identity.UseCases; + +public enum UpdateWorkspaceUserStatus +{ + Success, + UserNotFound, + ValidationFailed, + WouldRemoveLastAdministrator +} + +public sealed record UpdateWorkspaceUserResult(UpdateWorkspaceUserStatus Status, IReadOnlyList Errors) +{ + public static readonly UpdateWorkspaceUserResult Success = new(UpdateWorkspaceUserStatus.Success, []); + + public static UpdateWorkspaceUserResult Failure(UpdateWorkspaceUserStatus status, IReadOnlyList? errors = null) => + new(status, errors ?? []); +} + +/// Changes a member's role and/or enabled state. Enforces the "at least one +/// enabled administrator" invariant (docs/07-domain-model.md) before applying either +/// change, since both can remove the last enabled administrator. +public sealed class UpdateWorkspaceUserUseCase( + IWorkspaceRepository workspaces, + IWorkspaceMembershipRepository memberships, + IUserAccountManager userAccounts, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync( + Guid targetUserId, + string? role, + bool? enabled, + Guid updatedByUserId, + string? correlationId, + CancellationToken cancellationToken) + { + WorkspaceRole? parsedRole = null; + if (role is not null) + { + if (!Enum.TryParse(role, ignoreCase: true, out var value)) + { + return UpdateWorkspaceUserResult.Failure( + UpdateWorkspaceUserStatus.ValidationFailed, + ["Onbekende rol."]); + } + + parsedRole = value; + } + + var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken); + var account = await userAccounts.FindByIdAsync(targetUserId, cancellationToken); + var currentRole = workspaceId is null + ? null + : await memberships.GetRoleAsync(workspaceId.Value, targetUserId, cancellationToken); + + if (workspaceId is null || account is null || currentRole is null) + { + return UpdateWorkspaceUserResult.Failure(UpdateWorkspaceUserStatus.UserNotFound); + } + + var willStayAdministrator = (parsedRole ?? currentRole.Value) == WorkspaceRole.Administrator; + var willStayEnabled = enabled ?? account.Enabled; + var losesAdministratorCoverage = + currentRole.Value == WorkspaceRole.Administrator + && account.Enabled + && !(willStayAdministrator && willStayEnabled); + + if (losesAdministratorCoverage) + { + var remainingEnabledAdministrators = await CountOtherEnabledAdministratorsAsync( + workspaceId.Value, + excludingUserId: targetUserId, + cancellationToken); + + if (remainingEnabledAdministrators == 0) + { + return UpdateWorkspaceUserResult.Failure(UpdateWorkspaceUserStatus.WouldRemoveLastAdministrator); + } + } + + var now = timeProvider.GetUtcNow(); + + if (parsedRole is not null && parsedRole.Value != currentRole.Value) + { + await memberships.ChangeRoleAsync(workspaceId.Value, targetUserId, parsedRole.Value, now, cancellationToken); + } + + if (enabled is not null && enabled.Value != account.Enabled) + { + await userAccounts.SetEnabledAsync(targetUserId, enabled.Value, cancellationToken); + } + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, + updatedByUserId, + Source: "web", + Action: "user.updated", + TargetType: "user", + TargetId: targetUserId.ToString(), + Summary: "Gebruikersrol/-status bijgewerkt door beheerder.", + correlationId, + now), + cancellationToken); + + return UpdateWorkspaceUserResult.Success; + } + + private async Task CountOtherEnabledAdministratorsAsync( + Guid workspaceId, + Guid excludingUserId, + CancellationToken cancellationToken) + { + var membershipList = await memberships.ListAsync(workspaceId, cancellationToken); + var administratorIds = membershipList + .Where(membership => membership.Role == WorkspaceRole.Administrator && membership.UserId != excludingUserId) + .Select(membership => membership.UserId) + .ToList(); + + var count = 0; + foreach (var administratorId in administratorIds) + { + var administratorAccount = await userAccounts.FindByIdAsync(administratorId, cancellationToken); + if (administratorAccount is { Enabled: true }) + { + count++; + } + } + + return count; + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/UserAccount.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UserAccount.cs new file mode 100644 index 0000000..4a85ed6 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UserAccount.cs @@ -0,0 +1,11 @@ +namespace ItWorx.KnowledgeHub.Application.Identity; + +/// Application-level projection of an interactive local account. Never carries +/// the password hash, security stamp or any other secret material. +public sealed record UserAccount( + Guid Id, + string DisplayName, + string Email, + bool Enabled, + DateTimeOffset CreatedAt, + DateTimeOffset? LastLoginAt); diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Identity/UserAccountCreationResult.cs b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UserAccountCreationResult.cs new file mode 100644 index 0000000..bbb3e58 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Identity/UserAccountCreationResult.cs @@ -0,0 +1,23 @@ +namespace ItWorx.KnowledgeHub.Application.Identity; + +public enum UserAccountCreationStatus +{ + Success, + EmailAlreadyInUse, + PasswordRejected +} + +public sealed record UserAccountCreationResult( + UserAccountCreationStatus Status, + UserAccount? Account, + IReadOnlyList Errors) +{ + public static UserAccountCreationResult Success(UserAccount account) => + new(UserAccountCreationStatus.Success, account, []); + + public static UserAccountCreationResult EmailAlreadyInUse() => + new(UserAccountCreationStatus.EmailAlreadyInUse, null, ["Dit e-mailadres is al in gebruik."]); + + public static UserAccountCreationResult PasswordRejected(IReadOnlyList errors) => + new(UserAccountCreationStatus.PasswordRejected, null, errors); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/IInfrastructureRepository.cs b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/IInfrastructureRepository.cs new file mode 100644 index 0000000..7c76405 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/IInfrastructureRepository.cs @@ -0,0 +1,209 @@ +using ItWorx.KnowledgeHub.Domain.Infrastructure; + +namespace ItWorx.KnowledgeHub.Application.Infrastructure; + +public sealed record InfrastructureServiceGraph( + InfrastructureService Service, + EditorialProfile? EditorialProfile, + string EditorialETag, + IReadOnlyList Overrides, + IReadOnlyList Components, + IReadOnlyList Endpoints, + IReadOnlyList Networks, + IReadOnlyList Volumes, + IReadOnlyList Databases, + IReadOnlyList Dependencies, + IReadOnlyList HealthObservations, + IReadOnlyList Deployments, + IReadOnlyList Backups); + +/// A catalog-list row with only what a summary view actually renders — +/// counts, not the full related-fact rows +/// carries for a single service's detail page +/// (docs/adr/ADR-026-v2-r2-infrastructure-scale-and-sync-durability.md, V2-R2). +/// +public sealed record InfrastructureServiceSummaryRow( + InfrastructureService Service, + EditorialProfile? EditorialProfile, + string? ProjectName, + string? PrimaryEndpointUrl, + int ComponentCount, + int EndpointCount, + int VolumeCount, + int DatabaseCount); + +/// One cursor page of the service catalog, plus how many services the +/// current filters match in total. is deliberately the +/// count of the *filtered* catalog rather than of the returned page: an operator +/// filtering 80 services down to 6 needs to see "6", and needs to see it before +/// paging to the end. +public sealed record InfrastructureServiceSummaryPage( + IReadOnlyList Items, string? NextCursor, int TotalCount); + +public enum ServiceCatalogStatus { Attention, Healthy, Missing } + +/// A single volume plus just enough about its owning service to render a +/// storage row — not the service's other eight fact types +/// (V2-R2, same over-fetch class of finding as +/// ). +/// already applies the same editorial-display-name-over-detected-name preference +/// InfrastructureEndpoints.ToSummary uses elsewhere, so the storage view's +/// service names never silently regress to the raw detected name. +public sealed record InfrastructureVolumeRow( + InfrastructureService Service, InfrastructureVolume Volume, string ServiceDisplayName); + +/// Cursor-paginated storage listing (V2-R5, +/// docs/adr/ADR-029-v2-r5-final-hardening.md) — the same `{ items, nextCursor }` shape +/// already established for services (V2-R2). Previously an unbounded, unpaginated +/// full-table listing. +public sealed record InfrastructureVolumePage(IReadOnlyList Items, string? NextCursor); + +public sealed record InfrastructureOverviewData( + InventorySnapshot? LatestSnapshot, + IReadOnlyList Hosts, + int ServiceCount, + int ProposedServiceCount, + int MissingServiceCount, + int UnhealthyServiceCount, + int UnprotectedServiceCount); + +public sealed record InventoryHostInput( + string SourceKey, + string Hostname, + string? OperatingSystem, + string? KernelVersion, + string? Architecture, + int LogicalCpuCount, + long TotalMemoryBytes); + +public sealed record InventoryComponentInput( + string SourceKey, string Name, string? Kind, string? RuntimeId, string? Status, string? ImageReference, FactConfidence Confidence); + +public sealed record InventoryEndpointInput( + string SourceKey, string? Scheme, string? Host, int? PublishedPort, int? TargetPort, string? Url, bool IsPublic, FactConfidence Confidence); + +public sealed record InventoryNetworkInput( + string SourceKey, string Name, string? Driver, string? Scope, bool Internal, FactConfidence Confidence); + +public sealed record InventoryVolumeInput( + string SourceKey, string Name, string? MountPath, string? StorageClass, bool ReadOnly, long? SizeBytes, FactConfidence Confidence); + +public sealed record InventoryDatabaseInput( + string SourceKey, string Name, string? Engine, string? Version, string? Host, int? Port, bool HostPortPublished, FactConfidence Confidence); + +public sealed record InventoryDependencyInput( + string SourceKey, string DependsOnSourceKey, string? Kind, FactConfidence Confidence); + +public sealed record InventoryHealthInput( + string SourceKey, HealthState State, string? CheckKind, int? ResponseTimeMilliseconds, FactConfidence Confidence); + +public sealed record InventoryDeploymentInput( + string SourceKey, string? RepositoryUrl, string? Commit, string? ImageReference, string? ImageDigest, + DateTimeOffset? DeployedAt, FactConfidence Confidence); + +public sealed record InventoryBackupInput( + string SourceKey, string? Method, string? Schedule, DateTimeOffset? LastSuccessfulAt, bool RestoreTested, FactConfidence Confidence); + +public sealed record InventoryServiceInput( + string SourceKey, + string? HostSourceKey, + string DetectedName, + string? RuntimeKind, + string? RuntimeStatus, + HealthState Health, + string? ImageReference, + string? ImageDigest, + string? RuntimeUser, + bool ReadOnlyRootFilesystem, + bool NoNewPrivileges, + bool Privileged, + FactConfidence Confidence, + IReadOnlyList Components, + IReadOnlyList Endpoints, + IReadOnlyList Networks, + IReadOnlyList Volumes, + IReadOnlyList Databases, + IReadOnlyList Dependencies, + IReadOnlyList HealthObservations, + IReadOnlyList Deployments, + IReadOnlyList Backups); + +public sealed record InventorySnapshotInput( + string SourceKey, + string SourceHash, + DateTimeOffset CapturedAt, + IReadOnlyList Hosts, + IReadOnlyList Services); + +public sealed record InventorySyncResult( + Guid SnapshotId, + bool AlreadyImported, + int AddedServices, + int UpdatedServices, + int MissingServices, + int PreservedEditorialProfiles, + int PreservedOverrides); + +public interface IInfrastructureRepository +{ + Task GetOverviewAsync(Guid workspaceId, CancellationToken cancellationToken); + + /// Every service's full related-fact graph, unpaginated — kept only for + /// the curated-import package matcher (ImportCuratedInventoryPackageUseCase), + /// an infrequent, admin-triggered, already inventory-size-bounded batch operation + /// that genuinely needs the whole set. Never call this from an HTTP list endpoint — + /// use instead (V2-R2, docs/adr/ADR-026- + /// v2-r2-infrastructure-scale-and-sync-durability.md). + Task> ListServicesAsync( + Guid workspaceId, bool includeHidden, CancellationToken cancellationToken); + + /// Cursor-paginated, summary-only catalog listing (V2-R2): pages the + /// service rows first, then fetches only editorial profiles and fact *counts* for + /// the services on that one page — never every related fact row for every service + /// in the workspace, regardless of workspace size. + /// (V2-R5) is applied server-side on the same paginated query — the fix for the + /// Project workspace previously fetching up to 200 services and filtering by + /// project client-side (docs/adr/ADR-029-v2-r5-final-hardening.md). + Task ListServiceSummariesAsync( + Guid workspaceId, bool includeHidden, Guid? projectId, string? search, ServiceCatalogStatus? status, string? cursor, int limit, + CancellationToken cancellationToken); + + /// Cursor-paginated storage listing (V2-R5) — every volume across the + /// workspace's services, only volumes and each owning service's display fields, not + /// the other eight fact types a full + /// carries (V2-R2). matches case-insensitively against + /// the owning service's detected name and the volume's own name/mount path, + /// server-side — never only within an already-loaded page. + Task ListVolumesAsync( + Guid workspaceId, string? search, string? cursor, int limit, CancellationToken cancellationToken); + + Task GetServiceAsync(Guid workspaceId, Guid serviceId, CancellationToken cancellationToken); + + Task FindServiceForReviewAsync( + Guid workspaceId, Guid serviceId, CancellationToken cancellationToken); + + Task> ListChangesAsync( + Guid workspaceId, int limit, CancellationToken cancellationToken); + + Task ApplySnapshotAsync( + Guid workspaceId, InventorySnapshotInput input, DateTimeOffset importedAt, CancellationToken cancellationToken); + + Task FindEditorialProfileForUpdateAsync( + Guid workspaceId, InfrastructureObjectKind targetKind, Guid targetId, string expectedETag, CancellationToken cancellationToken); + + Task TargetExistsAsync( + Guid workspaceId, InfrastructureObjectKind targetKind, Guid targetId, CancellationToken cancellationToken); + + void AddEditorialProfile(EditorialProfile profile); + + Task FindOverrideAsync( + Guid workspaceId, InfrastructureObjectKind targetKind, Guid targetId, string fieldName, CancellationToken cancellationToken); + + void AddOverride(ManualOverride manualOverride); + + Task RemoveOverrideAsync(Guid workspaceId, Guid overrideId, CancellationToken cancellationToken); + + Task SaveChangesAsync(CancellationToken cancellationToken); + + Task GetEditorialETagAsync(Guid profileId, CancellationToken cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ImportCuratedInventoryPackageUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ImportCuratedInventoryPackageUseCase.cs new file mode 100644 index 0000000..bc62980 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ImportCuratedInventoryPackageUseCase.cs @@ -0,0 +1,119 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Application.Common; +using ItWorx.KnowledgeHub.Domain.Automation; +using ItWorx.KnowledgeHub.Domain.Infrastructure; + +namespace ItWorx.KnowledgeHub.Application.Infrastructure.UseCases; + +public sealed record CuratedServiceProfileInput( + string SourceKey, + string? DisplayName, + string? Summary, + string? Category, + string? Purpose, + string? RepositoryUrl, + string? LiveUrl, + IReadOnlyList Tags, + bool Reviewed, + bool Archived); + +public sealed record ImportCuratedInventoryPackageCommand( + Guid WorkspaceId, + InventorySnapshotInput Inventory, + IReadOnlyList Services, + Guid ActorId, + string? CorrelationId); + +public sealed record CuratedInventoryImportResult( + InventorySyncResult Inventory, + int CreatedEditorialProfiles, + int ConfirmedServices, + int ArchivedServices); + +public sealed class ImportCuratedInventoryPackageUseCase( + IInfrastructureRepository infrastructure, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task> ExecuteAsync( + ImportCuratedInventoryPackageCommand command, + CancellationToken cancellationToken) + { + var inventoryKeys = command.Inventory.Services + .Select(service => service.SourceKey.Trim()) + .ToHashSet(StringComparer.Ordinal); + var duplicateProfiles = command.Services + .GroupBy(profile => profile.SourceKey.Trim(), StringComparer.Ordinal) + .Any(group => group.Count() > 1); + if (duplicateProfiles || command.Services.Any(profile => !inventoryKeys.Contains(profile.SourceKey.Trim()))) + { + return OperationResultValue.Failure( + OperationStatus.ValidationFailed, + ["Elk redactioneel profiel moet uniek zijn en naar een service in dezelfde inventarissnapshot verwijzen."]); + } + + var now = timeProvider.GetUtcNow(); + var sync = await infrastructure.ApplySnapshotAsync( + command.WorkspaceId, command.Inventory, now, cancellationToken); + var graphs = await infrastructure.ListServicesAsync( + command.WorkspaceId, includeHidden: true, cancellationToken); + var bySource = graphs.ToDictionary(graph => graph.Service.SourceKey, StringComparer.Ordinal); + var createdProfiles = 0; + var confirmed = 0; + var archived = 0; + + foreach (var input in command.Services) + { + var graph = bySource[input.SourceKey.Trim()]; + var service = await infrastructure.FindServiceForReviewAsync( + command.WorkspaceId, graph.Service.Id, cancellationToken); + if (service is null) + { + return OperationResultValue.Failure(OperationStatus.NotFound); + } + + if (input.Archived && service.Presence == InventoryPresence.Proposed) + { + service.Reject(); + archived++; + } + else if (input.Reviewed && service.Presence == InventoryPresence.Proposed) + { + service.Confirm(); + confirmed++; + } + + if (graph.EditorialProfile is not null) + { + continue; + } + + var profile = EditorialProfile.Create( + command.WorkspaceId, InfrastructureObjectKind.Service, service.Id, command.ActorId, now); + profile.Update( + input.DisplayName, input.Summary, input.Category, input.Purpose, + null, null, input.RepositoryUrl, input.LiveUrl, null, null, + null, null, null, null, input.Tags, + false, false, input.Archived, command.ActorId, now); + infrastructure.AddEditorialProfile(profile); + createdProfiles++; + } + + await infrastructure.SaveChangesAsync(cancellationToken); + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, + command.ActorId, + "web", + "infrastructure.curated-package-imported", + "inventory-snapshot", + sync.SnapshotId.ToString(), + $"Gecureerde inventaris verwerkt: {createdProfiles} profielen, {confirmed} bevestigd, {archived} gearchiveerd.", + command.CorrelationId, + now), + cancellationToken); + + return OperationResultValue.Success( + new CuratedInventoryImportResult(sync, createdProfiles, confirmed, archived)); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/InfrastructureQueryUseCases.cs b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/InfrastructureQueryUseCases.cs new file mode 100644 index 0000000..239721e --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/InfrastructureQueryUseCases.cs @@ -0,0 +1,54 @@ +namespace ItWorx.KnowledgeHub.Application.Infrastructure.UseCases; + +public sealed class GetInfrastructureOverviewUseCase(IInfrastructureRepository infrastructure) +{ + public Task ExecuteAsync(Guid workspaceId, CancellationToken cancellationToken) => + infrastructure.GetOverviewAsync(workspaceId, cancellationToken); +} +/// Unpaginated full-graph listing — reserved for the curated-import matcher +/// (see 's own doc comment). +/// Not used by any HTTP endpoint since V2-R2; kept for that one internal caller. +/// +public sealed class ListInfrastructureServicesUseCase(IInfrastructureRepository infrastructure) +{ + public Task> ExecuteAsync( + Guid workspaceId, bool includeHidden, CancellationToken cancellationToken) => + infrastructure.ListServicesAsync(workspaceId, includeHidden, cancellationToken); +} + +/// Cursor-paginated, summary-only catalog listing (V2-R2, +/// docs/adr/ADR-026-v2-r2-infrastructure-scale-and-sync-durability.md) — what +/// `GET /infrastructure/services` actually uses now. +public sealed class ListInfrastructureServiceSummariesUseCase(IInfrastructureRepository infrastructure) +{ + public Task ExecuteAsync( + Guid workspaceId, bool includeHidden, Guid? projectId, string? search, string? status, string? cursor, int limit, + CancellationToken cancellationToken) => + infrastructure.ListServiceSummariesAsync( + workspaceId, includeHidden, projectId, search, + Enum.TryParse(status, true, out var parsedStatus) ? parsedStatus : null, + cursor, limit, cancellationToken); +} + +/// Cursor-paginated, lean-projected storage listing (V2-R2 fields, V2-R5 +/// pagination) — what `GET /infrastructure/storage` actually uses now. +public sealed class ListInfrastructureVolumesUseCase(IInfrastructureRepository infrastructure) +{ + public Task ExecuteAsync( + Guid workspaceId, string? search, string? cursor, int limit, CancellationToken cancellationToken) => + infrastructure.ListVolumesAsync(workspaceId, search, cursor, limit, cancellationToken); +} + +public sealed class GetInfrastructureServiceUseCase(IInfrastructureRepository infrastructure) +{ + public Task ExecuteAsync( + Guid workspaceId, Guid serviceId, CancellationToken cancellationToken) => + infrastructure.GetServiceAsync(workspaceId, serviceId, cancellationToken); +} + +public sealed class ListInfrastructureChangesUseCase(IInfrastructureRepository infrastructure) +{ + public Task> ExecuteAsync( + Guid workspaceId, int limit, CancellationToken cancellationToken) => + infrastructure.ListChangesAsync(workspaceId, Math.Clamp(limit, 1, 500), cancellationToken); +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ManageManualOverrideUseCases.cs b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ManageManualOverrideUseCases.cs new file mode 100644 index 0000000..ea12c06 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ManageManualOverrideUseCases.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Application.Common; +using ItWorx.KnowledgeHub.Application.Infrastructure.Validation; +using ItWorx.KnowledgeHub.Domain.Infrastructure; + +namespace ItWorx.KnowledgeHub.Application.Infrastructure.UseCases; + +public sealed record SetManualOverrideCommand( + Guid WorkspaceId, InfrastructureObjectKind TargetKind, Guid TargetId, string FieldName, + string ValueJson, string? Reason, Guid ActorId, string? CorrelationId); + +public sealed class SetManualOverrideUseCase( + IInfrastructureRepository infrastructure, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task> ExecuteAsync(SetManualOverrideCommand command, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(command.FieldName) || string.IsNullOrWhiteSpace(command.ValueJson)) + { + return OperationResultValue.Failure(OperationStatus.ValidationFailed, ["Veldnaam en overridewaarde zijn verplicht."]); + } + + JsonElement value; + try + { + using var document = JsonDocument.Parse(command.ValueJson); + value = document.RootElement.Clone(); + } + catch (JsonException) + { + return OperationResultValue.Failure(OperationStatus.ValidationFailed, ["Overridewaarde is geen geldige JSON."]); + } + + var catalogErrors = ManualOverrideCatalog.Validate(command.TargetKind, command.FieldName.Trim(), value, command.Reason); + if (catalogErrors.Count > 0) + { + return OperationResultValue.Failure(OperationStatus.ValidationFailed, catalogErrors); + } + + if (!await infrastructure.TargetExistsAsync(command.WorkspaceId, command.TargetKind, command.TargetId, cancellationToken)) + { + return OperationResultValue.Failure(OperationStatus.NotFound); + } + + var now = timeProvider.GetUtcNow(); + var manualOverride = await infrastructure.FindOverrideAsync( + command.WorkspaceId, command.TargetKind, command.TargetId, command.FieldName.Trim(), cancellationToken); + + if (manualOverride is null) + { + manualOverride = ManualOverride.Create( + command.WorkspaceId, command.TargetKind, command.TargetId, command.FieldName, + command.ValueJson, command.Reason, command.ActorId, now); + infrastructure.AddOverride(manualOverride); + } + else + { + manualOverride.Replace(command.ValueJson, command.Reason, command.ActorId, now); + } + + await infrastructure.SaveChangesAsync(cancellationToken); + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, command.ActorId, "web", "infrastructure.override-set", + command.TargetKind.ToString().ToLowerInvariant(), command.TargetId.ToString(), + $"Handmatige correctie voor veld '{command.FieldName.Trim()}' opgeslagen.", command.CorrelationId, now), + cancellationToken); + + return OperationResultValue.Success(manualOverride.Id); + } +} + +public sealed class RemoveManualOverrideUseCase( + IInfrastructureRepository infrastructure, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task ExecuteAsync( + Guid workspaceId, Guid overrideId, Guid actorId, string? correlationId, CancellationToken cancellationToken) + { + try + { + await infrastructure.RemoveOverrideAsync(workspaceId, overrideId, cancellationToken); + await infrastructure.SaveChangesAsync(cancellationToken); + } + catch (KeyNotFoundException) + { + return OperationResult.Failure(OperationStatus.NotFound); + } + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, actorId, "web", "infrastructure.override-removed", + "manual-override", overrideId.ToString(), "Handmatige correctie verwijderd.", + correlationId, timeProvider.GetUtcNow()), + cancellationToken); + + return OperationResult.Success(); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ReviewInfrastructureServiceUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ReviewInfrastructureServiceUseCase.cs new file mode 100644 index 0000000..0b7cf85 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ReviewInfrastructureServiceUseCase.cs @@ -0,0 +1,88 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Application.Common; +using ItWorx.KnowledgeHub.Domain.Automation; +using ItWorx.KnowledgeHub.Domain.Infrastructure; + +namespace ItWorx.KnowledgeHub.Application.Infrastructure.UseCases; + +public enum InfrastructureServiceReviewDecision +{ + Accept, + Reject +} + +public sealed record ReviewInfrastructureServiceCommand( + Guid WorkspaceId, + Guid ServiceId, + InfrastructureServiceReviewDecision Decision, + string? Reason, + Guid ActorId, + string? CorrelationId); + +public sealed class ReviewInfrastructureServiceUseCase( + IInfrastructureRepository infrastructure, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task> ExecuteAsync( + ReviewInfrastructureServiceCommand command, + CancellationToken cancellationToken) + { + var service = await infrastructure.FindServiceForReviewAsync( + command.WorkspaceId, command.ServiceId, cancellationToken); + if (service is null) + { + return OperationResultValue.Failure(OperationStatus.NotFound); + } + + var targetPresence = command.Decision == InfrastructureServiceReviewDecision.Accept + ? InventoryPresence.Present + : InventoryPresence.Archived; + if (service.Presence == targetPresence) + { + return OperationResultValue.Success(targetPresence); + } + + if (service.Presence != InventoryPresence.Proposed) + { + return OperationResultValue.Failure( + OperationStatus.ValidationFailed, + ["Alleen een voorgestelde service kan worden bevestigd of afgewezen."]); + } + + if (command.Reason?.Trim().Length > 500) + { + return OperationResultValue.Failure( + OperationStatus.ValidationFailed, + ["De reden mag maximaal 500 tekens bevatten."]); + } + + if (command.Decision == InfrastructureServiceReviewDecision.Accept) + { + service.Confirm(); + } + else + { + service.Reject(); + } + + await infrastructure.SaveChangesAsync(cancellationToken); + var accepted = command.Decision == InfrastructureServiceReviewDecision.Accept; + var decision = accepted ? "bevestigd" : "afgewezen"; + var reason = string.IsNullOrWhiteSpace(command.Reason) ? string.Empty : $" Reden: {command.Reason.Trim()}"; + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, + command.ActorId, + "web", + accepted ? "infrastructure.service-accepted" : "infrastructure.service-rejected", + "service", + service.Id.ToString(), + $"Voorgestelde service '{service.SourceKey}' is {decision}.{reason}", + command.CorrelationId, + timeProvider.GetUtcNow()), + cancellationToken); + + return OperationResultValue.Success(service.Presence); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/SynchronizeInfrastructureInventoryUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/SynchronizeInfrastructureInventoryUseCase.cs new file mode 100644 index 0000000..03401f3 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/SynchronizeInfrastructureInventoryUseCase.cs @@ -0,0 +1,58 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Application.Common; + +namespace ItWorx.KnowledgeHub.Application.Infrastructure.UseCases; + +public sealed record SynchronizeInfrastructureInventoryCommand( + Guid WorkspaceId, + InventorySnapshotInput Snapshot, + Guid? ActorId, + AuditActorKind ActorKind, + string Source, + string? CorrelationId); + +public sealed class SynchronizeInfrastructureInventoryUseCase( + IInfrastructureRepository infrastructure, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task> ExecuteAsync( + SynchronizeInfrastructureInventoryCommand command, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(command.Snapshot.SourceKey) + || string.IsNullOrWhiteSpace(command.Snapshot.SourceHash)) + { + return OperationResultValue.Failure( + OperationStatus.ValidationFailed, ["Bron-ID en bronhash zijn verplicht."]); + } + + if (HasDuplicateKeys(command.Snapshot.Hosts.Select(host => host.SourceKey)) + || HasDuplicateKeys(command.Snapshot.Services.Select(service => service.SourceKey))) + { + return OperationResultValue.Failure( + OperationStatus.ValidationFailed, ["De inventaris bevat dubbele bron-ID's."]); + } + + var result = await infrastructure.ApplySnapshotAsync( + command.WorkspaceId, command.Snapshot, timeProvider.GetUtcNow(), cancellationToken); + + await auditEvents.RecordAsync( + new AuditEventRecord( + command.ActorKind, command.ActorId, command.Source, "infrastructure.inventory-synchronized", + "inventory-snapshot", result.SnapshotId.ToString(), + result.AlreadyImported + ? $"Inventarissnapshot '{command.Snapshot.SourceKey}' idempotent herkend." + : $"Inventarissnapshot '{command.Snapshot.SourceKey}' verwerkt: {result.AddedServices} nieuw, {result.UpdatedServices} bijgewerkt, {result.MissingServices} ontbrekend.", + command.CorrelationId, timeProvider.GetUtcNow()), + cancellationToken); + + return OperationResultValue.Success(result); + } + + private static bool HasDuplicateKeys(IEnumerable keys) + { + var seen = new HashSet(StringComparer.Ordinal); + return keys.Any(key => string.IsNullOrWhiteSpace(key) || !seen.Add(key.Trim())); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/UpdateInfrastructureEditorialProfileUseCase.cs b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/UpdateInfrastructureEditorialProfileUseCase.cs new file mode 100644 index 0000000..8162c76 --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/UpdateInfrastructureEditorialProfileUseCase.cs @@ -0,0 +1,100 @@ +using ItWorx.KnowledgeHub.Application.Audit; +using ItWorx.KnowledgeHub.Application.Common; +using ItWorx.KnowledgeHub.Application.Infrastructure.Validation; +using ItWorx.KnowledgeHub.Domain.Infrastructure; + +namespace ItWorx.KnowledgeHub.Application.Infrastructure.UseCases; + +public sealed record UpdateInfrastructureEditorialProfileCommand( + Guid WorkspaceId, + InfrastructureObjectKind TargetKind, + Guid TargetId, + string? DisplayName, + string? Summary, + string? Category, + string? Purpose, + Guid? OwnerId, + Guid? ProjectId, + string? RepositoryUrl, + string? LiveUrl, + string? BackupStatus, + string? MonitoringUrl, + string? MaintenanceNotes, + string? KnownRisks, + string? UpdateProcedure, + string? RollbackProcedure, + IReadOnlyList? Tags, + bool IsFavorite, + bool IsHidden, + bool IsArchived, + string ExpectedETag, + Guid ActorId, + string? CorrelationId); + +public sealed class UpdateInfrastructureEditorialProfileUseCase( + IInfrastructureRepository infrastructure, + EditorialProfileValidator validator, + IAuditEventRecorder auditEvents, + TimeProvider timeProvider) +{ + public async Task> ExecuteAsync( + UpdateInfrastructureEditorialProfileCommand command, + CancellationToken cancellationToken) + { + if (!await infrastructure.TargetExistsAsync( + command.WorkspaceId, command.TargetKind, command.TargetId, cancellationToken)) + { + return OperationResultValue.Failure(OperationStatus.NotFound); + } + + var errors = await validator.ValidateAsync( + command.WorkspaceId, command.DisplayName, command.Summary, command.Category, command.Purpose, + command.OwnerId, command.ProjectId, command.RepositoryUrl, command.LiveUrl, command.BackupStatus, + command.MonitoringUrl, command.MaintenanceNotes, command.KnownRisks, command.UpdateProcedure, + command.RollbackProcedure, command.Tags, cancellationToken); + if (errors.Count > 0) + { + return OperationResultValue.Failure(OperationStatus.ValidationFailed, errors); + } + + var now = timeProvider.GetUtcNow(); + var profile = await infrastructure.FindEditorialProfileForUpdateAsync( + command.WorkspaceId, command.TargetKind, command.TargetId, command.ExpectedETag, cancellationToken); + + if (profile is null) + { + if (!string.Equals(command.ExpectedETag, "0", StringComparison.Ordinal)) + { + return OperationResultValue.Failure(OperationStatus.ConcurrencyConflict); + } + + profile = EditorialProfile.Create(command.WorkspaceId, command.TargetKind, command.TargetId, command.ActorId, now); + infrastructure.AddEditorialProfile(profile); + } + + profile.Update( + command.DisplayName, command.Summary, command.Category, command.Purpose, command.OwnerId, command.ProjectId, + command.RepositoryUrl, command.LiveUrl, command.BackupStatus, command.MonitoringUrl, + command.MaintenanceNotes, command.KnownRisks, command.UpdateProcedure, command.RollbackProcedure, + command.Tags, command.IsFavorite, command.IsHidden, command.IsArchived, command.ActorId, now); + + try + { + await infrastructure.SaveChangesAsync(cancellationToken); + } + catch (ConcurrencyConflictException) + { + return OperationResultValue.Failure(OperationStatus.ConcurrencyConflict); + } + + await auditEvents.RecordAsync( + new AuditEventRecord( + AuditActorKind.User, command.ActorId, "web", "infrastructure.editorial-profile-updated", + command.TargetKind.ToString().ToLowerInvariant(), command.TargetId.ToString(), + "Redactioneel infrastructuurprofiel bijgewerkt.", command.CorrelationId, now), + cancellationToken); + + var etag = await infrastructure.GetEditorialETagAsync(profile.Id, cancellationToken); + return OperationResultValue.Success(profile.Id, etag); + } +} diff --git a/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/Validation/EditorialProfileValidator.cs b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/Validation/EditorialProfileValidator.cs new file mode 100644 index 0000000..465855b --- /dev/null +++ b/src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/Validation/EditorialProfileValidator.cs @@ -0,0 +1,148 @@ +using ItWorx.KnowledgeHub.Application.Common; +using ItWorx.KnowledgeHub.Application.Identity; +using ItWorx.KnowledgeHub.Application.Projects; + +namespace ItWorx.KnowledgeHub.Application.Infrastructure.Validation; + +/// +/// Centralized server-side validation for +/// writes +/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H2). +/// Database column max-lengths (see +/// EditorialKnowledgeConfigurations.EditorialProfileConfiguration) remain +/// defense in depth — this is the primary validator, run *before* anything reaches +/// EF Core, so invalid input always comes back as a specific RFC 7807 422, never a +/// database exception. Kept as its own small class (not inlined into the use case) so +/// its every-field-and-boundary shape has one obvious place to extend when a new +/// editorial field is added. +/// +public sealed class EditorialProfileValidator( + IWorkspaceMembershipRepository memberships, IProjectRepository projects) +{ + // English tokens, matching this codebase's established enum-as-string convention + // (DocumentStatus, RunbookStatus, ...): the frontend's { + setQuery(event.target.value); + setSelectedIndex(0); + }} + onKeyDown={handleInputKeyDown} + placeholder="Zoek in kennis of typ een opdracht…" + aria-label="Zoeken en opdrachten" + aria-controls="command-result-list" + aria-activedescendant={activeOptionId} + /> + + +
+
+ {destinationResults.length > 0 && ( +
+ {destinationResults.map((destination, index) => { + const Icon = destination.icon; + return ( + + ); + })} +
+ )} + {displayedKnowledgeResults.length > 0 && ( +
+

Kennisbank

+ {displayedKnowledgeResults.map((item, itemIndex) => { + const index = destinationResults.length + itemIndex; + return ( + + ); + })} +
+ )} +
+ {searching &&
Zoeken…
} + {!searching && searchFailed && ( +
+ Kennis zoeken is tijdelijk niet beschikbaar. Bestemmingen blijven beschikbaar. + +
+ )} + {!searching && !searchFailed && !hasAnyResults &&
Geen overeenkomsten.
} +
+ + + + ); +} diff --git a/src/frontend/src/components/DataTable.test.tsx b/src/frontend/src/components/DataTable.test.tsx new file mode 100644 index 0000000..c73d12f --- /dev/null +++ b/src/frontend/src/components/DataTable.test.tsx @@ -0,0 +1,159 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; +import { DataTable } from "./DataTable"; + +type Row = { name: string; size: number; kind: string }; +const columns: ColumnDef[] = [ + { accessorKey: "name", header: "Naam" }, + { accessorKey: "size", header: "Omvang" }, + { accessorKey: "kind", header: "Klasse" }, +]; +const data = [ + { name: "PostgreSQL", size: 20, kind: "database" }, + { name: "Attachments", size: 10, kind: "files" }, +]; + +describe("DataTable", () => { + afterEach(() => window.localStorage.clear()); + + it("filters, sorts and persists column visibility", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.type( + screen.getByLabelText("Testmatrix doorzoeken"), + "PostgreSQL", + ); + expect(screen.getByText("PostgreSQL")).toBeInTheDocument(); + expect(screen.queryByText("Attachments")).not.toBeInTheDocument(); + + await user.clear(screen.getByLabelText("Testmatrix doorzoeken")); + expect(await screen.findByText("Attachments")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Omvang" })); + expect(screen.getByRole("columnheader", { name: "Omvang" })).toHaveAttribute("aria-sort", "descending"); + expect(screen.getAllByRole("row")[1]).toHaveTextContent("PostgreSQL"); + + await user.click(screen.getByText("Kolommen")); + await user.click(screen.getByLabelText("Klasse")); + expect( + screen.queryByRole("columnheader", { name: "Klasse" }), + ).not.toBeInTheDocument(); + expect( + window.localStorage.getItem("itworx.table.v1.test.columns"), + ).toContain('"kind":false'); + }); + + it("saves, restores and removes named views with table density", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Testmatrix doorzoeken"), "PostgreSQL"); + await user.click(screen.getByRole("button", { name: "Tabeldichtheid: comfortabel" })); + await user.click(screen.getByText("Weergaven")); + await user.type(screen.getByLabelText("Naam weergave"), "Database compact"); + await user.click(screen.getByRole("button", { name: "Huidige weergave opslaan" })); + await user.clear(screen.getByLabelText("Testmatrix doorzoeken")); + await user.click(screen.getByRole("button", { name: "Tabeldichtheid: compact" })); + await user.click(screen.getByRole("button", { name: "Database compact" })); + + expect(screen.getByLabelText("Testmatrix doorzoeken")).toHaveValue("PostgreSQL"); + expect(screen.getByRole("button", { name: "Tabeldichtheid: compact" })).toBeInTheDocument(); + expect(window.localStorage.getItem("itworx.table.v1.test.views")).toContain("Database compact"); + await user.click(screen.getByRole("button", { name: "Database compact verwijderen" })); + expect(screen.queryByRole("button", { name: "Database compact" })).not.toBeInTheDocument(); + }); + + // V2-M6: "matrices remain usable (sortable/filterable, no horizontal page scroll) + // at realistic service/project counts" — the existing tests above only ever used a + // 2-row/3-column fixture. This proves the same real behaviors (filter, sort, column + // visibility) still hold correctly at a scale closer to a real infrastructure + // matrix, not just that the component doesn't crash with more rows. + describe("at a realistic matrix scale (many rows and columns)", () => { + type MatrixRow = { name: string; host: string; port: number; status: string; owner: string; region: string }; + const matrixColumns: ColumnDef[] = [ + { accessorKey: "name", header: "Service" }, + { accessorKey: "host", header: "Host" }, + { accessorKey: "port", header: "Poort" }, + { accessorKey: "status", header: "Status" }, + { accessorKey: "owner", header: "Eigenaar" }, + { accessorKey: "region", header: "Regio" }, + ]; + const rowCount = 240; + const matrixData: MatrixRow[] = Array.from({ length: rowCount }, (_, index) => ({ + name: `service-${index.toString().padStart(3, "0")}`, + host: `host-${index % 12}`, + port: 30000 + index, + status: index % 7 === 0 ? "Degraded" : "Healthy", + owner: `team-${index % 5}`, + region: index % 3 === 0 ? "eu-west" : "eu-central", + })); + + function renderMatrix() { + return render( + , + ); + } + + it("reports the true total row count and every row is reachable, not truncated or paginated away", () => { + renderMatrix(); + expect(screen.getByText(`${rowCount} van ${rowCount} rijen`)).toBeInTheDocument(); + expect(screen.getAllByRole("row")).toHaveLength(rowCount + 1); // +1 header row + }); + + it("filters correctly to the real matching subset at scale", async () => { + const user = userEvent.setup(); + renderMatrix(); + + await user.type(screen.getByLabelText("Servicematrix doorzoeken"), "Degraded"); + const expectedMatches = matrixData.filter((row) => row.status === "Degraded").length; + expect(screen.getByText(`${expectedMatches} van ${rowCount} rijen`)).toBeInTheDocument(); + expect(screen.queryByText("service-001")).not.toBeInTheDocument(); // Healthy, filtered out + expect(screen.getByText("service-000")).toBeInTheDocument(); // index 0 is Degraded + }); + + it("sorts a numeric column correctly across the full row set, not just the first page", async () => { + const user = userEvent.setup(); + renderMatrix(); + + // Matches the pre-existing "Omvang" sort test above: the first click sorts + // descending, not ascending. + await user.click(screen.getByRole("button", { name: "Poort" })); + expect(screen.getByRole("columnheader", { name: "Poort" })).toHaveAttribute("aria-sort", "descending"); + const rows = screen.getAllByRole("row"); + // Row 1 is the header; the highest port (service with the last index) sorts first. + expect(rows[1]).toHaveTextContent(`service-${(rowCount - 1).toString().padStart(3, "0")}`); + expect(rows[rows.length - 1]).toHaveTextContent("service-000"); + }); + + it("hides a column across all rows when toggled off, without breaking the remaining columns", async () => { + const user = userEvent.setup(); + renderMatrix(); + + await user.click(screen.getByText("Kolommen")); + await user.click(screen.getByLabelText("Regio")); + + expect(screen.queryByRole("columnheader", { name: "Regio" })).not.toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Service" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Eigenaar" })).toBeInTheDocument(); + expect(screen.getByText(`${rowCount} van ${rowCount} rijen`)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/frontend/src/components/DataTable.tsx b/src/frontend/src/components/DataTable.tsx new file mode 100644 index 0000000..e75fd7d --- /dev/null +++ b/src/frontend/src/components/DataTable.tsx @@ -0,0 +1,253 @@ +import { BookmarkPlus, Download, Rows3, Search, SlidersHorizontal, Trash2 } from "lucide-react"; +import { useDeferredValue, useEffect, useState } from "react"; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type SortingState, + type VisibilityState, +} from "@tanstack/react-table"; + +type DataTableProps = { + data: TData[]; + columns: ColumnDef[]; + label: string; + storageKey: string; + exportFilename: string; + emptyMessage: string; +}; + +type TableDensity = "comfortable" | "compact"; +type SavedView = { name: string; query: string; sorting: SortingState; columnVisibility: VisibilityState; density: TableDensity }; + +function readJson(key: string, fallback: T): T { + try { return JSON.parse(window.localStorage.getItem(key) ?? "null") as T ?? fallback; } catch { return fallback; } +} + +export function DataTable({ + data, + columns, + label, + storageKey, + exportFilename, + emptyMessage, +}: DataTableProps) { + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); + const [sorting, setSorting] = useState([]); + const [density, setDensity] = useState(() => readJson(`itworx.table.v1.${storageKey}.density`, "comfortable")); + const [viewName, setViewName] = useState(""); + const [savedViews, setSavedViews] = useState(() => readJson(`itworx.table.v1.${storageKey}.views`, [])); + const [columnVisibility, setColumnVisibility] = useState( + () => { + try { + return JSON.parse( + window.localStorage.getItem( + `itworx.table.v1.${storageKey}.columns`, + ) ?? "{}", + ) as VisibilityState; + } catch { + return {}; + } + }, + ); + // TanStack Table intentionally returns an imperative table instance; React Compiler + // skips this component, while the explicit state inputs below keep rendering correct. + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + data, + columns, + state: { sorting, globalFilter: deferredQuery, columnVisibility }, + onSortingChange: setSorting, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + useEffect(() => { + window.localStorage.setItem( + `itworx.table.v1.${storageKey}.columns`, + JSON.stringify(columnVisibility), + ); + }, [columnVisibility, storageKey]); + + useEffect(() => { + window.localStorage.setItem(`itworx.table.v1.${storageKey}.density`, JSON.stringify(density)); + window.localStorage.setItem(`itworx.table.v1.${storageKey}.views`, JSON.stringify(savedViews)); + }, [density, savedViews, storageKey]); + + function saveView() { + const name = viewName.trim(); + if (!name) return; + const view = { name, query, sorting, columnVisibility, density }; + setSavedViews((current) => [...current.filter((item) => item.name !== name), view]); + setViewName(""); + } + + function applyView(view: SavedView) { + setQuery(view.query); setSorting(view.sorting); setColumnVisibility(view.columnVisibility); setDensity(view.density); + } + + function exportCsv() { + const visibleColumns = table.getVisibleLeafColumns(); + const rows = table.getRowModel().rows; + const csv = [ + visibleColumns + .map((column) => + csvValue(columnLabel(column.columnDef.header, column.id)), + ) + .join(","), + ...rows.map((row) => + visibleColumns + .map((column) => csvValue(row.getValue(column.id))) + .join(","), + ), + ].join("\r\n"); + const url = URL.createObjectURL( + new Blob([csv], { type: "text/csv;charset=utf-8" }), + ); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = exportFilename; + anchor.click(); + URL.revokeObjectURL(url); + } + + return ( +
+
+ + + {table.getRowModel().rows.length} van {data.length} rijen + +
+ + +
+ {table + .getAllLeafColumns() + .filter((column) => column.getCanHide()) + .map((column) => ( + + ))} +
+
+
+ +
{savedViews.length === 0 && Nog geen opgeslagen weergaven.}{savedViews.map((view) =>
)}
+
+ + +
+ {/* tabIndex={0}: a horizontally scrollable container with no other focusable + content is otherwise unreachable by keyboard — a real axe + "scrollable-region-focusable" (WCAG 2.1.1/2.1.3) violation found on a narrow + viewport during the V2-M4 audit, not by inspection. No extra role/aria-label + here: the enclosing
above already names this + area, and adding a second identically-named "region" would just create a + confusing duplicate landmark for screen reader users. */} +
+ + + {table.getHeaderGroups().map((group) => ( + + {group.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : header.column.getCanSort() ? ( + + ) : ( + flexRender( + header.column.columnDef.header, + header.getContext(), + ) + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ {table.getRowModel().rows.length === 0 && ( +
{emptyMessage}
+ )} +
+ ); +} + +function csvValue(value: unknown): string { + const raw = + value === null || value === undefined + ? "" + : typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ? `${value}` + : (JSON.stringify(value) ?? ""); + return `"${raw.replaceAll('"', '""')}"`; +} + +function columnLabel(header: unknown, fallback: string) { + return typeof header === "string" || typeof header === "number" + ? `${header}` + : fallback; +} diff --git a/src/frontend/src/components/EntityDialogs.test.tsx b/src/frontend/src/components/EntityDialogs.test.tsx new file mode 100644 index 0000000..1033539 --- /dev/null +++ b/src/frontend/src/components/EntityDialogs.test.tsx @@ -0,0 +1,118 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetCsrfCache } from "../api/client"; +import { ProjectCreateDialog } from "./ProjectDialogs"; +import { ProjectDecisionDialog } from "./ProjectWorkspaceActions"; +import { RunbookCreateDialog } from "./RunbookCreateDialog"; + +function response(body: unknown) { + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)) + }); +} + +describe("entity creation dialogs", () => { + afterEach(() => { + resetCsrfCache(); + vi.unstubAllGlobals(); + }); + + it("creates a project from the V2 portfolio interface", async () => { + const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => { + if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" }); + if (input === "/api/v1/projects" && init?.method === "POST") return response({ id: "project-1", name: "Atlas" }); + throw new Error(`Unexpected fetch: ${input}`); + }); + vi.stubGlobal("fetch", fetchMock); + const onCreated = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Nieuw project" })); + await user.type(screen.getByLabelText("Naam"), "Atlas"); + await user.type(screen.getByLabelText("Samenvatting"), "Infrastructuurproject"); + await user.click(screen.getByRole("button", { name: "Project aanmaken" })); + + expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "project-1", name: "Atlas" })); + }); + + it("creates a runbook with operational metadata", async () => { + const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => { + if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" }); + if (input === "/api/v1/runbooks" && init?.method === "POST") return response({ id: "runbook-1", title: "PostgreSQL herstellen" }); + throw new Error(`Unexpected fetch: ${input}`); + }); + vi.stubGlobal("fetch", fetchMock); + const onCreated = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Nieuw runbook" })); + await user.type(screen.getByLabelText("Titel"), "PostgreSQL herstellen"); + await user.click(screen.getByLabelText("Onderhoudsvenster vereist")); + await user.click(screen.getByRole("button", { name: "Runbook aanmaken" })); + + expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "runbook-1" })); + }); + + it("creates a project-bound architecture decision", async () => { + const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => { + if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" }); + if (input === "/api/v1/decisions" && init?.method === "POST") return response({ id: "decision-1", projectId: "project-1", title: "Kies PostgreSQL" }); + throw new Error(`Unexpected fetch: ${input}`); + }); + vi.stubGlobal("fetch", fetchMock); + const onCreated = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Beslissing" })); + await user.type(screen.getByLabelText("Titel"), "Kies PostgreSQL"); + await user.type(screen.getByLabelText("Samenvatting"), "Duurzame relationele opslag."); + await user.click(screen.getByRole("button", { name: "Beslissing aanmaken" })); + + expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "decision-1", projectId: "project-1" })); + }); + + it("only offers currently Accepted decisions as a supersession target, and sends the chosen id", async () => { + const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => { + if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" }); + if (input === "/api/v1/decisions" && init?.method === "POST") { + const body = JSON.parse(init.body as string) as { supersedesDecisionId: string | null }; + expect(body.supersedesDecisionId).toBe("decision-accepted"); + return response({ id: "decision-2", projectId: "project-1", title: "Kies MySQL in plaats van PostgreSQL" }); + } + throw new Error(`Unexpected fetch: ${input}`); + }); + vi.stubGlobal("fetch", fetchMock); + const onCreated = vi.fn(); + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole("button", { name: "Beslissing" })); + const supersedesField = screen.getByLabelText("Vervangt beslissing (optioneel)"); + expect(screen.getByRole("option", { name: /ADR-1: Kies PostgreSQL/ })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: /ADR-2: Kies Redis/ })).not.toBeInTheDocument(); + + await user.type(screen.getByLabelText("Titel"), "Kies MySQL in plaats van PostgreSQL"); + await user.type(screen.getByLabelText("Samenvatting"), "Herziene keuze na loadtest."); + await user.selectOptions(supersedesField, "decision-accepted"); + await user.click(screen.getByRole("button", { name: "Beslissing aanmaken" })); + + expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "decision-2" })); + }); +}); diff --git a/src/frontend/src/components/InventoryReviewPanel.test.tsx b/src/frontend/src/components/InventoryReviewPanel.test.tsx new file mode 100644 index 0000000..13aea97 --- /dev/null +++ b/src/frontend/src/components/InventoryReviewPanel.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetCsrfCache } from "../api/client"; +import { InventoryReviewPanel } from "./InventoryReviewPanel"; + +describe("InventoryReviewPanel", () => { + afterEach(() => { + resetCsrfCache(); + vi.unstubAllGlobals(); + }); + + it("shows secret-free changed fields with snapshot provenance", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((input: string) => { + const body = input.includes("/services?") + ? { items: [], nextCursor: null } + : [{ id: "change-1", snapshotId: "snapshot-12345678", targetKind: "Service", targetId: "service-1", sourceKey: "docker:knowledge-hub", changeKind: "Updated", changedFields: ["runtimeStatus", "imageReference"], recordedAt: "2026-08-02T00:00:00Z" }]; + return Promise.resolve({ ok: true, status: 200, headers: new Headers({ "content-type": "application/json" }), json: () => Promise.resolve(body) }); + }), + ); + + render(); + + expect(await screen.findByText("docker:knowledge-hub")).toBeInTheDocument(); + expect(screen.getByText("runtimeStatus, imageReference")).toBeInTheDocument(); + expect(screen.getByText("Service · snapshot snapshot")).toBeInTheDocument(); + }); + + it("lets an administrator accept a proposed service", async () => { + let accepted = false; + const proposedService = { + id: "service-1", + sourceKey: "docker:new-service", + name: "Nieuwe service", + detectedName: "new-service", + runtimeKind: "container", + imageReference: "example/new-service:1", + presence: "Proposed", + }; + const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => { + if (input === "/api/v1/auth/csrf") { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ token: "csrf", headerName: "X-CSRF-TOKEN" }), + }); + } + if (input.endsWith("/review") && init?.method === "POST") { + accepted = true; + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve('{"serviceId":"service-1","presence":"Present"}'), + }); + } + const body = input.includes("/services?") + ? { items: accepted ? [] : [proposedService], nextCursor: null } + : []; + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve(body), + }); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + expect(await screen.findByText("Nieuwe service")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Bevestigen" })); + expect(await screen.findByText("Geen openstaande wijzigingen")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/infrastructure/services/service-1/review", + expect.objectContaining({ method: "POST" }), + ); + }); +}); diff --git a/src/frontend/src/components/InventoryReviewPanel.tsx b/src/frontend/src/components/InventoryReviewPanel.tsx new file mode 100644 index 0000000..62408ea --- /dev/null +++ b/src/frontend/src/components/InventoryReviewPanel.tsx @@ -0,0 +1,217 @@ +import { AlertTriangle, CheckCircle2, GitCompareArrows } from "lucide-react"; +import { useEffect, useState } from "react"; +import { ApiError, apiGet, apiPost } from "../api/client"; +import type { + InfrastructureServiceListResponse, + InfrastructureServiceSummary, + InventoryChange, +} from "../api/infrastructureTypes"; +import { StatusBadge } from "./ProductPrimitives"; + +type ReviewState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { + kind: "ready"; + changes: InventoryChange[]; + proposals: InfrastructureServiceSummary[]; + }; + +async function loadReviewState(): Promise> { + const [changes, services] = await Promise.all([ + apiGet("/api/v1/infrastructure/changes?limit=100"), + // Proposed/hidden services are a review queue, expected to stay small relative to + // the confirmed catalog — a single generous page covers realistic scale (V2-R2). + apiGet( + "/api/v1/infrastructure/services?includeHidden=true&limit=200", + ), + ]); + return { + kind: "ready", + changes, + proposals: services.items.filter((service) => service.presence === "Proposed"), + }; +} + +export function InventoryReviewPanel() { + const [state, setState] = useState({ kind: "loading" }); + const [reviewingId, setReviewingId] = useState(null); + const [rejectingId, setRejectingId] = useState(null); + + useEffect(() => { + let cancelled = false; + void loadReviewState() + .then((nextState) => { + if (!cancelled) setState(nextState); + }) + .catch((caught: unknown) => { + if (!cancelled) { + setState({ + kind: "error", + message: + caught instanceof ApiError + ? caught.message + : "Inventariswijzigingen konden niet worden geladen.", + }); + } + }); + return () => { + cancelled = true; + }; + }, []); + + async function review( + service: InfrastructureServiceSummary, + decision: "Accept" | "Reject", + reason?: string, + ) { + setReviewingId(service.id); + try { + await apiPost(`/api/v1/infrastructure/services/${service.id}/review`, { + decision, + reason: reason?.trim() || null, + }); + setRejectingId(null); + setState(await loadReviewState()); + } catch (caught) { + setState({ + kind: "error", + message: + caught instanceof ApiError + ? caught.message + : "De inventarisbeslissing kon niet worden opgeslagen.", + }); + } finally { + setReviewingId(null); + } + } + + return ( +
+
+
+

Inventariscontrole

+

Wijzigingen sinds snapshots

+

+ Bevestig nieuwe services expliciet. De ledger bevat alleen veldnamen en + identifiers; geen geheime bronwaarden. +

+
+
+ + {state.kind === "loading" &&

Wijzigingen worden geladen…

} + {state.kind === "error" && ( +
+ {state.message} +
+ )} + {state.kind === "ready" && ( + <> + {state.proposals.length > 0 && ( +
+

Te beoordelen services

+ {state.proposals.map((service) => ( +
+
+ {service.name || service.detectedName} + {service.sourceKey} +

+ {service.runtimeKind || "Onbekend type"} · {service.imageReference || "Geen image"} +

+
+ {rejectingId === service.id ? ( +
{ + event.preventDefault(); + const data = new FormData(event.currentTarget); + const reason = data.get("reason"); + void review(service, "Reject", typeof reason === "string" ? reason : ""); + }} + > + + + +
+ ) : ( +
+ + +
+ )} +
+ ))} +
+ )} + + {state.changes.length === 0 && state.proposals.length === 0 && ( +
+
+ )} + {state.changes.length > 0 && ( +
+ {state.changes.map((change) => ( +
+ + {change.changeKind === "Missing" ? ( + +
+ {change.sourceKey} + + {change.targetKind} · snapshot {change.snapshotId.slice(0, 8)} + +

{change.changedFields.join(", ") || "object"}

+
+
+ + +
+
+ ))} +
+ )} + + )} +
+ ); +} diff --git a/src/frontend/src/components/LeafWikiImportPanel.test.tsx b/src/frontend/src/components/LeafWikiImportPanel.test.tsx new file mode 100644 index 0000000..b0957d2 --- /dev/null +++ b/src/frontend/src/components/LeafWikiImportPanel.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetCsrfCache } from "../api/client"; +import { LeafWikiImportPanel } from "./LeafWikiImportPanel"; + +function response(body: unknown) { + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)) + }); +} + +describe("LeafWikiImportPanel", () => { + afterEach(() => { + resetCsrfCache(); + vi.unstubAllGlobals(); + }); + + it("blocks importing when preflight reports blockers, and offers no import button", async () => { + const fetchMock = vi.fn().mockImplementation((input: string) => { + if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" }); + if (input === "/api/v1/admin/imports/preflight") { + return response({ + status: "NoApplicableAdapter", + jobId: null, + adapterName: null, + discoveredCount: 0, + plannedCount: 0, + items: [], + warnings: [], + blockers: ["Geen adapter kan deze bron interpreteren."] + }); + } + throw new Error(`Unexpected fetch: ${input}`); + }); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + + render(); + await user.type(screen.getByLabelText("Relatief pad onder de importmap"), "leeg-pad"); + await user.click(screen.getByRole("button", { name: "Preflight uitvoeren" })); + + expect(await screen.findByText("Geen adapter kan deze bron interpreteren.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Importeren" })).not.toBeInTheDocument(); + }); + + it("runs preflight, execute and rollback end to end when there are no blockers", async () => { + const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => { + if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" }); + if (input === "/api/v1/admin/imports/preflight") { + return response({ + status: "Ready", + jobId: "job-1", + adapterName: "filesystem-markdown", + discoveredCount: 1, + plannedCount: 1, + items: [{ sourceIdentity: "welkom.md", kind: "Markdown", proposedTitle: "Welkom", proposedParentSourceIdentity: null, conflict: "None", conflictDetail: null }], + warnings: [], + blockers: [] + }); + } + if (typeof input === "string" && input.startsWith("/api/v1/admin/imports/job-1/execute")) { + expect(init?.method).toBe("POST"); + return response({ status: "Success", imported: 1, skipped: 0, failed: 0, sourceUnchangedAfterExecution: true, diagnostics: [] }); + } + if (input === "/api/v1/admin/imports/job-1/rollback") { + return response({ status: "Success", removedPages: 1, preservedPages: 0, removedAttachments: 0 }); + } + throw new Error(`Unexpected fetch: ${input}`); + }); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + + render(); + await user.type(screen.getByLabelText("Relatief pad onder de importmap"), "leafwiki-export"); + await user.click(screen.getByRole("button", { name: "Preflight uitvoeren" })); + + expect(await screen.findByText(/1 item\(s\) gevonden/)).toBeInTheDocument(); + expect(screen.getByRole("cell", { name: "Welkom" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Importeren" })); + expect(await screen.findByText(/1 geïmporteerd/)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Importtaak ongedaan maken" })); + expect(await screen.findByText(/1 pagina's verwijderd/)).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/src/components/LeafWikiImportPanel.tsx b/src/frontend/src/components/LeafWikiImportPanel.tsx new file mode 100644 index 0000000..a34aacc --- /dev/null +++ b/src/frontend/src/components/LeafWikiImportPanel.tsx @@ -0,0 +1,241 @@ +import { useId, useState } from "react"; +import { apiPost, ApiError } from "../api/client"; +import { newUuid } from "../lib/uuid"; +import type { + ExecuteImportResponse, + ImportPreflightResponse, + RollbackImportResponse +} from "../api/adminTypes"; + +type State = + | { kind: "idle" } + | { kind: "preflighting" } + | { kind: "preflighted"; sourceRelativePath: string; result: ImportPreflightResponse } + | { kind: "executing"; sourceRelativePath: string; jobId: string } + | { kind: "executed"; jobId: string; result: ExecuteImportResponse } + | { kind: "rollingBack"; jobId: string } + | { kind: "rolledBack"; result: RollbackImportResponse }; + +/** + * LeafWiki import, closed for the admin UI in V2-M7 (previously API-first only — + * see CURRENT_STATE.md "What is not yet complete" and ADR-013). Deliberately a + * single sequential panel, not a multi-page wizard: preflight → review plan/ + * blockers/warnings → execute (only once no blockers remain) → optionally roll + * back. Never touches a real LeafWiki source directly — it only ever reads + * whatever an operator has placed under the server-side configured imports root + * (`KnowledgeHub:Paths:Imports`), matching docs/17-leafwiki-migration.md exactly. + */ +export function LeafWikiImportPanel() { + const [state, setState] = useState({ kind: "idle" }); + const [sourceRelativePath, setSourceRelativePath] = useState(""); + const [error, setError] = useState(null); + const pathInputId = useId(); + + async function runPreflight() { + setError(null); + setState({ kind: "preflighting" }); + try { + const result = await apiPost("/api/v1/admin/imports/preflight", { + sourceRelativePath, + idempotencyKey: newUuid() + }); + setState({ kind: "preflighted", sourceRelativePath, result }); + } catch (caught) { + setError(caught instanceof ApiError ? caught.message : "Preflight is mislukt."); + setState({ kind: "idle" }); + } + } + + async function runExecute(jobId: string, path: string, preflightResult: ImportPreflightResponse) { + setError(null); + setState({ kind: "executing", sourceRelativePath: path, jobId }); + try { + const result = await apiPost( + `/api/v1/admin/imports/${jobId}/execute?sourceRelativePath=${encodeURIComponent(path)}` + ); + setState({ kind: "executed", jobId, result }); + } catch (caught) { + setError(caught instanceof ApiError ? caught.message : "Importeren is mislukt."); + setState({ kind: "preflighted", sourceRelativePath: path, result: preflightResult }); + } + } + + async function runRollback(jobId: string) { + setError(null); + setState({ kind: "rollingBack", jobId }); + try { + const result = await apiPost(`/api/v1/admin/imports/${jobId}/rollback`); + setState({ kind: "rolledBack", result }); + } catch (caught) { + setError(caught instanceof ApiError ? caught.message : "Ongedaan maken is mislukt."); + } + } + + function reset() { + setError(null); + setState({ kind: "idle" }); + setSourceRelativePath(""); + } + + return ( +
+
+
+

Migratie

+

LeafWiki-import

+

+ Leest alleen-lezen vanaf een map die de beheerder vooraf onder de + geconfigureerde importmap heeft geplaatst. De bron wordt nooit gewijzigd; + niets wordt overschreven of stilzwijgend samengevoegd. +

+
+
+ + {error &&
{error}
} + + {(state.kind === "idle" || state.kind === "preflighting") && ( +
{ + event.preventDefault(); + void runPreflight(); + }} + > +
+ + setSourceRelativePath(event.target.value)} + placeholder="bijv. leafwiki-export-2026-08" + disabled={state.kind === "preflighting"} + /> +
+ +
+ )} + + {state.kind === "preflighted" && ( +
+

+ Adapter {state.result.adapterName ?? "onbekend"}: {state.result.discoveredCount} item(s) + gevonden, {state.result.plannedCount} gepland om te importeren. +

+ + {state.result.blockers.length > 0 && ( +
+ Blokkerende problemen — importeren is niet mogelijk: +
    + {state.result.blockers.map((blocker) => ( +
  • {blocker}
  • + ))} +
+
+ )} + + {state.result.warnings.length > 0 && ( +
+ Waarschuwingen: +
    + {state.result.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ )} + + {state.result.items.length > 0 && ( + // .data-table's is position: sticky, which needs a scoped + // overflow:auto ancestor (matching DataTable.tsx's own established + // pattern) — without it, sticky positions relative to the page's own + // scroll instead, so the header floats over and intercepts clicks on + // whatever content comes after the table (found by a real mobile + // Playwright run failing to click "Importeren", not by inspection). + // tabIndex={0} is the same real scrollable-region-focusable + // (WCAG 2.1.1/2.1.3) fix already applied in DataTable.tsx (V2-M4). +
+ + + + + + + + + + + {state.result.items.map((item) => ( + + + + + + + ))} + +
BronSoortVoorgestelde titelConflict
{item.sourceIdentity}{item.kind}{item.proposedTitle}{item.conflict === "None" ? "—" : item.conflict}
+
+ )} + +
+ {state.result.jobId && state.result.blockers.length === 0 && ( + + )} + +
+
+ )} + + {state.kind === "executing" &&

Importeren wordt uitgevoerd…

} + + {state.kind === "executed" && ( +
+

+ Status: {state.result.status} — {state.result.imported} geïmporteerd,{" "} + {state.result.skipped} overgeslagen, {state.result.failed} mislukt. +

+ {state.result.diagnostics.length > 0 && ( +
    + {state.result.diagnostics.map((diagnostic) => ( +
  • {diagnostic}
  • + ))} +
+ )} +
+ + +
+
+ )} + + {state.kind === "rollingBack" &&

Ongedaan maken…

} + + {state.kind === "rolledBack" && ( +
+

+ Ongedaan gemaakt: {state.result.removedPages} pagina's verwijderd,{" "} + {state.result.preservedPages} behouden (sindsdien bewerkt),{" "} + {state.result.removedAttachments} bijlagen verwijderd. +

+ +
+ )} +
+ ); +} diff --git a/src/frontend/src/components/MarkdownContent.test.tsx b/src/frontend/src/components/MarkdownContent.test.tsx new file mode 100644 index 0000000..ad2ac66 --- /dev/null +++ b/src/frontend/src/components/MarkdownContent.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resetCsrfCache } from "../api/client"; +import { MarkdownContent } from "./MarkdownContent"; + +function response(body: unknown) { + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }); +} + +describe("MarkdownContent", () => { + beforeEach(() => { + resetCsrfCache(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows the empty-state message for blank markdown without calling the API", () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + render(); + + expect(screen.getByText("Nog niets beschreven.")).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("renders headings, links, lists and code through the real server-sanitized pipeline", async () => { + const markdown = "# Titel\n\n[link](https://example.test)\n\n- item\n\n`code`"; + const fetchMock = vi.fn().mockImplementation((input: string) => { + if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" }); + return response({ + renderedHtml: + '

Titel

link

  • item

code

', + }); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + expect(await screen.findByRole("heading", { level: 1, name: "Titel" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "link" })).toHaveAttribute("href", "https://example.test"); + expect(screen.getByRole("list")).toBeInTheDocument(); + expect(screen.getByText("code").tagName).toBe("CODE"); + + const previewCall = fetchMock.mock.calls.find(([input]) => input === "/api/v1/pages/preview"); + expect(previewCall).toBeDefined(); + expect(JSON.parse((previewCall![1] as RequestInit).body as string)).toEqual({ markdown }); + }); + + it("renders exactly the server's already-sanitized HTML, doing no additional client-side sanitization itself", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((input: string) => { + if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" }); + // A real server response would already have stripped a raw gewone tekst'} emptyMessage="Leeg." />); + + expect(await screen.findByText("onschadelijke tekst")).toBeInTheDocument(); + expect(document.querySelector("script[src], script:not([type])")).toBeNull(); + }); + + it("falls back to plain escaped text when the render request fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((input: string) => { + if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" }); + return Promise.reject(new Error("network down")); + }), + ); + + render(); + + expect(await screen.findByText("Ruwe tekst als fallback")).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/src/components/MarkdownContent.tsx b/src/frontend/src/components/MarkdownContent.tsx new file mode 100644 index 0000000..3a43ab5 --- /dev/null +++ b/src/frontend/src/components/MarkdownContent.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import { apiPost } from "../api/client"; + +type PreviewMarkdownResponse = { renderedHtml: string }; + +type MarkdownContentProps = { + markdown: string; + emptyMessage: string; + className?: string; +}; + +/** + * Renders a free-text Markdown field (project `currentStateMarkdown`, an update's + * `summaryMarkdown`, etc.) through the exact same server-side, sanitized rendering + * pipeline real wiki pages use — `POST /api/v1/pages/preview` + * (docs/adr/ADR-029-v2-r5-final-hardening.md). Never `dangerouslySetInnerHTML`s + * unsanitized text: the HTML this component sets always came back from the server's + * own Markdig + HtmlSanitizer pass, the same trust boundary `PageViewPage.tsx`/ + * `PageEditorPage.tsx`'s live preview already rely on. + */ +export function MarkdownContent({ markdown, emptyMessage, className }: MarkdownContentProps) { + const trimmed = markdown.trim(); + const [result, setResult] = useState< + { kind: "loading" } | { kind: "error" } | { kind: "ready"; html: string } + >({ kind: "loading" }); + + useEffect(() => { + if (!trimmed) { + return; + } + let cancelled = false; + apiPost("/api/v1/pages/preview", { markdown }) + .then((response) => { + if (!cancelled) setResult({ kind: "ready", html: response.renderedHtml }); + }) + .catch(() => { + if (!cancelled) setResult({ kind: "error" }); + }); + return () => { + cancelled = true; + }; + }, [markdown, trimmed]); + + if (!trimmed) { + return

{emptyMessage}

; + } + if (result.kind === "loading") { + return

Inhoud wordt weergegeven…

; + } + if (result.kind === "error") { + // Falls back to plain, safely React-escaped text — never raw HTML, and never a + // blank/broken panel just because the render call failed. + return

{markdown}

; + } + return
; +} diff --git a/src/frontend/src/components/OwnerPicker.test.tsx b/src/frontend/src/components/OwnerPicker.test.tsx new file mode 100644 index 0000000..2204b11 --- /dev/null +++ b/src/frontend/src/components/OwnerPicker.test.tsx @@ -0,0 +1,100 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { OwnerPicker } from "./OwnerPicker"; + +function jsonResponse(body: unknown) { + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve(body), + }); +} + +describe("OwnerPicker", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("searches workspace members by name or email and selects one via the keyboard", async () => { + const fetchMock = vi.fn().mockImplementation((input: string) => { + if (typeof input === "string" && input.startsWith("/api/v1/workspace/members")) { + return jsonResponse({ + items: [{ id: "user-1", displayName: "Jens Az", email: "jens@itworx.test" }], + }); + } + return jsonResponse({ items: [] }); + }); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + + render(); + + const input = screen.getByRole("combobox", { name: "Eigenaar zoeken" }); + await user.click(input); + await user.type(input, "Jens"); + + expect(await screen.findByRole("option", { name: /Jens Az/ })).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/workspace/members?q=Jens"), + expect.anything(), + ); + + await user.keyboard("{ArrowDown}{Enter}"); + + expect(input).toHaveValue("Jens Az (jens@itworx.test)"); + // The hidden field is what the surrounding
actually submits. + const hidden = document.querySelector('input[type="hidden"][name="ownerId"]') as HTMLInputElement; + expect(hidden.value).toBe("user-1"); + }); + + it("shows an empty state when no member matches, and clears a selection", async () => { + const fetchMock = vi.fn().mockImplementation((input: string) => { + if (typeof input === "string" && input.includes("q=geen-overeenkomst")) { + return jsonResponse({ items: [] }); + } + return jsonResponse({ items: [{ id: "user-1", displayName: "Jens Az", email: "jens@itworx.test" }] }); + }); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + + render(); + + expect(await screen.findByRole("button", { name: "Eigenaar wissen" })).toBeInTheDocument(); + + const input = screen.getByRole("combobox", { name: "Eigenaar zoeken" }); + await user.click(input); + await user.type(input, "geen-overeenkomst"); + + expect(await screen.findByText("Geen gebruikers gevonden.")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + await user.click(screen.getByRole("button", { name: "Eigenaar wissen" })); + + const hidden = document.querySelector('input[type="hidden"][name="ownerId"]') as HTMLInputElement; + expect(hidden.value).toBe(""); + }); + + it("resolves an already-set owner ID to a human-readable label instead of showing the raw GUID", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => + jsonResponse({ items: [{ id: "user-2", displayName: "Tweede Beheerder", email: "tweede@itworx.test" }] }), + ), + ); + + render(); + + const input = await screen.findByRole("combobox", { name: "Eigenaar zoeken" }); + expect(input).toHaveValue("Tweede Beheerder (tweede@itworx.test)"); + }); + + it("is fully disabled when the disabled prop is set", () => { + vi.stubGlobal("fetch", vi.fn().mockImplementation(() => jsonResponse({ items: [] }))); + + render(); + + expect(screen.getByRole("combobox", { name: "Eigenaar zoeken" })).toBeDisabled(); + }); +}); diff --git a/src/frontend/src/components/OwnerPicker.tsx b/src/frontend/src/components/OwnerPicker.tsx new file mode 100644 index 0000000..9cdb0fb --- /dev/null +++ b/src/frontend/src/components/OwnerPicker.tsx @@ -0,0 +1,200 @@ +import { Check, Loader2, X } from "lucide-react"; +import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"; +import { apiGet } from "../api/client"; + +export type WorkspaceMember = { id: string; displayName: string; email: string }; +type WorkspaceMemberListResponse = { items: WorkspaceMember[] }; + +type OwnerPickerProps = { + /** Form field name the selected owner's ID is submitted under (the existing + * FormData-based submit handlers in ServiceDetailPage.tsx / project dialogs keep + * working unchanged via the hidden input this component renders). */ + name: string; + /** The currently-set owner's ID, if any — resolved to a human-readable label on + * mount so the field never shows a raw GUID. */ + defaultValue?: string | null | undefined; + disabled?: boolean; + "aria-label"?: string; +}; + +/** + * Accessible, searchable workspace-member picker + * (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 7) — replaces manual + * `OwnerId`/GUID entry. Searches `GET /api/v1/workspace/members` (name or email), + * scoped server-side to the current workspace's enabled members only. Fully keyboard + * operable (ARIA combobox pattern: ArrowUp/ArrowDown/Enter/Escape), with loading, + * empty, clear and disabled states. + */ +export function OwnerPicker({ name, defaultValue, disabled, ...ariaProps }: OwnerPickerProps) { + const listboxId = useId(); + const [selectedId, setSelectedId] = useState(defaultValue ?? null); + const [selectedLabel, setSelectedLabel] = useState(null); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [members, setMembers] = useState(null); + const [loading, setLoading] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const containerRef = useRef(null); + + // Resolve an already-set owner's ID to a human-readable label — never show the raw + // GUID. Falls back to showing the ID itself only if the member can't be resolved + // (e.g. since disabled/removed from the workspace) rather than showing nothing. + useEffect(() => { + if (!defaultValue) { + return; + } + let cancelled = false; + apiGet("/api/v1/workspace/members") + .then((response) => { + if (cancelled) return; + const match = response.items.find((item) => item.id === defaultValue); + setSelectedLabel(match ? `${match.displayName} (${match.email})` : defaultValue); + }) + .catch(() => { + if (!cancelled) setSelectedLabel(defaultValue); + }); + return () => { + cancelled = true; + }; + }, [defaultValue]); + + useEffect(() => { + if (!open) return; + let cancelled = false; + const timeout = window.setTimeout(() => { + setLoading(true); + apiGet(`/api/v1/workspace/members?q=${encodeURIComponent(query.trim())}`) + .then((response) => { + if (cancelled) return; + setMembers(response.items); + setActiveIndex(response.items.length > 0 ? 0 : -1); + }) + .catch(() => { + if (!cancelled) setMembers([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + }, 250); + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [open, query]); + + useEffect(() => { + function onPointerDown(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, []); + + function select(member: WorkspaceMember) { + setSelectedId(member.id); + setSelectedLabel(`${member.displayName} (${member.email})`); + setOpen(false); + setQuery(""); + } + + function clear() { + setSelectedId(null); + setSelectedLabel(null); + setQuery(""); + } + + function onKeyDown(event: KeyboardEvent) { + if (disabled) return; + if (!open) { + if (event.key === "ArrowDown" || event.key === "Enter") { + event.preventDefault(); + setOpen(true); + } + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((current) => Math.min(current + 1, (members?.length ?? 1) - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((current) => Math.max(current - 1, 0)); + } else if (event.key === "Enter") { + event.preventDefault(); + const active = members && activeIndex >= 0 ? members[activeIndex] : undefined; + if (active) select(active); + } else if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + } + } + + return ( +
+
+ = 0 ? `${listboxId}-option-${activeIndex}` : undefined} + aria-label={ariaProps["aria-label"]} + disabled={disabled} + value={open ? query : (selectedLabel ?? "")} + placeholder="Zoek op naam of e-mail…" + autoComplete="off" + onFocus={() => { + setOpen(true); + setQuery(""); + }} + onChange={(event) => setQuery(event.target.value)} + onKeyDown={onKeyDown} + /> + {loading && open &&
+ {open && ( +
    + {!loading && members && members.length === 0 && ( +
  • Geen gebruikers gevonden.
  • + )} + {members?.map((member, index) => ( +
  • { + event.preventDefault(); + select(member); + }} + > + + {member.displayName} + {member.email} + + {member.id === selectedId &&
  • + ))} +
+ )} + +
+ ); +} diff --git a/src/frontend/src/components/PageActions.test.tsx b/src/frontend/src/components/PageActions.test.tsx new file mode 100644 index 0000000..423001f --- /dev/null +++ b/src/frontend/src/components/PageActions.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetCsrfCache } from "../api/client"; +import type { PageDetail } from "../api/knowledgeTypes"; +import { PageActions } from "./PageActions"; + +const page: PageDetail = { + id: "11111111-1111-1111-1111-111111111111", + workspaceId: "22222222-2222-2222-2222-222222222222", + parentPageId: null, + title: "Unraid-overzicht", + slug: "unraid-overzicht", + markdown: "# Unraid", + renderedHtml: "

Unraid

", + documentType: "WikiPage", + documentStatus: "Active", + currentRevisionNumber: 3, + tags: ["infrastructuur"], + breadcrumb: [], + createdBy: null, + updatedBy: null, + createdAt: "2026-08-02T00:00:00Z", + updatedAt: "2026-08-02T00:00:00Z", + publishedAt: "2026-08-02T00:00:00Z", + trashedAt: null, + eTag: "page-etag", +}; + +function response(body: unknown) { + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }); +} + +describe("PageActions", () => { + afterEach(() => { + resetCsrfCache(); + vi.unstubAllGlobals(); + }); + + it("marks a page as favorite while preserving its content and ETag contract", async () => { + const onUpdated = vi.fn(); + const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => { + if (input === "/api/v1/auth/csrf") { + return response({ token: "csrf", headerName: "X-CSRF-Token" }); + } + if (input === `/api/v1/pages/${page.id}` && init?.method === "PUT") { + expect((init.headers as Record)["If-Match"]).toBe('"page-etag"'); + expect(JSON.parse(init.body as string)).toMatchObject({ + markdown: "# Unraid", + tags: ["infrastructuur", "favorite"], + }); + return response({ ...page, tags: ["infrastructuur", "favorite"], eTag: "next-etag" }); + } + throw new Error(`Unexpected fetch: ${input}`); + }); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Meer pagina-acties" })); + await user.click(screen.getByRole("menuitem", { name: "Favoriet maken" })); + + expect(onUpdated).toHaveBeenCalledWith(expect.objectContaining({ eTag: "next-etag" })); + }); + + it("updates page metadata without rewriting markdown", async () => { + const onUpdated = vi.fn(); + const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => { + if (input === "/api/v1/auth/csrf") { + return response({ token: "csrf", headerName: "X-CSRF-Token" }); + } + if (input === `/api/v1/pages/${page.id}` && init?.method === "PUT") { + expect(JSON.parse(init.body as string)).toMatchObject({ title: "Unraid server", markdown: "# Unraid" }); + return response({ ...page, title: "Unraid server", eTag: "metadata-etag" }); + } + throw new Error(`Unexpected fetch: ${input}`); + }); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Metadata" })); + const title = screen.getByLabelText("Titel"); + await user.clear(title); + await user.type(title, "Unraid server"); + await user.click(screen.getByRole("button", { name: "Metadata opslaan" })); + + expect(onUpdated).toHaveBeenCalledWith(expect.objectContaining({ title: "Unraid server" })); + }); +}); diff --git a/src/frontend/src/components/PageActions.tsx b/src/frontend/src/components/PageActions.tsx new file mode 100644 index 0000000..f5a0091 --- /dev/null +++ b/src/frontend/src/components/PageActions.tsx @@ -0,0 +1,93 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { Archive, ChevronDown, Copy, FilePlus2, FolderInput, Link2, MoreHorizontal, Pencil, Star, Trash2, X } from "lucide-react"; +import { useEffect, useState, type FormEvent } from "react"; +import { apiGet, apiPost, apiPut, ifMatchHeader } from "../api/client"; +import type { PageChildrenResponse, PageDetail } from "../api/knowledgeTypes"; +import { documentStatusLabels, documentTypeLabels } from "../api/knowledgeTypes"; +import { copyText } from "../lib/clipboard"; + +type PageActionsProps = { + page: PageDetail; + onEdit: () => void; + onCreateChild: () => void; + onNavigate: (pageId: string) => void; + onUpdated: (page: PageDetail) => void; + onTrash: () => void; +}; + +export function PageActions({ page, onEdit, onCreateChild, onNavigate, onUpdated, onTrash }: PageActionsProps) { + const [busy, setBusy] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + + async function update(status: string, tags = page.tags) { + setBusy(true); setError(null); + try { + const updated = await apiPut(`/api/v1/pages/${page.id}`, { + title: page.title, markdown: page.markdown, documentType: page.documentType, + documentStatus: status, changeSummary: "Snelle pagina-actie.", tags + }, ifMatchHeader(page.eTag)); + onUpdated(updated); + } catch (caught) { setError(caught instanceof Error ? caught.message : "De pagina-actie is mislukt."); } finally { setBusy(false); } + } + + async function duplicate() { + setBusy(true); setError(null); + try { + const created = await apiPost("/api/v1/pages", { + parentPageId: page.parentPageId, title: `${page.title} — kopie`, documentType: page.documentType, + markdown: page.markdown, templateKey: null, tags: page.tags + }); + onNavigate(created.id); + } catch (caught) { setError(caught instanceof Error ? caught.message : "Dupliceren is mislukt."); } finally { setBusy(false); } + } + + async function copyPermalink() { + setError(null); + try { + if (!(await copyText(window.location.href))) { + throw new Error("Het klembord is niet beschikbaar in deze browser."); + } + setCopied(true); + window.setTimeout(() => setCopied(false), 1800); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Permalink kopiëren is mislukt."); + } + } + + const favorite = page.tags.includes("favorite"); + return <>
+ + + + + { void update(page.documentStatus, favorite ? page.tags.filter((tag) => tag !== "favorite") : [...page.tags, "favorite"]); }}> + { void duplicate(); }}> + { void copyPermalink(); }}> + + + {page.documentStatus !== "Archived" && { void update("Archived"); }}>} + {!page.trashedAt && } + +
{error &&
{error}
}; +} + +function PageMetadataDialog({ page, onUpdated }: { page: PageDetail; onUpdated: (page: PageDetail) => void }) { + const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + async function submit(event: FormEvent) { + event.preventDefault(); setSaving(true); setError(null); const form = new FormData(event.currentTarget); const value = (name: string) => { const entry = form.get(name); return typeof entry === "string" ? entry.trim() : ""; }; + try { + const updated = await apiPut(`/api/v1/pages/${page.id}`, { title: value("title"), markdown: page.markdown, documentType: value("documentType"), documentStatus: value("documentStatus"), changeSummary: "Paginametadata bijgewerkt.", tags: value("tags").split(",").map((tag) => tag.trim()).filter(Boolean) }, ifMatchHeader(page.eTag)); + onUpdated(updated); setOpen(false); + } catch (caught) { setError(caught instanceof Error ? caught.message : "Metadata opslaan is mislukt."); } finally { setSaving(false); } + } + return

Kennisdocument

PaginametadataWijzig titel, type, status en labels met behoud van inhoud en revisiehistorie.
{error &&
{error}
} { void submit(event); }}>
Annuleren
; +} + +function PageMoveItem({ page, onUpdated }: { page: PageDetail; onUpdated: (page: PageDetail) => void }) { + const [open, setOpen] = useState(false); const [roots, setRoots] = useState([]); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + useEffect(() => { if (open) void apiGet("/api/v1/pages/roots?limit=100").then((response) => setRoots(response.items.filter((item) => item.id !== page.id))).catch(() => setError("Bestemmingen konden niet worden geladen.")); }, [open, page.id]); + async function submit(event: FormEvent) { event.preventDefault(); setSaving(true); setError(null); const form = new FormData(event.currentTarget); const entry = form.get("parentPageId"); const parentPageId = typeof entry === "string" && entry ? entry : null; try { const moved = await apiPost(`/api/v1/pages/${page.id}/move`, { newParentPageId: parentPageId, afterSiblingId: null }, ifMatchHeader(page.eTag)); onUpdated(moved); setOpen(false); } catch (caught) { setError(caught instanceof Error ? caught.message : "Verplaatsen is mislukt."); } finally { setSaving(false); } } + return event.preventDefault()}>

Documentstructuur

Pagina verplaatsenKies de kennisbankroot of een hoofdpagina. Drag-and-drop is niet vereist.
{error &&
{error}
}
{ void submit(event); }}>
Annuleren
; +} diff --git a/src/frontend/src/components/ProductPrimitives.tsx b/src/frontend/src/components/ProductPrimitives.tsx new file mode 100644 index 0000000..e025054 --- /dev/null +++ b/src/frontend/src/components/ProductPrimitives.tsx @@ -0,0 +1,30 @@ +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { Link } from "react-router"; +import { statusValueLabels } from "./statusLabels"; + +export function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description?: string; actions?: ReactNode }) { + return

{eyebrow}

{title}

{description &&

{description}

}
{actions &&
{actions}
}
; +} + +export function StatusBadge({ value, label }: { value: string; label?: string }) { + const tone = /healthy|active|present|running|verified|completed/i.test(value) ? "positive" + : /degraded|proposed|paused|review|medium/i.test(value) ? "warning" + : /unhealthy|missing|blocked|critical|failed|stopped/i.test(value) ? "critical" : "neutral"; + // Explicit label wins; otherwise fall back to the shared Dutch translation table + // (statusLabels.ts) before ever showing the raw backend enum value. + return ; +} + +/** + * `to` is optional on purpose (OUX-4): a KPI becomes clickable only where a route + * genuinely answers the number it shows. A card that navigates somewhere vaguely + * related is worse than one that does not navigate at all. + */ +export function MetricCard({ label, value, detail, icon: Icon, tone = "default", to }: { label: string; value: string | number; detail: string; icon: LucideIcon; tone?: "default" | "warning" | "critical" | "positive"; to?: string }) { + const body = <>
{label}{value}{detail}
; + if (to) { + return {body}; + } + return
{body}
; +} diff --git a/src/frontend/src/components/ProjectDialogs.tsx b/src/frontend/src/components/ProjectDialogs.tsx new file mode 100644 index 0000000..e292083 --- /dev/null +++ b/src/frontend/src/components/ProjectDialogs.tsx @@ -0,0 +1,378 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import { Pencil, Plus, X } from "lucide-react"; +import { useState, type FormEvent } from "react"; +import { apiPost, apiPut, apiUpload, ifMatchHeader } from "../api/client"; +import type { + CreateProjectRequest, + ProjectDetail, + UpdateProjectRequest, +} from "../api/projectTypes"; +import { projectPriorityLabels } from "../api/projectTypes"; + +type ProjectCreateDialogProps = { + currentUserId: string; + onCreated: (project: ProjectDetail) => void; +}; + +export function ProjectCreateDialog({ + currentUserId, + onCreated, +}: ProjectCreateDialogProps) { + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function submit(event: FormEvent) { + event.preventDefault(); + setSaving(true); + setError(null); + const form = new FormData(event.currentTarget); + const request: CreateProjectRequest = { + name: text(form, "name"), + summary: text(form, "summary"), + priority: text(form, "priority"), + currentStateMarkdown: text(form, "currentStateMarkdown"), + ownerId: currentUserId, + }; + try { + const created = await apiPost("/api/v1/projects", request); + setOpen(false); + onCreated(created); + } catch (caught) { + setError( + caught instanceof Error + ? caught.message + : "Project aanmaken is mislukt.", + ); + } finally { + setSaving(false); + } + } + + return ( + + + + + + + + + {error && ( +
+ {error} +
+ )} +
{ + void submit(event); + }} + > +
+ +