From ad8ad252a9323e9999055ca67838dcd0057d8312 Mon Sep 17 00:00:00 2001 From: Public Source Publisher Date: Thu, 1 Oct 2026 01:37:37 +0200 Subject: [PATCH] Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817 --- .config/dotnet-tools.json | 13 + .dockerignore | 20 + .editorconfig | 35 + .env.example | 21 + .gitattributes | 10 + .gitignore | 108 + CONTRIBUTING.md | 33 + Directory.Build.props | 11 + Directory.Build.targets | 8 + Directory.Packages.props | 35 + ItWorx.KnowledgeHub.slnx | 15 + LICENSE | 9 + MANIFEST.json | 35 + NuGet.Config | 10 + PUBLIC_SOURCE_EXPORT.md | 3 + PUBLIC_SOURCE_MANIFEST.json | 4121 +++++++++++ README.md | 123 + SECURITY.md | 45 + deploy/docker/Dockerfile | 84 + docker-compose.yml | 105 + docs/00-product-vision.md | 74 + docs/01-scope-and-non-goals.md | 96 + docs/05-non-functional-requirements.md | 121 + docs/06-system-architecture.md | 203 + docs/07-domain-model.md | 325 + docs/08-database-schema.md | 256 + docs/09-api-contract.md | 240 + docs/10-search-and-indexing.md | 132 + docs/11-markdown-and-linking.md | 157 + docs/12-auth-security.md | 203 + docs/13-attachments.md | 132 + docs/14-revisions-and-audit.md | 129 + docs/15-project-hub.md | 170 + docs/16-runbooks-and-adrs.md | 106 + docs/17-leafwiki-migration.md | 281 + docs/18-backup-restore.md | 136 + docs/19-observability.md | 145 + docs/20-ui-ux-design-system.md | 204 + docs/21-accessibility.md | 106 + docs/22-testing-strategy.md | 208 + docs/24-mcp-readiness.md | 162 + docs/26-threat-model.md | 89 + docs/api/openapi.yaml | 6133 +++++++++++++++++ docs/assets/screenshots/02-dashboard.png | Bin 0 -> 120018 bytes docs/assets/screenshots/03-knowledge.png | Bin 0 -> 190279 bytes .../back-ups-herstel-en-unraid-beheer.md | 119 + docs/guides/bijlagen-en-leafwiki-import.md | 82 + docs/guides/documenten-toevoegen-en-intake.md | 29 + .../guides/eerste-installatie-en-aanmelden.md | 77 + .../guides/operationele-runbooks-overzicht.md | 75 + docs/guides/pagina-s-schrijven-en-beheren.md | 98 + .../projecten-beslissingen-en-runbooks.md | 95 + .../service-accounts-en-automatisering.md | 158 + docs/guides/source-batch-removal.md | 37 + docs/guides/zoeken-geschiedenis-en-labels.md | 58 + fixtures/leafwiki/README.md | 15 + .../Projecten/Voorbeeldproject.md | 10 + .../School/Linux/Bestandsrechten.md | 5 + .../School/Linux/Shell-operators.md | 12 + .../markdown-import/assets/architecture.svg | 8 + fixtures/markdown-import/source-manifest.json | 5 + global.json | 7 + package.json | 27 + pnpm-lock.yaml | 3903 +++++++++++ pnpm-workspace.yaml | 7 + scripts/check-openapi-coverage.mjs | 99 + scripts/doctor.mjs | 98 + scripts/generate-release-manifest.mjs | 131 + scripts/lib/exec.mjs | 106 + scripts/validate-openapi.mjs | 79 + scripts/validate-public-source.mjs | 31 + scripts/verify.mjs | 238 + .../Authorization/AuthorizationPolicies.cs | 10 + .../ContentAccessAuthorizationHandler.cs | 58 + .../ContentAccessEndpointExtensions.cs | 26 + .../Authorization/ContentAccessRequirement.cs | 20 + .../ServiceTokenAuthenticationHandler.cs | 89 + .../WorkspaceRoleAuthorizationHandler.cs | 44 + .../Authorization/WorkspaceRoleRequirement.cs | 12 + .../StagingSessionCleanupBackgroundService.cs | 109 + .../Endpoints/ActivityEndpoints.cs | 30 + .../Endpoints/AdminEndpoints.cs | 233 + .../Endpoints/AttachmentsEndpoints.cs | 226 + .../Endpoints/AuthEndpoints.cs | 136 + .../Endpoints/BackupsEndpoints.cs | 52 + .../Endpoints/DecisionsEndpoints.cs | 197 + .../Endpoints/DraftsEndpoints.cs | 119 + .../Endpoints/ImportsEndpoints.cs | 312 + .../Endpoints/InfrastructureEndpoints.cs | 565 ++ .../Endpoints/IngestionProposalsEndpoints.cs | 318 + .../Endpoints/PagesEndpoints.cs | 582 ++ .../Endpoints/ProjectsEndpoints.cs | 548 ++ .../Endpoints/RagPublicationEndpoints.cs | 81 + .../Endpoints/RunbooksEndpoints.cs | 204 + .../Endpoints/SearchEndpoints.cs | 51 + .../Endpoints/ServiceAccountsEndpoints.cs | 135 + .../Endpoints/SetupEndpoints.cs | 88 + .../Endpoints/SourceBatchPurgeEndpoints.cs | 77 + .../Endpoints/TemplatesEndpoints.cs | 28 + .../Endpoints/WorkspaceEndpoints.cs | 33 + .../ItWorx.KnowledgeHub.Api.csproj | 12 + .../Knowledge/ETagHelper.cs | 13 + .../Operations/RestoreModeRunner.cs | 69 + .../ItWorx.KnowledgeHub.Api/Program.cs | 608 ++ .../Properties/launchSettings.json | 15 + .../Security/AntiforgeryEndpointFilter.cs | 38 + .../Security/CorrelationIdMiddleware.cs | 27 + .../Security/IdempotencyEndpointFilter.cs | 175 + .../Security/RateLimitPolicies.cs | 11 + .../Security/SecurityHeadersMiddleware.cs | 30 + .../ServiceTokenFailureRateLimiter.cs | 27 + .../appsettings.Development.json | 9 + .../ItWorx.KnowledgeHub.Api/appsettings.json | 17 + .../wwwroot/index.html | 30 + .../AssemblyMarker.cs | 5 + .../Attachments/AttachmentContentLock.cs | 91 + .../Attachments/IAttachmentBlobStore.cs | 41 + .../Attachments/IAttachmentRepository.cs | 105 + .../Attachments/IContentSignatureDetector.cs | 19 + .../OrphanedBlobGarbageCollector.cs | 58 + .../UseCases/GetAttachmentContentUseCase.cs | 137 + .../UseCases/GetPageAttachmentsUseCase.cs | 19 + .../UseCases/UploadAttachmentUseCase.cs | 245 + .../Audit/AuditEventRecord.cs | 24 + .../Audit/IAuditEventReader.cs | 10 + .../Audit/IAuditEventRecorder.cs | 11 + .../Automation/IIdempotencyStore.cs | 10 + .../Automation/IOutboxRepository.cs | 19 + .../Automation/IServiceAccountRepository.cs | 27 + .../Automation/IServiceAccountTokenIssuer.cs | 20 + .../AuthenticateServiceTokenUseCase.cs | 53 + .../UseCases/CreateServiceAccountUseCase.cs | 42 + .../IssueServiceAccountTokenUseCase.cs | 45 + .../UseCases/ListServiceAccountsUseCase.cs | 21 + .../RevokeServiceAccountTokenUseCase.cs | 36 + .../Common/OperationResult.cs | 46 + .../Common/SafeUrlValidator.cs | 46 + .../Decisions/IDecisionRepository.cs | 31 + .../UseCases/AcceptDecisionUseCase.cs | 68 + .../UseCases/CreateDecisionUseCase.cs | 112 + .../Decisions/UseCases/GetDecisionUseCase.cs | 43 + .../UseCases/ListDecisionsUseCase.cs | 8 + .../UseCases/RejectDecisionUseCase.cs | 51 + .../UseCases/WithdrawDecisionUseCase.cs | 51 + .../Identity/IInstallationRepository.cs | 14 + .../Identity/IRecoveryTokenService.cs | 18 + .../Identity/IUserAccountManager.cs | 53 + .../IWorkspaceMembershipRepository.cs | 26 + .../Identity/IWorkspaceRepository.cs | 10 + .../Identity/PasswordSignInOutcome.cs | 24 + .../UseCases/CompleteFirstRunSetupUseCase.cs | 106 + .../UseCases/CreateWorkspaceUserUseCase.cs | 109 + .../Identity/UseCases/GetSessionUseCase.cs | 44 + .../UseCases/IssueRecoveryTokenUseCase.cs | 58 + .../UseCases/ListWorkspaceUsersUseCase.cs | 37 + .../Identity/UseCases/LoginUseCase.cs | 58 + .../Identity/UseCases/LogoutUseCase.cs | 31 + .../UseCases/RedeemRecoveryTokenUseCase.cs | 70 + .../UseCases/SearchWorkspaceMembersUseCase.cs | 54 + .../UseCases/UpdateWorkspaceUserUseCase.cs | 135 + .../Identity/UserAccount.cs | 11 + .../Identity/UserAccountCreationResult.cs | 23 + .../IInfrastructureRepository.cs | 209 + .../ImportCuratedInventoryPackageUseCase.cs | 119 + .../UseCases/InfrastructureQueryUseCases.cs | 54 + .../UseCases/ManageManualOverrideUseCases.cs | 102 + .../ReviewInfrastructureServiceUseCase.cs | 88 + ...nchronizeInfrastructureInventoryUseCase.cs | 58 + ...teInfrastructureEditorialProfileUseCase.cs | 100 + .../Validation/EditorialProfileValidator.cs | 148 + .../Validation/ManualOverrideCatalog.cs | 205 + .../Ingestion/IIngestionProposalRepository.cs | 42 + .../IIngestionSourceRetentionRepository.cs | 20 + .../Ingestion/IngestionMetrics.cs | 41 + .../Ingestion/IngestionProposalOptions.cs | 11 + .../ApplyIngestionProposalAnalysisUseCase.cs | 35 + .../CleanupExpiredIngestionSourcesUseCase.cs | 70 + .../CreateIngestionProposalUseCase.cs | 35 + .../DecideIngestionProposalUseCase.cs | 33 + .../UseCases/PatchIngestionProposalUseCase.cs | 26 + .../PublishIngestionProposalUseCase.cs | 173 + .../UseCases/RetryIngestionProposalUseCase.cs | 21 + .../StageIngestionProposalSourceUseCase.cs | 124 + .../SubmitManualIngestionProposalUseCase.cs | 34 + .../ItWorx.KnowledgeHub.Application.csproj | 10 + .../Knowledge/IDraftRepository.cs | 14 + .../Knowledge/IMarkdownRenderer.cs | 21 + .../IPageCreationSessionRepository.cs | 50 + .../Knowledge/IPageRepository.cs | 124 + .../Knowledge/ISearchRepository.cs | 37 + .../Knowledge/ITemplateProvider.cs | 15 + .../Knowledge/InternalLinkExtractor.cs | 46 + .../Knowledge/LinkResolutionService.cs | 65 + .../Knowledge/PageCreationSessionOptions.cs | 26 + .../Knowledge/RagPublicationOptions.cs | 19 + .../BeginPageCreationSessionUseCase.cs | 46 + ...eanupExpiredPageCreationSessionsUseCase.cs | 122 + .../CommitPageCreationSessionUseCase.cs | 152 + .../Knowledge/UseCases/CreatePageUseCase.cs | 133 + .../Knowledge/UseCases/DiscardDraftUseCase.cs | 17 + .../DiscardPageCreationSessionUseCase.cs | 77 + .../Knowledge/UseCases/GetDraftUseCase.cs | 9 + .../UseCases/GetPageBacklinksUseCase.cs | 17 + .../UseCases/GetPageChildrenUseCase.cs | 32 + .../UseCases/GetPageRevisionUseCase.cs | 24 + .../UseCases/GetPageRevisionsUseCase.cs | 17 + .../Knowledge/UseCases/GetPageUseCase.cs | 65 + .../GetRagPublicationManifestUseCase.cs | 121 + .../Knowledge/UseCases/MovePageUseCase.cs | 105 + .../Knowledge/UseCases/PageOperationResult.cs | 20 + .../Knowledge/UseCases/PurgeTrashUseCase.cs | 41 + .../UseCases/RestorePageRevisionUseCase.cs | 98 + .../Knowledge/UseCases/RestorePageUseCase.cs | 45 + .../Knowledge/UseCases/SaveDraftUseCase.cs | 72 + .../Knowledge/UseCases/SearchPagesUseCase.cs | 25 + .../UseCases/StagingCleanupStatusTracker.cs | 54 + .../Knowledge/UseCases/TrashPageUseCase.cs | 57 + .../Knowledge/UseCases/UpdatePageUseCase.cs | 113 + .../Migration/IImportRepository.cs | 29 + .../Migration/ILeafWikiAdapter.cs | 19 + .../Migration/ILeafWikiAdapterCatalog.cs | 15 + .../Migration/ILeafWikiSourceReader.cs | 16 + .../Migration/IPageRollbackRepository.cs | 23 + .../Migration/ISourceBatchPurgeRepository.cs | 58 + .../Migration/ImportPlan.cs | 42 + .../Migration/SourceFingerprint.cs | 22 + .../UseCases/ExecuteImportUseCase.cs | 377 + .../Migration/UseCases/GetImportJobUseCase.cs | 9 + .../UseCases/PreflightImportUseCase.cs | 90 + .../UseCases/PurgeSourceBatchUseCase.cs | 94 + .../UseCases/RollbackImportUseCase.cs | 86 + .../Operations/BackupManifest.cs | 42 + .../Operations/IBackupService.cs | 18 + .../Operations/IRestoreService.cs | 34 + .../UseCases/CreateBackupUseCase.cs | 57 + .../Operations/UseCases/ListBackupsUseCase.cs | 7 + .../Projects/IProjectRepository.cs | 80 + .../UseCases/ChangeProjectStatusUseCase.cs | 69 + .../UseCases/CreateProjectMilestoneUseCase.cs | 59 + .../UseCases/CreateProjectUpdateUseCase.cs | 110 + .../Projects/UseCases/CreateProjectUseCase.cs | 58 + .../GetProjectCatalogTotalsUseCase.cs | 7 + .../UseCases/GetProjectContextUseCase.cs | 116 + .../UseCases/GetProjectMilestonesUseCase.cs | 9 + .../UseCases/GetProjectOverviewUseCase.cs | 57 + .../UseCases/GetProjectUpdatesUseCase.cs | 7 + .../UseCases/LinkProjectDocumentUseCase.cs | 56 + .../Projects/UseCases/ListProjectsUseCase.cs | 33 + .../UseCases/SetProjectIconUseCase.cs | 169 + .../UseCases/UnlinkProjectDocumentUseCase.cs | 33 + .../UseCases/UpdateProjectMilestoneUseCase.cs | 75 + .../Projects/UseCases/UpdateProjectUseCase.cs | 111 + .../Runbooks/IRunbookRepository.cs | 26 + .../Runbooks/UseCases/CreateRunbookUseCase.cs | 91 + .../Runbooks/UseCases/GetRunbookUseCase.cs | 27 + .../Runbooks/UseCases/ListRunbooksUseCase.cs | 8 + .../UseCases/UpdateRunbookProfileUseCase.cs | 77 + .../UseCases/ValidateRunbookUseCase.cs | 55 + .../Activity/ActivityContracts.cs | 7 + .../Admin/CreateUserRequest.cs | 7 + .../Admin/IssueRecoveryTokenResponse.cs | 7 + .../Admin/StagingCleanupContracts.cs | 22 + .../Admin/UpdateUserRequest.cs | 5 + .../Admin/UserSummaryResponse.cs | 10 + .../AssemblyMarker.cs | 5 + .../Attachments/AttachmentSummary.cs | 13 + .../Auth/CsrfTokenResponse.cs | 8 + .../Auth/LoginRequest.cs | 3 + .../Auth/RecoveryRedeemRequest.cs | 3 + .../Auth/SessionResponse.cs | 23 + .../Automation/ServiceAccountContracts.cs | 19 + .../Decisions/DecisionContracts.cs | 29 + .../Errors/ProblemTypes.cs | 50 + .../Identity/WorkspaceMemberContracts.cs | 10 + .../Infrastructure/InfrastructureContracts.cs | 192 + .../Ingestion/IngestionProposalContracts.cs | 83 + .../ItWorx.KnowledgeHub.Contracts.csproj | 6 + .../Knowledge/BacklinkEntry.cs | 5 + .../Knowledge/BreadcrumbEntry.cs | 3 + .../Knowledge/CreatePageRequest.cs | 9 + .../Knowledge/DraftResponse.cs | 15 + .../Knowledge/MovePageRequest.cs | 9 + .../Knowledge/PageChildrenResponse.cs | 6 + .../Knowledge/PageCreationSessionContracts.cs | 10 + .../Knowledge/PageDetail.cs | 22 + .../Knowledge/PageRevisionDetailResponse.cs | 12 + .../Knowledge/PageRevisionSummary.cs | 10 + .../Knowledge/PageSummary.cs | 11 + .../Knowledge/PreviewMarkdownContracts.cs | 5 + .../RagPublicationManifestResponse.cs | 26 + .../Knowledge/SaveDraftRequest.cs | 11 + .../Knowledge/SearchResultItem.cs | 21 + .../Knowledge/TemplateSummary.cs | 3 + .../Knowledge/TrashPurgeResponse.cs | 3 + .../Knowledge/UpdatePageRequest.cs | 9 + .../Migration/ImportContracts.cs | 52 + .../Migration/SourceBatchPurgeContracts.cs | 53 + .../Operations/BackupContracts.cs | 16 + .../Projects/ProjectContextResponse.cs | 21 + .../Projects/ProjectContracts.cs | 121 + .../Runbooks/RunbookContracts.cs | 40 + .../Setup/CompleteSetupRequest.cs | 7 + .../Setup/CompleteSetupResponse.cs | 3 + .../Setup/SetupStatusResponse.cs | 6 + .../SystemInfo/SystemInfoResponse.cs | 37 + .../AssemblyMarker.cs | 5 + .../Attachments/AttachmentBlob.cs | 80 + .../Attachments/AttachmentDeliveryPolicy.cs | 23 + .../Attachments/AttachmentReference.cs | 91 + .../Attachments/AttachmentStorageState.cs | 9 + .../Attachments/AttachmentUsage.cs | 83 + .../Attachments/SafeFilename.cs | 77 + .../Automation/IdempotencyRecord.cs | 50 + .../Automation/OutboxMessage.cs | 71 + .../Automation/Scopes.cs | 47 + .../Automation/ServiceAccount.cs | 64 + .../Automation/ServiceAccountToken.cs | 91 + .../Decisions/DecisionRecord.cs | 150 + .../Decisions/DecisionStatus.cs | 11 + .../Identity/DisplayName.cs | 35 + .../Identity/EmailAddress.cs | 64 + .../Identity/Installation.cs | 77 + .../Identity/Workspace.cs | 53 + .../Identity/WorkspaceMembership.cs | 57 + .../Identity/WorkspaceName.cs | 76 + .../Identity/WorkspaceRole.cs | 14 + .../Infrastructure/EditorialKnowledge.cs | 137 + .../Infrastructure/FileSystemPathBoundary.cs | 18 + .../Infrastructure/InfrastructureEntities.cs | 391 ++ .../Infrastructure/InfrastructureEnums.cs | 46 + .../Infrastructure/InventoryChange.cs | 40 + .../Infrastructure/InventorySnapshot.cs | 70 + .../Ingestion/IngestionProposal.cs | 430 ++ .../Ingestion/IngestionProposalStatus.cs | 79 + .../ItWorx.KnowledgeHub.Domain.csproj | 6 + .../Knowledge/DocumentStatus.cs | 10 + .../Knowledge/DocumentType.cs | 16 + .../Knowledge/Draft.cs | 134 + .../Knowledge/Page.cs | 262 + .../Knowledge/PageCreationSession.cs | 110 + .../Knowledge/PageCreationSessionStatus.cs | 8 + .../Knowledge/PageId.cs | 8 + .../Knowledge/PageLink.cs | 73 + .../Knowledge/PageRevision.cs | 85 + .../Knowledge/PageSlug.cs | 64 + .../Knowledge/PageTitle.cs | 35 + .../Knowledge/RagPublicationTags.cs | 94 + .../Knowledge/SortKey.cs | 63 + .../Migration/ImportItem.cs | 78 + .../Migration/ImportItemStatus.cs | 10 + .../Migration/ImportJob.cs | 153 + .../Migration/ImportJobStatus.cs | 11 + .../Migration/ImportProvenance.cs | 43 + .../Migration/ImportSourceKind.cs | 9 + .../Migration/ImportTargetEntityType.cs | 7 + .../Projects/Project.cs | 254 + .../Projects/ProjectDocument.cs | 32 + .../Projects/ProjectDocumentRole.cs | 17 + .../Projects/ProjectId.cs | 8 + .../Projects/ProjectLifecycleStatus.cs | 14 + .../Projects/ProjectMilestone.cs | 104 + .../Projects/ProjectMilestoneState.cs | 9 + .../Projects/ProjectPriority.cs | 9 + .../Projects/ProjectStatus.cs | 13 + .../Projects/ProjectUpdate.cs | 156 + .../Projects/ProjectUpdateCategory.cs | 17 + .../ProjectUpdateVerificationStatus.cs | 10 + .../Runbooks/RunbookProfile.cs | 125 + .../Runbooks/RunbookRiskLevel.cs | 9 + .../Runbooks/RunbookStatus.cs | 8 + .../AssemblyMarker.cs | 5 + .../Attachments/AttachmentRepository.cs | 194 + .../Attachments/ContentSignatureDetector.cs | 169 + .../FileSystemAttachmentBlobStore.cs | 132 + .../Audit/AuditEventReader.cs | 19 + .../Audit/AuditEventRecorder.cs | 50 + .../Automation/IdempotencyStore.cs | 19 + .../Automation/OutboxRepository.cs | 19 + .../Automation/ServiceAccountRepository.cs | 46 + .../Automation/ServiceAccountTokenIssuer.cs | 70 + .../Decisions/DecisionRepository.cs | 143 + .../Identity/EnabledAccountConfirmation.cs | 15 + .../Identity/InstallationDefaults.cs | 9 + .../Identity/InstallationRepository.cs | 57 + .../Identity/RecoveryTokenService.cs | 62 + .../Identity/UserAccountManager.cs | 251 + .../Identity/WorkspaceMembershipRepository.cs | 64 + .../Identity/WorkspaceRepository.cs | 33 + .../InfrastructureRepository.cs | 831 +++ ...frastructureServiceCollectionExtensions.cs | 219 + .../Ingestion/IngestionProposalRepository.cs | 216 + .../ItWorx.KnowledgeHub.Infrastructure.csproj | 37 + .../Knowledge/DraftRepository.cs | 26 + .../ManagedMarkdownControlMarkers.cs | 48 + .../Knowledge/MarkdownRenderer.cs | 133 + .../PageCreationSessionRepository.cs | 49 + .../Knowledge/PageRepository.cs | 378 + .../Knowledge/SearchRepository.cs | 230 + .../Knowledge/TemplateProvider.cs | 131 + .../FileSystemLeafWikiSourceReader.cs | 249 + .../LeafWiki/FilesystemMarkdownAdapter.cs | 251 + .../LeafWiki/ImportRepository.cs | 54 + .../LeafWiki/LeafWikiAdapterCatalog.cs | 25 + .../LeafWiki/LeafWikiV5FilesystemAdapter.cs | 706 ++ .../LeafWiki/PageRollbackRepository.cs | 73 + .../LeafWiki/SourceBatchPurgeRepository.cs | 230 + .../LeafWiki/SyntheticSqliteAdapter.cs | Bin 0 -> 7007 bytes .../Operations/BackupPaths.cs | 6 + .../Operations/DatabaseHealthCheck.cs | 28 + .../Operations/PostgresBackupService.cs | 344 + .../Operations/PostgresRestoreService.cs | 174 + .../Operations/ReleaseManifest.cs | 57 + .../Operations/StorageHealthCheck.cs | 52 + .../Operations/TableDiscovery.cs | 65 + .../Persistence/ApplicationUser.cs | 25 + .../Persistence/AuditEventEntity.cs | 24 + .../ApplicationUserConfiguration.cs | 14 + .../AttachmentBlobConfiguration.cs | 21 + .../AttachmentReferenceConfiguration.cs | 28 + .../AttachmentUsageConfiguration.cs | 55 + .../Configurations/AuditEventConfiguration.cs | 27 + .../DecisionRecordConfiguration.cs | 46 + .../Configurations/DraftConfiguration.cs | 31 + .../EditorialKnowledgeConfigurations.cs | 56 + .../IdempotencyRecordConfiguration.cs | 24 + .../Configurations/ImportItemConfiguration.cs | 22 + .../Configurations/ImportJobConfiguration.cs | 23 + .../ImportProvenanceConfiguration.cs | 20 + .../InfrastructureHostConfiguration.cs | 24 + .../InfrastructureServiceConfiguration.cs | 36 + .../IngestionProposalConfiguration.cs | 83 + .../InstallationConfiguration.cs | 26 + .../InventoryChangeConfiguration.cs | 22 + .../InventorySnapshotConfiguration.cs | 22 + .../OutboxMessageConfiguration.cs | 32 + .../Configurations/PageConfiguration.cs | 66 + .../PageCreationSessionConfiguration.cs | 30 + .../Configurations/PageLinkConfiguration.cs | 21 + .../PageRevisionConfiguration.cs | 27 + .../Configurations/ProjectConfiguration.cs | 47 + .../ProjectDocumentConfiguration.cs | 21 + .../ProjectMilestoneConfiguration.cs | 30 + .../ProjectUpdateConfiguration.cs | 33 + .../RecoveryTokenConfiguration.cs | 20 + .../RunbookProfileConfiguration.cs | 32 + .../ServiceAccountConfiguration.cs | 23 + .../ServiceAccountTokenConfiguration.cs | 28 + .../ServiceFactConfigurations.cs | 131 + .../Configurations/WorkspaceConfiguration.cs | 21 + .../WorkspaceMembershipConfiguration.cs | 23 + .../Persistence/DatabaseConnectionOptions.cs | 6 + .../DatabaseConnectionStringFactory.cs | 31 + .../Persistence/KnowledgeHubDbContext.cs | 135 + .../KnowledgeHubDbContextFactory.cs | 24 + .../20260731162849_InitialCreate.Designer.cs | 484 ++ .../20260731162849_InitialCreate.cs | 284 + ...260731185224_AddKnowledgePages.Designer.cs | 783 +++ .../20260731185224_AddKnowledgePages.cs | 170 + ...31201729_AddDiscoveryAndSearch.Designer.cs | 788 +++ .../20260731201729_AddDiscoveryAndSearch.cs | 91 + ...15422_AddAttachmentsAndImports.Designer.cs | 1116 +++ ...20260731215422_AddAttachmentsAndImports.cs | 203 + ...3144_AddProjectHubAdrsRunbooks.Designer.cs | 1592 +++++ ...0260801013144_AddProjectHubAdrsRunbooks.cs | 255 + ...1023652_AddAutomationAndOutbox.Designer.cs | 1808 +++++ .../20260801023652_AddAutomationAndOutbox.cs | 134 + ...ucturedInfrastructureKnowledge.Designer.cs | 2689 ++++++++ ...39_AddStructuredInfrastructureKnowledge.cs | 572 ++ ...3193236_AddPageCreationStaging.Designer.cs | 2759 ++++++++ .../20260803193236_AddPageCreationStaging.cs | 101 + ...0803195722_ExtendDraftMetadata.Designer.cs | 2779 ++++++++ .../20260803195722_ExtendDraftMetadata.cs | 62 + ...ctureServicesDetectedNameIndex.Designer.cs | 2782 ++++++++ ...InfrastructureServicesDetectedNameIndex.cs | 27 + ...20120000_AddIngestionProposals.Designer.cs | 3049 ++++++++ .../20260920120000_AddIngestionProposals.cs | 130 + ...estionProposalAttachmentUsages.Designer.cs | 3062 ++++++++ ...18_AddIngestionProposalAttachmentUsages.cs | 68 + ...173238_AddIngestionPublication.Designer.cs | 3135 +++++++++ .../20260920173238_AddIngestionPublication.cs | 134 + ...0923221837_AddPageTagsGinIndex.Designer.cs | 3140 +++++++++ .../20260923221837_AddPageTagsGinIndex.cs | 28 + ...20260924182209_AddProjectIcons.Designer.cs | 3168 +++++++++ .../20260924182209_AddProjectIcons.cs | 100 + .../KnowledgeHubDbContextModelSnapshot.cs | 3165 +++++++++ .../Persistence/RecoveryTokenEntity.cs | 20 + .../Persistence/SchemaGuard.cs | 46 + .../Projects/ProjectRepository.cs | 332 + .../Runbooks/RunbookRepository.cs | 137 + src/frontend/eslint.config.mjs | 31 + src/frontend/index.html | 17 + src/frontend/package.json | 58 + src/frontend/public/favicon.svg | 6 + .../project-icons/blockpilot-autonomous.png | Bin 0 -> 55470 bytes src/frontend/public/project-icons/chimera.png | Bin 0 -> 353492 bytes .../public/project-icons/devrunbook.png | Bin 0 -> 15050 bytes .../public/project-icons/dockdeck.png | Bin 0 -> 100522 bytes .../public/project-icons/evidencevault.png | Bin 0 -> 23068 bytes .../public/project-icons/geointel.png | Bin 0 -> 25979 bytes src/frontend/public/project-icons/gifted.png | Bin 0 -> 17248 bytes .../public/project-icons/infrabinder.png | Bin 0 -> 13330 bytes .../public/project-icons/itworx-mcp-hub.png | Bin 0 -> 1242042 bytes .../public/project-icons/knowledge-hub.svg | 6 + .../public/project-icons/ludarium.png | Bin 0 -> 34655 bytes src/frontend/public/project-icons/lumaops.svg | 12 + src/frontend/public/project-icons/mcop.png | Bin 0 -> 10754 bytes .../public/project-icons/mobilityops.svg | 6 + .../pokemon-legacy-rom-builder.png | Bin 0 -> 103981 bytes .../project-icons/portfolio-itworx-tech.png | Bin 0 -> 54726 bytes .../project-icons/project-launchpad.png | Bin 0 -> 7293 bytes .../public/project-icons/projectbrain.png | Bin 0 -> 29208 bytes src/frontend/public/project-icons/psforge.png | Bin 0 -> 7379 bytes .../public/project-icons/quizmonitor.png | Bin 0 -> 3091 bytes src/frontend/public/project-icons/ragcore.svg | 11 + .../public/project-icons/spoortracker.png | Bin 0 -> 4602 bytes .../project-icons/unraid-appops-gateway.png | Bin 0 -> 6567 bytes .../public/project-icons/vacatureradar.svg | 6 + .../public/project-icons/widefrog.png | Bin 0 -> 8546 bytes src/frontend/src/App.test.tsx | 148 + src/frontend/src/App.tsx | 83 + src/frontend/src/api/activityTypes.ts | 13 + src/frontend/src/api/adminTypes.ts | 94 + src/frontend/src/api/attachmentTypes.ts | 14 + src/frontend/src/api/client.test.ts | 51 + src/frontend/src/api/client.ts | 174 + src/frontend/src/api/infrastructureTypes.ts | 187 + src/frontend/src/api/intake.test.ts | 98 + src/frontend/src/api/intake.ts | 54 + src/frontend/src/api/intakeTypes.ts | 23 + src/frontend/src/api/knowledgeTypes.ts | 194 + src/frontend/src/api/projectTypes.ts | 345 + src/frontend/src/api/setupTypes.ts | 4 + src/frontend/src/api/types.ts | 28 + src/frontend/src/auth/SessionContext.tsx | 54 + src/frontend/src/auth/session-context.ts | 22 + src/frontend/src/components/AuthShell.tsx | 38 + .../src/components/CommandPalette.test.tsx | 148 + .../src/components/CommandPalette.tsx | 266 + .../src/components/DataTable.test.tsx | 159 + src/frontend/src/components/DataTable.tsx | 253 + .../src/components/EntityDialogs.test.tsx | 118 + .../components/InventoryReviewPanel.test.tsx | 80 + .../src/components/InventoryReviewPanel.tsx | 217 + .../components/LeafWikiImportPanel.test.tsx | 91 + .../src/components/LeafWikiImportPanel.tsx | 241 + .../src/components/MarkdownContent.test.tsx | 89 + .../src/components/MarkdownContent.tsx | 56 + .../src/components/OwnerPicker.test.tsx | 100 + src/frontend/src/components/OwnerPicker.tsx | 200 + .../src/components/PageActions.test.tsx | 114 + src/frontend/src/components/PageActions.tsx | 93 + .../src/components/ProductPrimitives.tsx | 30 + .../src/components/ProjectDialogs.tsx | 378 + .../ProjectWorkspaceActions.test.tsx | 101 + .../components/ProjectWorkspaceActions.tsx | 72 + .../src/components/RunbookCreateDialog.tsx | 142 + .../ServiceOverrideManager.test.tsx | 116 + .../src/components/ServiceOverrideManager.tsx | 155 + src/frontend/src/components/Tooltip.test.tsx | 54 + src/frontend/src/components/Tooltip.tsx | 52 + .../src/components/infrastructureFormat.ts | 5 + .../src/components/ingestion/EmptyState.tsx | 6 + .../src/components/ingestion/ErrorState.tsx | 11 + .../ingestion/IngestionProgress.tsx | 5 + .../ingestion/IngestionQueueCard.test.tsx | 69 + .../ingestion/IngestionQueueCard.tsx | 23 + .../ingestion/IngestionQueueFilters.test.tsx | 33 + .../ingestion/IngestionQueueFilters.tsx | 24 + .../ingestion/IngestionQueueTable.tsx | 13 + .../src/components/ingestion/LoadingState.tsx | 6 + .../ingestion/ManualDocumentIntake.test.tsx | 88 + .../ingestion/ManualDocumentIntake.tsx | 84 + .../ingestion/MobileReviewPanels.tsx | 26 + .../ingestion/ProposalContentDiff.tsx | 9 + .../ingestion/ProposalDecisionPane.tsx | 15 + .../ingestion/ProposalEvidencePane.tsx | 10 + .../ingestion/ProposalMetadataForm.tsx | 18 + .../ingestion/ProposalQueuePane.tsx | 7 + .../ingestion/ProposalReviewHistory.tsx | 7 + .../ingestion/ProposalSourceList.tsx | 11 + .../components/ingestion/StatusBadge.test.tsx | 39 + .../src/components/ingestion/StatusBadge.tsx | 28 + .../ingestion/ingestionQueueFilterState.ts | 38 + src/frontend/src/components/statusLabels.ts | 98 + src/frontend/src/design-system/auth.css | 159 + src/frontend/src/design-system/base.css | 22 + src/frontend/src/design-system/components.css | 190 + src/frontend/src/design-system/documents.css | 336 + src/frontend/src/design-system/editor.css | 13 + src/frontend/src/design-system/forms.css | 383 + src/frontend/src/design-system/responsive.css | 26 + src/frontend/src/design-system/shell.css | 41 + src/frontend/src/design-system/tokens.css | 96 + .../src/design-system/typography.test.ts | 216 + src/frontend/src/design-system/workspaces.css | 95 + src/frontend/src/hooks/useDocumentTitle.ts | 9 + src/frontend/src/hooks/useMobileViewport.ts | 49 + src/frontend/src/hooks/useOnlineStatus.ts | 30 + .../layouts/AppShell.accessibility.test.tsx | 69 + src/frontend/src/layouts/AppShell.tsx | 626 ++ .../src/layouts/active-entity-context.ts | 44 + .../src/layouts/context-shortcuts.test.ts | 35 + src/frontend/src/layouts/context-shortcuts.ts | 18 + src/frontend/src/lib/clipboard.ts | 46 + src/frontend/src/lib/uuid.test.ts | 37 + src/frontend/src/lib/uuid.ts | 43 + src/frontend/src/main.tsx | 27 + .../src/notifications/ErrorBoundary.tsx | 78 + .../src/notifications/OfflineBanner.test.tsx | 42 + .../src/notifications/OfflineBanner.tsx | 29 + .../src/notifications/ToastProvider.tsx | 108 + .../src/notifications/toast-context.ts | 63 + .../pages/AdminPage.courseTagging.test.tsx | 241 + src/frontend/src/pages/AdminPage.test.tsx | 227 + src/frontend/src/pages/AdminPage.tsx | 800 +++ src/frontend/src/pages/LoginPage.test.tsx | 180 + src/frontend/src/pages/LoginPage.tsx | 273 + src/frontend/src/pages/SetupPage.test.tsx | 54 + src/frontend/src/pages/SetupPage.tsx | 145 + src/frontend/src/pages/StatusScreens.tsx | 18 + .../src/pages/knowledge/Attachments.test.tsx | 123 + .../src/pages/knowledge/Attachments.tsx | 136 + .../src/pages/knowledge/Backlinks.test.tsx | 57 + .../src/pages/knowledge/Backlinks.tsx | 56 + .../pages/knowledge/KnowledgeTree.test.tsx | 316 + .../src/pages/knowledge/KnowledgeTree.tsx | 357 + .../PageEditorPage.insecureContext.test.tsx | 60 + .../pages/knowledge/PageEditorPage.test.tsx | 499 ++ .../src/pages/knowledge/PageEditorPage.tsx | 384 ++ .../src/pages/knowledge/PageViewPage.test.tsx | 250 + .../src/pages/knowledge/PageViewPage.tsx | 314 + .../pages/knowledge/RevisionHistory.test.tsx | 123 + .../src/pages/knowledge/RevisionHistory.tsx | 226 + .../src/pages/knowledge/SearchPage.test.tsx | 185 + .../src/pages/knowledge/SearchPage.tsx | 201 + .../src/pages/knowledge/editorAppearance.ts | 98 + .../pages/knowledge/knowledgeMetadata.test.ts | 25 + .../src/pages/knowledge/knowledgeMetadata.ts | 55 + .../pages/knowledge/recentKnowledge.test.ts | 21 + .../src/pages/knowledge/recentKnowledge.ts | 21 + .../src/pages/knowledge/revisionDiff.ts | 16 + .../src/pages/projects/DecisionDetailView.tsx | 228 + .../pages/projects/RunbookDetailView.test.tsx | 86 + .../src/pages/projects/RunbookDetailView.tsx | 292 + .../src/pages/projects/formHelpers.ts | 8 + .../src/pages/v2/ActivityPage.test.tsx | 62 + src/frontend/src/pages/v2/ActivityPage.tsx | 176 + src/frontend/src/pages/v2/AdminRoutePage.tsx | 3 + .../src/pages/v2/DashboardPage.test.tsx | 156 + src/frontend/src/pages/v2/DashboardPage.tsx | 117 + .../src/pages/v2/DecisionWorkspacePage.tsx | 16 + .../v2/InfrastructureOverviewPage.test.tsx | 80 + .../pages/v2/InfrastructureOverviewPage.tsx | 51 + .../src/pages/v2/IngestionQueuePage.test.tsx | 245 + .../src/pages/v2/IngestionQueuePage.tsx | 58 + .../src/pages/v2/IngestionReviewPage.test.tsx | 396 ++ .../src/pages/v2/IngestionReviewPage.tsx | 59 + .../src/pages/v2/KnowledgeEditorRoute.tsx | 53 + .../src/pages/v2/KnowledgeRoutePage.test.tsx | 196 + .../src/pages/v2/KnowledgeRoutePage.tsx | 245 + src/frontend/src/pages/v2/NotFoundPage.tsx | 4 + .../src/pages/v2/ProjectWorkspacePage.tsx | 323 + .../pages/v2/ProjectsOverviewPage.test.tsx | 214 + .../src/pages/v2/ProjectsOverviewPage.tsx | 382 + src/frontend/src/pages/v2/RouteStates.tsx | 20 + .../src/pages/v2/RunbookWorkspacePage.tsx | 9 + .../pages/v2/RunbooksOverviewPage.test.tsx | 42 + .../src/pages/v2/RunbooksOverviewPage.tsx | 112 + src/frontend/src/pages/v2/SearchRoutePage.tsx | 4 + .../src/pages/v2/ServiceDetailPage.tsx | 161 + .../src/pages/v2/ServicesCatalogPage.test.tsx | 251 + .../src/pages/v2/ServicesCatalogPage.tsx | 433 ++ src/frontend/src/pages/v2/SettingsPage.tsx | 35 + .../src/pages/v2/StoragePage.test.tsx | 75 + src/frontend/src/pages/v2/StoragePage.tsx | 249 + .../pages/v2/ingestionProposalMutations.ts | 42 + .../preferences/PreferencesContext.test.tsx | 166 + .../src/preferences/PreferencesContext.tsx | 171 + .../src/preferences/preferences-context.ts | 39 + .../src/router/AuthenticatedRoutes.test.tsx | 214 + .../src/router/AuthenticatedRoutes.tsx | 60 + src/frontend/src/test/motionTestUtils.tsx | 41 + src/frontend/src/test/setup.ts | 32 + .../src/visualizations/ProjectArtwork.tsx | 47 + .../visualizations/ProjectSignature.test.tsx | 107 + .../src/visualizations/ProjectSignature.tsx | 13 + .../src/visualizations/ServerMap.test.tsx | 159 + src/frontend/src/visualizations/ServerMap.tsx | 61 + .../visualizations/ServiceTopology.test.tsx | 119 + .../src/visualizations/ServiceTopology.tsx | 35 + src/frontend/src/visualizations/hostLabel.ts | 14 + .../visualizations/projectArtworkGeometry.ts | 27 + .../src/visualizations/projectLogoSource.ts | 45 + src/frontend/src/vite-env.d.ts | 1 + src/frontend/tsconfig.app.json | 24 + src/frontend/tsconfig.json | 7 + src/frontend/tsconfig.node.json | 21 + src/frontend/vite.config.ts | 41 + src/frontend/vitest.config.ts | 21 + tests/backend/Directory.Build.props | 13 + .../ForwardedHeadersTrustTests.cs | 102 + .../GlobalUsings.cs | 1 + .../HealthEndpointTests.cs | 45 + .../ItWorx.KnowledgeHub.Api.Tests.csproj | 18 + .../KnowledgeHubApiFactory.cs | 38 + .../RestoreModeRunnerTests.cs | 90 + .../SecurityHeaderTests.cs | 49 + .../GlobalUsings.cs | 1 + ...orx.KnowledgeHub.Architecture.Tests.csproj | 14 + .../ProjectReferenceTests.cs | 72 + .../AttachmentUsageTests.cs | 16 + .../DecisionRecordTests.cs | 102 + .../DraftTests.cs | 55 + .../EmailAddressTests.cs | 33 + .../FileSystemPathBoundaryTests.cs | 25 + .../GlobalUsings.cs | 1 + .../InfrastructureKnowledgeTests.cs | 58 + .../IngestionProposalTests.cs | 354 + .../InstallationTests.cs | 29 + .../ItWorx.KnowledgeHub.Domain.Tests.csproj | 21 + .../PageCreationSessionTests.cs | 77 + .../PageTests.cs | 130 + .../PageTitleTests.cs | 31 + .../ProjectTests.cs | 72 + .../RunbookProfileTests.cs | 38 + .../SafeFilenameTests.cs | 64 + .../ServiceAccountTests.cs | 124 + .../SortKeyTests.cs | 36 + .../WorkspaceNameTests.cs | 38 + .../Attachments/AttachmentUploadTests.cs | 250 + .../StagedAttachmentAuthorizationTests.cs | 229 + .../Auth/ConcurrentLoginTests.cs | 29 + .../Auth/CsrfTests.cs | 50 + .../Auth/LoginLogoutLockoutTests.cs | 132 + .../Auth/RecoveryTests.cs | 147 + .../Auth/SessionExpiryTests.cs | 36 + .../Auth/WorkspaceMemberSearchTests.cs | 87 + .../Authorization/RoleAuthorizationTests.cs | 134 + .../Automation/IdempotencyTests.cs | 83 + .../Automation/OutboxTests.cs | 88 + .../Automation/ProjectContextTests.cs | 101 + .../Automation/ServiceAccountTests.cs | 374 + .../ServiceAccountTokenIssuerTests.cs | 61 + .../GlobalUsings.cs | 1 + .../KnowledgeHubIntegrationFactory.cs | 157 + .../Infrastructure/OwnerSetupTestBase.cs | 47 + .../PostgresContainerFixture.cs | 59 + .../Infrastructure/PostgresTestGroup.cs | 7 + .../Infrastructure/SetupHelper.cs | 156 + .../EditorialProfileValidationTests.cs | 249 + .../ExpandedManualOverrideCatalogTests.cs | 174 + .../InfrastructureCatalogReleaseScaleTests.cs | 258 + .../InfrastructureCatalogScaleTests.cs | 173 + .../InfrastructurePaginationTests.cs | 248 + .../InfrastructureSynchronizationTests.cs | 514 ++ .../ManualOverrideCatalogTests.cs | 205 + .../Ingestion/IngestionObservabilityTests.cs | 184 + .../IngestionPilotAvailabilityTests.cs | 63 + .../Ingestion/IngestionPilotContractTests.cs | 327 + .../Ingestion/IngestionProposalApiTests.cs | 767 +++ .../IngestionProposalAuthorizationTests.cs | 218 + .../IngestionProposalEtagContractTests.cs | 87 + .../IngestionProposalPersistenceTests.cs | 160 + .../IngestionProposalPrivacyTests.cs | 184 + ...ngestionProposalSourceCompensationTests.cs | 259 + .../Ingestion/IngestionPublicationTests.cs | 304 + ...ngestionSourceRetentionPersistenceTests.cs | 179 + .../IngestionSourceRetentionTests.cs | 168 + ...tWorx.KnowledgeHub.IntegrationTests.csproj | 32 + .../Knowledge/BacklinksTests.cs | 72 + .../Knowledge/DraftAutosaveTests.cs | 92 + .../Knowledge/InternalLinkResolutionTests.cs | 103 + .../Knowledge/MarkdownSanitizationTests.cs | 195 + .../Knowledge/PageConcurrencyTests.cs | 85 + .../Knowledge/PageCreationStagingTests.cs | 243 + .../Knowledge/PageCrudTests.cs | 196 + .../Knowledge/PageTreeScaleTests.cs | 101 + .../Knowledge/RagPublicationManifestTests.cs | 304 + .../Knowledge/RevisionsTests.cs | 107 + .../Knowledge/SearchFullScaleTests.cs | 435 ++ .../Knowledge/SearchScaleTests.cs | 99 + .../Knowledge/SearchTests.cs | 147 + .../Knowledge/StagingSessionCleanupTests.cs | 326 + .../Knowledge/TemplateTests.cs | 65 + .../Knowledge/TrashPurgeTests.cs | 95 + .../Migration/CutoverRehearsalTests.cs | 212 + .../Migration/InPlaceUpgradeRehearsalTests.cs | 192 + .../Migration/LeafWikiImportTests.cs | 556 ++ .../Migration/LeafWikiV5ImportTests.cs | 317 + .../Migration/SourceBatchPurgeTests.cs | 168 + .../Operations/ActivityFeedTests.cs | 28 + .../Operations/BackupServiceTests.cs | 88 + .../Operations/HealthCheckTests.cs | 134 + .../Operations/LoginRedactionTests.cs | 55 + .../Operations/RestoreServiceTests.cs | 188 + .../Operations/SchemaGuardTests.cs | 89 + .../Operations/SystemInfoTests.cs | 69 + .../Projects/DecisionTests.cs | 159 + .../Projects/ProjectHubTests.cs | 362 + .../Projects/RunbookTests.cs | 145 + .../Setup/FirstRunSetupTests.cs | 67 + tests/e2e/global-setup.ts | 105 + tests/e2e/package.json | 16 + tests/e2e/playwright.config.ts | 36 + tests/e2e/specs/attachments.spec.ts | 71 + tests/e2e/specs/auth-journey.spec.ts | 56 + tests/e2e/specs/baseline.spec.ts | 21 + tests/e2e/specs/discovery.spec.ts | 103 + tests/e2e/specs/ingestion-review.spec.ts | 67 + tests/e2e/specs/knowledge-editing.spec.ts | 141 + tests/e2e/specs/project-hub.spec.ts | 168 + tests/e2e/specs/support/credentials.ts | 6 + tests/e2e/specs/support/v2.ts | 60 + .../specs/v2-editor-leafwiki-floor.spec.ts | 202 + .../specs/v2-infrastructure-review.spec.ts | 195 + .../specs/v2-m5-workspace-fidelity.spec.ts | 134 + .../specs/v2-m6-motion-and-matrices.spec.ts | 126 + tests/e2e/specs/v2-m7-admin-actions.spec.ts | 194 + .../v2-m8-accessibility-and-fidelity.spec.ts | 173 + .../specs/v2-r3-daily-ux-completeness.spec.ts | 132 + .../e2e/specs/v2-routing-and-fidelity.spec.ts | 263 + tests/e2e/specs/v3-intake-pilot.spec.ts | 232 + tools/leafwiki-inspector/README.md | 42 + tools/leafwiki-inspector/inspect.py | 586 ++ .../leafwiki-inspector/tests/test_inspect.py | 120 + 825 files changed, 124216 insertions(+) create mode 100644 .config/dotnet-tools.json create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Directory.Build.props create mode 100644 Directory.Build.targets create mode 100644 Directory.Packages.props create mode 100644 ItWorx.KnowledgeHub.slnx create mode 100644 LICENSE create mode 100644 MANIFEST.json create mode 100644 NuGet.Config create mode 100644 PUBLIC_SOURCE_EXPORT.md create mode 100644 PUBLIC_SOURCE_MANIFEST.json create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 deploy/docker/Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/00-product-vision.md create mode 100644 docs/01-scope-and-non-goals.md create mode 100644 docs/05-non-functional-requirements.md create mode 100644 docs/06-system-architecture.md create mode 100644 docs/07-domain-model.md create mode 100644 docs/08-database-schema.md create mode 100644 docs/09-api-contract.md create mode 100644 docs/10-search-and-indexing.md create mode 100644 docs/11-markdown-and-linking.md create mode 100644 docs/12-auth-security.md create mode 100644 docs/13-attachments.md create mode 100644 docs/14-revisions-and-audit.md create mode 100644 docs/15-project-hub.md create mode 100644 docs/16-runbooks-and-adrs.md create mode 100644 docs/17-leafwiki-migration.md create mode 100644 docs/18-backup-restore.md create mode 100644 docs/19-observability.md create mode 100644 docs/20-ui-ux-design-system.md create mode 100644 docs/21-accessibility.md create mode 100644 docs/22-testing-strategy.md create mode 100644 docs/24-mcp-readiness.md create mode 100644 docs/26-threat-model.md create mode 100644 docs/api/openapi.yaml create mode 100644 docs/assets/screenshots/02-dashboard.png create mode 100644 docs/assets/screenshots/03-knowledge.png create mode 100644 docs/guides/back-ups-herstel-en-unraid-beheer.md create mode 100644 docs/guides/bijlagen-en-leafwiki-import.md create mode 100644 docs/guides/documenten-toevoegen-en-intake.md create mode 100644 docs/guides/eerste-installatie-en-aanmelden.md create mode 100644 docs/guides/operationele-runbooks-overzicht.md create mode 100644 docs/guides/pagina-s-schrijven-en-beheren.md create mode 100644 docs/guides/projecten-beslissingen-en-runbooks.md create mode 100644 docs/guides/service-accounts-en-automatisering.md create mode 100644 docs/guides/source-batch-removal.md create mode 100644 docs/guides/zoeken-geschiedenis-en-labels.md create mode 100644 fixtures/leafwiki/README.md create mode 100644 fixtures/markdown-import/Projecten/Voorbeeldproject.md create mode 100644 fixtures/markdown-import/School/Linux/Bestandsrechten.md create mode 100644 fixtures/markdown-import/School/Linux/Shell-operators.md create mode 100644 fixtures/markdown-import/assets/architecture.svg create mode 100644 fixtures/markdown-import/source-manifest.json create mode 100644 global.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/check-openapi-coverage.mjs create mode 100644 scripts/doctor.mjs create mode 100644 scripts/generate-release-manifest.mjs create mode 100644 scripts/lib/exec.mjs create mode 100644 scripts/validate-openapi.mjs create mode 100644 scripts/validate-public-source.mjs create mode 100644 scripts/verify.mjs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Authorization/AuthorizationPolicies.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessAuthorizationHandler.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessEndpointExtensions.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Authorization/ContentAccessRequirement.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Authorization/ServiceTokenAuthenticationHandler.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Authorization/WorkspaceRoleAuthorizationHandler.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Authorization/WorkspaceRoleRequirement.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/BackgroundServices/StagingSessionCleanupBackgroundService.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ActivityEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AdminEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AttachmentsEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/AuthEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/BackupsEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DecisionsEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/DraftsEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ImportsEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/InfrastructureEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/IngestionProposalsEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/PagesEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ProjectsEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/RagPublicationEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/RunbooksEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SearchEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/ServiceAccountsEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SetupEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/SourceBatchPurgeEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/TemplatesEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Endpoints/WorkspaceEndpoints.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/ItWorx.KnowledgeHub.Api.csproj create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Knowledge/ETagHelper.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Operations/RestoreModeRunner.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Program.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Properties/launchSettings.json create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Security/AntiforgeryEndpointFilter.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Security/CorrelationIdMiddleware.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Security/IdempotencyEndpointFilter.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Security/RateLimitPolicies.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Security/SecurityHeadersMiddleware.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/Security/ServiceTokenFailureRateLimiter.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/appsettings.Development.json create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/appsettings.json create mode 100644 src/backend/ItWorx.KnowledgeHub.Api/wwwroot/index.html create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/AssemblyMarker.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Attachments/AttachmentContentLock.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Attachments/IAttachmentBlobStore.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Attachments/IAttachmentRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Attachments/IContentSignatureDetector.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Attachments/OrphanedBlobGarbageCollector.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/GetAttachmentContentUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/GetPageAttachmentsUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Attachments/UseCases/UploadAttachmentUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Audit/AuditEventRecord.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventReader.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Audit/IAuditEventRecorder.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Automation/IIdempotencyStore.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Automation/IOutboxRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Automation/IServiceAccountRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Automation/IServiceAccountTokenIssuer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/AuthenticateServiceTokenUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/CreateServiceAccountUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/IssueServiceAccountTokenUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/ListServiceAccountsUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Automation/UseCases/RevokeServiceAccountTokenUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Common/OperationResult.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Common/SafeUrlValidator.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Decisions/IDecisionRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/AcceptDecisionUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/CreateDecisionUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/GetDecisionUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/ListDecisionsUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/RejectDecisionUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Decisions/UseCases/WithdrawDecisionUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/IInstallationRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/IRecoveryTokenService.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/IUserAccountManager.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/IWorkspaceMembershipRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/IWorkspaceRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/PasswordSignInOutcome.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/CompleteFirstRunSetupUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/CreateWorkspaceUserUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/GetSessionUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/IssueRecoveryTokenUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/ListWorkspaceUsersUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/LoginUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/LogoutUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/RedeemRecoveryTokenUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/SearchWorkspaceMembersUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/UseCases/UpdateWorkspaceUserUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/UserAccount.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Identity/UserAccountCreationResult.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/IInfrastructureRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ImportCuratedInventoryPackageUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/InfrastructureQueryUseCases.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ManageManualOverrideUseCases.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/ReviewInfrastructureServiceUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/SynchronizeInfrastructureInventoryUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/UseCases/UpdateInfrastructureEditorialProfileUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/Validation/EditorialProfileValidator.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Infrastructure/Validation/ManualOverrideCatalog.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Ingestion/IIngestionProposalRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Ingestion/IIngestionSourceRetentionRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Ingestion/IngestionMetrics.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Ingestion/IngestionProposalOptions.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/ApplyIngestionProposalAnalysisUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/CleanupExpiredIngestionSourcesUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/CreateIngestionProposalUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/DecideIngestionProposalUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/PatchIngestionProposalUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/PublishIngestionProposalUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/RetryIngestionProposalUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/StageIngestionProposalSourceUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Ingestion/UseCases/SubmitManualIngestionProposalUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/ItWorx.KnowledgeHub.Application.csproj create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/IDraftRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/IMarkdownRenderer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/IPageCreationSessionRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/IPageRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/ISearchRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/ITemplateProvider.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/InternalLinkExtractor.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/LinkResolutionService.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/PageCreationSessionOptions.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/RagPublicationOptions.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/BeginPageCreationSessionUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/CleanupExpiredPageCreationSessionsUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/CommitPageCreationSessionUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/CreatePageUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/DiscardDraftUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/DiscardPageCreationSessionUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetDraftUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageBacklinksUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageChildrenUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageRevisionUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageRevisionsUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetPageUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/GetRagPublicationManifestUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/MovePageUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/PageOperationResult.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/PurgeTrashUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/RestorePageRevisionUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/RestorePageUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/SaveDraftUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/SearchPagesUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/StagingCleanupStatusTracker.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/TrashPageUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Knowledge/UseCases/UpdatePageUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Migration/IImportRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Migration/ILeafWikiAdapter.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Migration/ILeafWikiAdapterCatalog.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Migration/ILeafWikiSourceReader.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Migration/IPageRollbackRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Migration/ISourceBatchPurgeRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Migration/ImportPlan.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Migration/SourceFingerprint.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/ExecuteImportUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/GetImportJobUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/PreflightImportUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/PurgeSourceBatchUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Migration/UseCases/RollbackImportUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Operations/BackupManifest.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Operations/IBackupService.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Operations/IRestoreService.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Operations/UseCases/CreateBackupUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Operations/UseCases/ListBackupsUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/IProjectRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/ChangeProjectStatusUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/CreateProjectMilestoneUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/CreateProjectUpdateUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/CreateProjectUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectCatalogTotalsUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectContextUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectMilestonesUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectOverviewUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/GetProjectUpdatesUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/LinkProjectDocumentUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/ListProjectsUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/SetProjectIconUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/UnlinkProjectDocumentUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/UpdateProjectMilestoneUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Projects/UseCases/UpdateProjectUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Runbooks/IRunbookRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/CreateRunbookUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/GetRunbookUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/ListRunbooksUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/UpdateRunbookProfileUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Application/Runbooks/UseCases/ValidateRunbookUseCase.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Activity/ActivityContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Admin/CreateUserRequest.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Admin/IssueRecoveryTokenResponse.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Admin/StagingCleanupContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Admin/UpdateUserRequest.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Admin/UserSummaryResponse.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/AssemblyMarker.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Attachments/AttachmentSummary.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Auth/CsrfTokenResponse.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Auth/LoginRequest.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Auth/RecoveryRedeemRequest.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Auth/SessionResponse.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Automation/ServiceAccountContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Decisions/DecisionContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Errors/ProblemTypes.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Identity/WorkspaceMemberContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Infrastructure/InfrastructureContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Ingestion/IngestionProposalContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/ItWorx.KnowledgeHub.Contracts.csproj create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/BacklinkEntry.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/BreadcrumbEntry.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/CreatePageRequest.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/DraftResponse.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/MovePageRequest.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageChildrenResponse.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageCreationSessionContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageDetail.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageRevisionDetailResponse.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageRevisionSummary.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PageSummary.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/PreviewMarkdownContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/RagPublicationManifestResponse.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/SaveDraftRequest.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/SearchResultItem.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/TemplateSummary.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/TrashPurgeResponse.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Knowledge/UpdatePageRequest.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Migration/ImportContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Migration/SourceBatchPurgeContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Operations/BackupContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Projects/ProjectContextResponse.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Projects/ProjectContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Runbooks/RunbookContracts.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Setup/CompleteSetupRequest.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Setup/CompleteSetupResponse.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/Setup/SetupStatusResponse.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Contracts/SystemInfo/SystemInfoResponse.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/AssemblyMarker.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentBlob.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentDeliveryPolicy.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentReference.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentStorageState.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Attachments/AttachmentUsage.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Attachments/SafeFilename.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Automation/IdempotencyRecord.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Automation/OutboxMessage.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Automation/Scopes.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Automation/ServiceAccount.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Automation/ServiceAccountToken.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Decisions/DecisionRecord.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Decisions/DecisionStatus.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Identity/DisplayName.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Identity/EmailAddress.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Identity/Installation.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Identity/Workspace.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Identity/WorkspaceMembership.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Identity/WorkspaceName.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Identity/WorkspaceRole.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/EditorialKnowledge.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/FileSystemPathBoundary.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InfrastructureEntities.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InfrastructureEnums.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InventoryChange.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Infrastructure/InventorySnapshot.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Ingestion/IngestionProposal.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Ingestion/IngestionProposalStatus.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/ItWorx.KnowledgeHub.Domain.csproj create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/DocumentStatus.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/DocumentType.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/Draft.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/Page.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageCreationSession.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageCreationSessionStatus.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageId.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageLink.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageRevision.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageSlug.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/PageTitle.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/RagPublicationTags.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Knowledge/SortKey.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportItem.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportItemStatus.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportJob.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportJobStatus.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportProvenance.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportSourceKind.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Migration/ImportTargetEntityType.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Projects/Project.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectDocument.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectDocumentRole.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectId.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectLifecycleStatus.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectMilestone.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectMilestoneState.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectPriority.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectStatus.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectUpdate.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectUpdateCategory.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Projects/ProjectUpdateVerificationStatus.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Runbooks/RunbookProfile.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Runbooks/RunbookRiskLevel.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Domain/Runbooks/RunbookStatus.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/AssemblyMarker.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Attachments/AttachmentRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Attachments/ContentSignatureDetector.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Attachments/FileSystemAttachmentBlobStore.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Audit/AuditEventReader.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Audit/AuditEventRecorder.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Automation/IdempotencyStore.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Automation/OutboxRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Automation/ServiceAccountRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Automation/ServiceAccountTokenIssuer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Decisions/DecisionRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/EnabledAccountConfirmation.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/InstallationDefaults.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/InstallationRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/RecoveryTokenService.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/UserAccountManager.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/WorkspaceMembershipRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Identity/WorkspaceRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureKnowledge/InfrastructureRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/InfrastructureServiceCollectionExtensions.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Ingestion/IngestionProposalRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/ItWorx.KnowledgeHub.Infrastructure.csproj create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/DraftRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/ManagedMarkdownControlMarkers.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/MarkdownRenderer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/PageCreationSessionRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/PageRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/SearchRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Knowledge/TemplateProvider.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/FileSystemLeafWikiSourceReader.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/FilesystemMarkdownAdapter.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/ImportRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/LeafWikiAdapterCatalog.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/LeafWikiV5FilesystemAdapter.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/PageRollbackRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/SourceBatchPurgeRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/LeafWiki/SyntheticSqliteAdapter.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/BackupPaths.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/DatabaseHealthCheck.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresBackupService.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/PostgresRestoreService.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/ReleaseManifest.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/StorageHealthCheck.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Operations/TableDiscovery.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/ApplicationUser.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/AuditEventEntity.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ApplicationUserConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/AttachmentBlobConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/AttachmentReferenceConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/AttachmentUsageConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/AuditEventConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/DecisionRecordConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/DraftConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/EditorialKnowledgeConfigurations.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/IdempotencyRecordConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ImportItemConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ImportJobConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ImportProvenanceConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InfrastructureHostConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InfrastructureServiceConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/IngestionProposalConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InstallationConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InventoryChangeConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/InventorySnapshotConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PageConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PageCreationSessionConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PageLinkConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/PageRevisionConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ProjectDocumentConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ProjectMilestoneConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ProjectUpdateConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/RecoveryTokenConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/RunbookProfileConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ServiceAccountConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ServiceAccountTokenConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/ServiceFactConfigurations.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/WorkspaceConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Configurations/WorkspaceMembershipConfiguration.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/DatabaseConnectionOptions.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/DatabaseConnectionStringFactory.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContext.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/KnowledgeHubDbContextFactory.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731162849_InitialCreate.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731162849_InitialCreate.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731185224_AddKnowledgePages.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731185224_AddKnowledgePages.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731201729_AddDiscoveryAndSearch.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731201729_AddDiscoveryAndSearch.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731215422_AddAttachmentsAndImports.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260731215422_AddAttachmentsAndImports.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260801013144_AddProjectHubAdrsRunbooks.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260801013144_AddProjectHubAdrsRunbooks.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260801023652_AddAutomationAndOutbox.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260801023652_AddAutomationAndOutbox.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260802040639_AddStructuredInfrastructureKnowledge.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260802040639_AddStructuredInfrastructureKnowledge.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260803193236_AddPageCreationStaging.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260803193236_AddPageCreationStaging.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260803195722_ExtendDraftMetadata.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260803195722_ExtendDraftMetadata.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260804005007_AddInfrastructureServicesDetectedNameIndex.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260804005007_AddInfrastructureServicesDetectedNameIndex.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920120000_AddIngestionProposals.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920120000_AddIngestionProposals.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920155218_AddIngestionProposalAttachmentUsages.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920155218_AddIngestionProposalAttachmentUsages.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920173238_AddIngestionPublication.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260920173238_AddIngestionPublication.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260923221837_AddPageTagsGinIndex.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260923221837_AddPageTagsGinIndex.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260924182209_AddProjectIcons.Designer.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/20260924182209_AddProjectIcons.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/Migrations/KnowledgeHubDbContextModelSnapshot.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/RecoveryTokenEntity.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Persistence/SchemaGuard.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Projects/ProjectRepository.cs create mode 100644 src/backend/ItWorx.KnowledgeHub.Infrastructure/Runbooks/RunbookRepository.cs create mode 100644 src/frontend/eslint.config.mjs create mode 100644 src/frontend/index.html create mode 100644 src/frontend/package.json create mode 100644 src/frontend/public/favicon.svg create mode 100644 src/frontend/public/project-icons/blockpilot-autonomous.png create mode 100644 src/frontend/public/project-icons/chimera.png create mode 100644 src/frontend/public/project-icons/devrunbook.png create mode 100644 src/frontend/public/project-icons/dockdeck.png create mode 100644 src/frontend/public/project-icons/evidencevault.png create mode 100644 src/frontend/public/project-icons/geointel.png create mode 100644 src/frontend/public/project-icons/gifted.png create mode 100644 src/frontend/public/project-icons/infrabinder.png create mode 100644 src/frontend/public/project-icons/itworx-mcp-hub.png create mode 100644 src/frontend/public/project-icons/knowledge-hub.svg create mode 100644 src/frontend/public/project-icons/ludarium.png create mode 100644 src/frontend/public/project-icons/lumaops.svg create mode 100644 src/frontend/public/project-icons/mcop.png create mode 100644 src/frontend/public/project-icons/mobilityops.svg create mode 100644 src/frontend/public/project-icons/pokemon-legacy-rom-builder.png create mode 100644 src/frontend/public/project-icons/portfolio-itworx-tech.png create mode 100644 src/frontend/public/project-icons/project-launchpad.png create mode 100644 src/frontend/public/project-icons/projectbrain.png create mode 100644 src/frontend/public/project-icons/psforge.png create mode 100644 src/frontend/public/project-icons/quizmonitor.png create mode 100644 src/frontend/public/project-icons/ragcore.svg create mode 100644 src/frontend/public/project-icons/spoortracker.png create mode 100644 src/frontend/public/project-icons/unraid-appops-gateway.png create mode 100644 src/frontend/public/project-icons/vacatureradar.svg create mode 100644 src/frontend/public/project-icons/widefrog.png create mode 100644 src/frontend/src/App.test.tsx create mode 100644 src/frontend/src/App.tsx create mode 100644 src/frontend/src/api/activityTypes.ts create mode 100644 src/frontend/src/api/adminTypes.ts create mode 100644 src/frontend/src/api/attachmentTypes.ts create mode 100644 src/frontend/src/api/client.test.ts create mode 100644 src/frontend/src/api/client.ts create mode 100644 src/frontend/src/api/infrastructureTypes.ts create mode 100644 src/frontend/src/api/intake.test.ts create mode 100644 src/frontend/src/api/intake.ts create mode 100644 src/frontend/src/api/intakeTypes.ts create mode 100644 src/frontend/src/api/knowledgeTypes.ts create mode 100644 src/frontend/src/api/projectTypes.ts create mode 100644 src/frontend/src/api/setupTypes.ts create mode 100644 src/frontend/src/api/types.ts create mode 100644 src/frontend/src/auth/SessionContext.tsx create mode 100644 src/frontend/src/auth/session-context.ts create mode 100644 src/frontend/src/components/AuthShell.tsx create mode 100644 src/frontend/src/components/CommandPalette.test.tsx create mode 100644 src/frontend/src/components/CommandPalette.tsx create mode 100644 src/frontend/src/components/DataTable.test.tsx create mode 100644 src/frontend/src/components/DataTable.tsx create mode 100644 src/frontend/src/components/EntityDialogs.test.tsx create mode 100644 src/frontend/src/components/InventoryReviewPanel.test.tsx create mode 100644 src/frontend/src/components/InventoryReviewPanel.tsx create mode 100644 src/frontend/src/components/LeafWikiImportPanel.test.tsx create mode 100644 src/frontend/src/components/LeafWikiImportPanel.tsx create mode 100644 src/frontend/src/components/MarkdownContent.test.tsx create mode 100644 src/frontend/src/components/MarkdownContent.tsx create mode 100644 src/frontend/src/components/OwnerPicker.test.tsx create mode 100644 src/frontend/src/components/OwnerPicker.tsx create mode 100644 src/frontend/src/components/PageActions.test.tsx create mode 100644 src/frontend/src/components/PageActions.tsx create mode 100644 src/frontend/src/components/ProductPrimitives.tsx create mode 100644 src/frontend/src/components/ProjectDialogs.tsx create mode 100644 src/frontend/src/components/ProjectWorkspaceActions.test.tsx create mode 100644 src/frontend/src/components/ProjectWorkspaceActions.tsx create mode 100644 src/frontend/src/components/RunbookCreateDialog.tsx create mode 100644 src/frontend/src/components/ServiceOverrideManager.test.tsx create mode 100644 src/frontend/src/components/ServiceOverrideManager.tsx create mode 100644 src/frontend/src/components/Tooltip.test.tsx create mode 100644 src/frontend/src/components/Tooltip.tsx create mode 100644 src/frontend/src/components/infrastructureFormat.ts create mode 100644 src/frontend/src/components/ingestion/EmptyState.tsx create mode 100644 src/frontend/src/components/ingestion/ErrorState.tsx create mode 100644 src/frontend/src/components/ingestion/IngestionProgress.tsx create mode 100644 src/frontend/src/components/ingestion/IngestionQueueCard.test.tsx create mode 100644 src/frontend/src/components/ingestion/IngestionQueueCard.tsx create mode 100644 src/frontend/src/components/ingestion/IngestionQueueFilters.test.tsx create mode 100644 src/frontend/src/components/ingestion/IngestionQueueFilters.tsx create mode 100644 src/frontend/src/components/ingestion/IngestionQueueTable.tsx create mode 100644 src/frontend/src/components/ingestion/LoadingState.tsx create mode 100644 src/frontend/src/components/ingestion/ManualDocumentIntake.test.tsx create mode 100644 src/frontend/src/components/ingestion/ManualDocumentIntake.tsx create mode 100644 src/frontend/src/components/ingestion/MobileReviewPanels.tsx create mode 100644 src/frontend/src/components/ingestion/ProposalContentDiff.tsx create mode 100644 src/frontend/src/components/ingestion/ProposalDecisionPane.tsx create mode 100644 src/frontend/src/components/ingestion/ProposalEvidencePane.tsx create mode 100644 src/frontend/src/components/ingestion/ProposalMetadataForm.tsx create mode 100644 src/frontend/src/components/ingestion/ProposalQueuePane.tsx create mode 100644 src/frontend/src/components/ingestion/ProposalReviewHistory.tsx create mode 100644 src/frontend/src/components/ingestion/ProposalSourceList.tsx create mode 100644 src/frontend/src/components/ingestion/StatusBadge.test.tsx create mode 100644 src/frontend/src/components/ingestion/StatusBadge.tsx create mode 100644 src/frontend/src/components/ingestion/ingestionQueueFilterState.ts create mode 100644 src/frontend/src/components/statusLabels.ts create mode 100644 src/frontend/src/design-system/auth.css create mode 100644 src/frontend/src/design-system/base.css create mode 100644 src/frontend/src/design-system/components.css create mode 100644 src/frontend/src/design-system/documents.css create mode 100644 src/frontend/src/design-system/editor.css create mode 100644 src/frontend/src/design-system/forms.css create mode 100644 src/frontend/src/design-system/responsive.css create mode 100644 src/frontend/src/design-system/shell.css create mode 100644 src/frontend/src/design-system/tokens.css create mode 100644 src/frontend/src/design-system/typography.test.ts create mode 100644 src/frontend/src/design-system/workspaces.css create mode 100644 src/frontend/src/hooks/useDocumentTitle.ts create mode 100644 src/frontend/src/hooks/useMobileViewport.ts create mode 100644 src/frontend/src/hooks/useOnlineStatus.ts create mode 100644 src/frontend/src/layouts/AppShell.accessibility.test.tsx create mode 100644 src/frontend/src/layouts/AppShell.tsx create mode 100644 src/frontend/src/layouts/active-entity-context.ts create mode 100644 src/frontend/src/layouts/context-shortcuts.test.ts create mode 100644 src/frontend/src/layouts/context-shortcuts.ts create mode 100644 src/frontend/src/lib/clipboard.ts create mode 100644 src/frontend/src/lib/uuid.test.ts create mode 100644 src/frontend/src/lib/uuid.ts create mode 100644 src/frontend/src/main.tsx create mode 100644 src/frontend/src/notifications/ErrorBoundary.tsx create mode 100644 src/frontend/src/notifications/OfflineBanner.test.tsx create mode 100644 src/frontend/src/notifications/OfflineBanner.tsx create mode 100644 src/frontend/src/notifications/ToastProvider.tsx create mode 100644 src/frontend/src/notifications/toast-context.ts create mode 100644 src/frontend/src/pages/AdminPage.courseTagging.test.tsx create mode 100644 src/frontend/src/pages/AdminPage.test.tsx create mode 100644 src/frontend/src/pages/AdminPage.tsx create mode 100644 src/frontend/src/pages/LoginPage.test.tsx create mode 100644 src/frontend/src/pages/LoginPage.tsx create mode 100644 src/frontend/src/pages/SetupPage.test.tsx create mode 100644 src/frontend/src/pages/SetupPage.tsx create mode 100644 src/frontend/src/pages/StatusScreens.tsx create mode 100644 src/frontend/src/pages/knowledge/Attachments.test.tsx create mode 100644 src/frontend/src/pages/knowledge/Attachments.tsx create mode 100644 src/frontend/src/pages/knowledge/Backlinks.test.tsx create mode 100644 src/frontend/src/pages/knowledge/Backlinks.tsx create mode 100644 src/frontend/src/pages/knowledge/KnowledgeTree.test.tsx create mode 100644 src/frontend/src/pages/knowledge/KnowledgeTree.tsx create mode 100644 src/frontend/src/pages/knowledge/PageEditorPage.insecureContext.test.tsx create mode 100644 src/frontend/src/pages/knowledge/PageEditorPage.test.tsx create mode 100644 src/frontend/src/pages/knowledge/PageEditorPage.tsx create mode 100644 src/frontend/src/pages/knowledge/PageViewPage.test.tsx create mode 100644 src/frontend/src/pages/knowledge/PageViewPage.tsx create mode 100644 src/frontend/src/pages/knowledge/RevisionHistory.test.tsx create mode 100644 src/frontend/src/pages/knowledge/RevisionHistory.tsx create mode 100644 src/frontend/src/pages/knowledge/SearchPage.test.tsx create mode 100644 src/frontend/src/pages/knowledge/SearchPage.tsx create mode 100644 src/frontend/src/pages/knowledge/editorAppearance.ts create mode 100644 src/frontend/src/pages/knowledge/knowledgeMetadata.test.ts create mode 100644 src/frontend/src/pages/knowledge/knowledgeMetadata.ts create mode 100644 src/frontend/src/pages/knowledge/recentKnowledge.test.ts create mode 100644 src/frontend/src/pages/knowledge/recentKnowledge.ts create mode 100644 src/frontend/src/pages/knowledge/revisionDiff.ts create mode 100644 src/frontend/src/pages/projects/DecisionDetailView.tsx create mode 100644 src/frontend/src/pages/projects/RunbookDetailView.test.tsx create mode 100644 src/frontend/src/pages/projects/RunbookDetailView.tsx create mode 100644 src/frontend/src/pages/projects/formHelpers.ts create mode 100644 src/frontend/src/pages/v2/ActivityPage.test.tsx create mode 100644 src/frontend/src/pages/v2/ActivityPage.tsx create mode 100644 src/frontend/src/pages/v2/AdminRoutePage.tsx create mode 100644 src/frontend/src/pages/v2/DashboardPage.test.tsx create mode 100644 src/frontend/src/pages/v2/DashboardPage.tsx create mode 100644 src/frontend/src/pages/v2/DecisionWorkspacePage.tsx create mode 100644 src/frontend/src/pages/v2/InfrastructureOverviewPage.test.tsx create mode 100644 src/frontend/src/pages/v2/InfrastructureOverviewPage.tsx create mode 100644 src/frontend/src/pages/v2/IngestionQueuePage.test.tsx create mode 100644 src/frontend/src/pages/v2/IngestionQueuePage.tsx create mode 100644 src/frontend/src/pages/v2/IngestionReviewPage.test.tsx create mode 100644 src/frontend/src/pages/v2/IngestionReviewPage.tsx create mode 100644 src/frontend/src/pages/v2/KnowledgeEditorRoute.tsx create mode 100644 src/frontend/src/pages/v2/KnowledgeRoutePage.test.tsx create mode 100644 src/frontend/src/pages/v2/KnowledgeRoutePage.tsx create mode 100644 src/frontend/src/pages/v2/NotFoundPage.tsx create mode 100644 src/frontend/src/pages/v2/ProjectWorkspacePage.tsx create mode 100644 src/frontend/src/pages/v2/ProjectsOverviewPage.test.tsx create mode 100644 src/frontend/src/pages/v2/ProjectsOverviewPage.tsx create mode 100644 src/frontend/src/pages/v2/RouteStates.tsx create mode 100644 src/frontend/src/pages/v2/RunbookWorkspacePage.tsx create mode 100644 src/frontend/src/pages/v2/RunbooksOverviewPage.test.tsx create mode 100644 src/frontend/src/pages/v2/RunbooksOverviewPage.tsx create mode 100644 src/frontend/src/pages/v2/SearchRoutePage.tsx create mode 100644 src/frontend/src/pages/v2/ServiceDetailPage.tsx create mode 100644 src/frontend/src/pages/v2/ServicesCatalogPage.test.tsx create mode 100644 src/frontend/src/pages/v2/ServicesCatalogPage.tsx create mode 100644 src/frontend/src/pages/v2/SettingsPage.tsx create mode 100644 src/frontend/src/pages/v2/StoragePage.test.tsx create mode 100644 src/frontend/src/pages/v2/StoragePage.tsx create mode 100644 src/frontend/src/pages/v2/ingestionProposalMutations.ts create mode 100644 src/frontend/src/preferences/PreferencesContext.test.tsx create mode 100644 src/frontend/src/preferences/PreferencesContext.tsx create mode 100644 src/frontend/src/preferences/preferences-context.ts create mode 100644 src/frontend/src/router/AuthenticatedRoutes.test.tsx create mode 100644 src/frontend/src/router/AuthenticatedRoutes.tsx create mode 100644 src/frontend/src/test/motionTestUtils.tsx create mode 100644 src/frontend/src/test/setup.ts create mode 100644 src/frontend/src/visualizations/ProjectArtwork.tsx create mode 100644 src/frontend/src/visualizations/ProjectSignature.test.tsx create mode 100644 src/frontend/src/visualizations/ProjectSignature.tsx create mode 100644 src/frontend/src/visualizations/ServerMap.test.tsx create mode 100644 src/frontend/src/visualizations/ServerMap.tsx create mode 100644 src/frontend/src/visualizations/ServiceTopology.test.tsx create mode 100644 src/frontend/src/visualizations/ServiceTopology.tsx create mode 100644 src/frontend/src/visualizations/hostLabel.ts create mode 100644 src/frontend/src/visualizations/projectArtworkGeometry.ts create mode 100644 src/frontend/src/visualizations/projectLogoSource.ts create mode 100644 src/frontend/src/vite-env.d.ts create mode 100644 src/frontend/tsconfig.app.json create mode 100644 src/frontend/tsconfig.json create mode 100644 src/frontend/tsconfig.node.json create mode 100644 src/frontend/vite.config.ts create mode 100644 src/frontend/vitest.config.ts create mode 100644 tests/backend/Directory.Build.props create mode 100644 tests/backend/ItWorx.KnowledgeHub.Api.Tests/ForwardedHeadersTrustTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Api.Tests/GlobalUsings.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Api.Tests/HealthEndpointTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Api.Tests/ItWorx.KnowledgeHub.Api.Tests.csproj create mode 100644 tests/backend/ItWorx.KnowledgeHub.Api.Tests/KnowledgeHubApiFactory.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Api.Tests/RestoreModeRunnerTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Api.Tests/SecurityHeaderTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Architecture.Tests/GlobalUsings.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Architecture.Tests/ItWorx.KnowledgeHub.Architecture.Tests.csproj create mode 100644 tests/backend/ItWorx.KnowledgeHub.Architecture.Tests/ProjectReferenceTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/AttachmentUsageTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/DecisionRecordTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/DraftTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/EmailAddressTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/FileSystemPathBoundaryTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/GlobalUsings.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/InfrastructureKnowledgeTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/IngestionProposalTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/InstallationTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/ItWorx.KnowledgeHub.Domain.Tests.csproj create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/PageCreationSessionTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/PageTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/PageTitleTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/ProjectTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/RunbookProfileTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/SafeFilenameTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/ServiceAccountTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/SortKeyTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.Domain.Tests/WorkspaceNameTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Attachments/AttachmentUploadTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Attachments/StagedAttachmentAuthorizationTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/ConcurrentLoginTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/CsrfTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/LoginLogoutLockoutTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/RecoveryTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/SessionExpiryTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Auth/WorkspaceMemberSearchTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Authorization/RoleAuthorizationTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/IdempotencyTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/OutboxTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/ProjectContextTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/ServiceAccountTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Automation/ServiceAccountTokenIssuerTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/GlobalUsings.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/KnowledgeHubIntegrationFactory.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/OwnerSetupTestBase.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/PostgresContainerFixture.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/PostgresTestGroup.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Infrastructure/SetupHelper.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/EditorialProfileValidationTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/ExpandedManualOverrideCatalogTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/InfrastructureCatalogReleaseScaleTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/InfrastructureCatalogScaleTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/InfrastructurePaginationTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/InfrastructureSynchronizationTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/ManualOverrideCatalogTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionObservabilityTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionPilotAvailabilityTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionPilotContractTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalApiTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalAuthorizationTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalEtagContractTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalPersistenceTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalPrivacyTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionProposalSourceCompensationTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionPublicationTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionSourceRetentionPersistenceTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Ingestion/IngestionSourceRetentionTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/ItWorx.KnowledgeHub.IntegrationTests.csproj create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/BacklinksTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/DraftAutosaveTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/InternalLinkResolutionTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/MarkdownSanitizationTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/PageConcurrencyTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/PageCreationStagingTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/PageCrudTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/PageTreeScaleTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/RagPublicationManifestTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/RevisionsTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/SearchFullScaleTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/SearchScaleTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/SearchTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/StagingSessionCleanupTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/TemplateTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/TrashPurgeTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/CutoverRehearsalTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/InPlaceUpgradeRehearsalTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/LeafWikiImportTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/LeafWikiV5ImportTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Migration/SourceBatchPurgeTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/ActivityFeedTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/BackupServiceTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/HealthCheckTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/LoginRedactionTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/RestoreServiceTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/SchemaGuardTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Operations/SystemInfoTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Projects/DecisionTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Projects/ProjectHubTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Projects/RunbookTests.cs create mode 100644 tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Setup/FirstRunSetupTests.cs create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/playwright.config.ts create mode 100644 tests/e2e/specs/attachments.spec.ts create mode 100644 tests/e2e/specs/auth-journey.spec.ts create mode 100644 tests/e2e/specs/baseline.spec.ts create mode 100644 tests/e2e/specs/discovery.spec.ts create mode 100644 tests/e2e/specs/ingestion-review.spec.ts create mode 100644 tests/e2e/specs/knowledge-editing.spec.ts create mode 100644 tests/e2e/specs/project-hub.spec.ts create mode 100644 tests/e2e/specs/support/credentials.ts create mode 100644 tests/e2e/specs/support/v2.ts create mode 100644 tests/e2e/specs/v2-editor-leafwiki-floor.spec.ts create mode 100644 tests/e2e/specs/v2-infrastructure-review.spec.ts create mode 100644 tests/e2e/specs/v2-m5-workspace-fidelity.spec.ts create mode 100644 tests/e2e/specs/v2-m6-motion-and-matrices.spec.ts create mode 100644 tests/e2e/specs/v2-m7-admin-actions.spec.ts create mode 100644 tests/e2e/specs/v2-m8-accessibility-and-fidelity.spec.ts create mode 100644 tests/e2e/specs/v2-r3-daily-ux-completeness.spec.ts create mode 100644 tests/e2e/specs/v2-routing-and-fidelity.spec.ts create mode 100644 tests/e2e/specs/v3-intake-pilot.spec.ts create mode 100644 tools/leafwiki-inspector/README.md create mode 100644 tools/leafwiki-inspector/inspect.py create mode 100644 tools/leafwiki-inspector/tests/test_inspect.py 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 0000000000000000000000000000000000000000..046946997066c291805bc054588cb4331457259c GIT binary patch literal 120018 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%0Dl5w6AWPk&Khf-eLRE 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 literal 0 HcmV?d00001 diff --git a/docs/assets/screenshots/03-knowledge.png b/docs/assets/screenshots/03-knowledge.png new file mode 100644 index 0000000000000000000000000000000000000000..70bdc2d70533cfaa060034c9f98bf12892b5b071 GIT binary patch literal 190279 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" -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); + }} + > +
+ +