From 0e04372d76a15185bddf45c3d42537a3197bc46e Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Thu, 6 Aug 2026 20:31:12 +0200 Subject: [PATCH 1/6] refactor(rewrite): isolate greenfield future root --- .github/workflows/dashboard-checks.yml | 145 +- .oxfmtrc.json | 2 - .oxlintrc.json | 301 +---- README.md | 16 +- backend/src/requestPolicy/evaluator.ts | 3 - backend/test/utilityBehavior.test.ts | 2 +- bun.lock | 151 +-- bunfig.toml | 2 +- codecov.yml | 23 + docs/api/endpoints.md | 59 +- docs/architecture/gateway-and-chat.md | 7 - docs/development/local-dev.md | 17 +- docs/development/testing-and-prs.md | 20 - docs/index.md | 6 - docs/security/auth-and-trust-boundaries.md | 330 ++--- docs/setup/new-vps.md | 5 +- greenfield/.bun-version | 1 + greenfield/.editorconfig | 17 + greenfield/.gitattributes | 4 + greenfield/.github/CODEOWNERS | 2 + greenfield/.github/CONTRIBUTING.md | 46 + .../.github/ISSUE_TEMPLATE/bug_report.yml | 86 ++ greenfield/.github/ISSUE_TEMPLATE/config.yml | 5 + .../.github/ISSUE_TEMPLATE/feature_task.yml | 84 ++ .../.github/ISSUE_TEMPLATE/ops_deploy.yml | 68 + greenfield/.github/codeql/codeql-config.yml | 10 + greenfield/.github/dependabot.yml | 53 + greenfield/.github/pull_request_template.md | 41 + greenfield/.github/workflows/codeql.yml | 38 + .../.github/workflows/dashboard-checks.yml | 76 ++ greenfield/.gitignore | 33 + greenfield/.oxfmtrc.json | 25 + greenfield/.oxlintrc.json | 442 +++++++ greenfield/CODE_OF_CONDUCT.md | 112 ++ greenfield/LICENSE | 21 + greenfield/README.md | 42 + greenfield/SECURITY.md | 35 + greenfield/bun.lock | 1169 +++++++++++++++++ greenfield/bunfig.toml | 18 + greenfield/codecov.yml | 37 + .../docs}/architecture/greenfield-rewrite.md | 7 +- .../application-architecture.md | 40 +- .../greenfield-rewrite/data-and-security.md | 0 .../greenfield-rewrite/implementation-plan.md | 6 +- .../greenfield-rewrite/progress.md | 48 +- .../runtime-and-delivery.md | 78 +- .../docs/development/testing-and-prs.md | 87 ++ {docs => greenfield/docs}/generated/README.md | 0 .../docs}/generated/configuration.md | 0 .../docs}/generated/packages-and-runtime.md | 20 +- .../docs}/generated/procedures.md | 0 .../docs}/generated/raw-http.md | 0 .../docs}/generated/realtime-events.md | 0 ...rity.beginTotpEnrollment.input.schema.json | 0 ...ity.beginTotpEnrollment.output.schema.json | 0 ....beginWebAuthnEnrollment.input.schema.json | 0 ...beginWebAuthnEnrollment.output.schema.json | 0 ...rity.beginWebAuthnStepUp.input.schema.json | 0 ...ity.beginWebAuthnStepUp.output.schema.json | 0 ...ty.confirmTotpEnrollment.input.schema.json | 0 ...y.confirmTotpEnrollment.output.schema.json | 0 ...onfirmWebAuthnEnrollment.input.schema.json | 0 ...nfirmWebAuthnEnrollment.output.schema.json | 0 ...countSecurity.disableMfa.input.schema.json | 0 ...ountSecurity.disableMfa.output.schema.json | 0 ...y.reauthenticatePassword.input.schema.json | 0 ....reauthenticatePassword.output.schema.json | 0 ...ecurity.removeTotpFactor.input.schema.json | 0 ...curity.removeTotpFactor.output.schema.json | 0 ...removeWebAuthnCredential.input.schema.json | 0 ...emoveWebAuthnCredential.output.schema.json | 0 ...rity.rotateRecoveryCodes.input.schema.json | 0 ...ity.rotateRecoveryCodes.output.schema.json | 0 ...tSecurity.stepUpRecovery.input.schema.json | 0 ...Security.stepUpRecovery.output.schema.json | 0 ...countSecurity.stepUpTotp.input.schema.json | 0 ...ountSecurity.stepUpTotp.output.schema.json | 0 ...tSecurity.stepUpWebAuthn.input.schema.json | 0 ...Security.stepUpWebAuthn.output.schema.json | 0 .../accountSecurity.summary.input.schema.json | 0 ...accountSecurity.summary.output.schema.json | 0 .../auth.beginWebAuthnLogin.input.schema.json | 0 ...auth.beginWebAuthnLogin.output.schema.json | 0 .../schemas/auth.bootstrap.input.schema.json | 0 .../schemas/auth.bootstrap.output.schema.json | 0 .../auth.changePassword.input.schema.json | 0 .../auth.changePassword.output.schema.json | 0 .../schemas/auth.login.input.schema.json | 0 .../schemas/auth.login.output.schema.json | 0 .../auth.loginRecovery.input.schema.json | 0 .../auth.loginRecovery.output.schema.json | 0 .../schemas/auth.loginTotp.input.schema.json | 0 .../schemas/auth.loginTotp.output.schema.json | 0 .../auth.loginWebAuthn.input.schema.json | 0 .../auth.loginWebAuthn.output.schema.json | 0 .../schemas/auth.logout.input.schema.json | 0 .../schemas/auth.logout.output.schema.json | 0 .../auth.revokeSession.input.schema.json | 0 .../auth.revokeSession.output.schema.json | 0 .../schemas/auth.sessions.input.schema.json | 0 .../schemas/auth.sessions.output.schema.json | 0 .../schemas/auth.status.input.schema.json | 0 .../schemas/auth.status.output.schema.json | 0 .../schemas/auth.touch.input.schema.json | 0 .../schemas/auth.touch.output.schema.json | 0 ...ecurity.createCredential.input.schema.json | 0 ...curity.createCredential.output.schema.json | 0 ...Security.createPrincipal.input.schema.json | 0 ...ecurity.createPrincipal.output.schema.json | 0 ...ecurity.disablePrincipal.input.schema.json | 0 ...curity.disablePrincipal.output.schema.json | 0 ...Security.listCredentials.input.schema.json | 0 ...ecurity.listCredentials.output.schema.json | 0 ...nSecurity.listPrincipals.input.schema.json | 0 ...Security.listPrincipals.output.schema.json | 0 ...rity.replaceCapabilities.input.schema.json | 0 ...ity.replaceCapabilities.output.schema.json | 0 ...ecurity.revokeCredential.input.schema.json | 0 ...curity.revokeCredential.output.schema.json | 0 ...ecurity.rotateCredential.input.schema.json | 0 ...curity.rotateCredential.output.schema.json | 0 .../schemas/events.stream.input.schema.json | 0 .../schemas/events.stream.output.schema.json | 0 .../health.liveness.response.schema.json | 0 .../health.readiness.response.schema.json | 0 .../system.runtimeIdentity.input.schema.json | 0 .../system.runtimeIdentity.output.schema.json | 0 greenfield/docs/index.md | 11 + .../greenfield-phase-two-threat-model.md | 7 +- .../drizzle.config.ts | 0 .../migration.sql | 0 .../snapshot.json | 0 greenfield/package.json | 113 ++ .../fixtures/2026.7.2-beta.7/agents.json | 0 .../fixtures/2026.7.2-beta.7/chat.json | 0 .../fixtures/2026.7.2-beta.7/cron.json | 0 .../fixtures/2026.7.2-beta.7/gateway.json | 0 .../fixtures/2026.7.2-beta.7/manifest.json | 0 .../fixtures/2026.7.2-beta.7/sessions.json | 0 .../fixtures/2026.7.2-beta.7/tasks.json | 0 .../audits}/openclaw/reviewedFixtures.ts | 2 +- .../audits}/openclaw/runSourceAudit.ts | 0 .../scripts/audits}/openclaw/sourceAudit.ts | 2 +- .../audits}/openclaw/sourceAuditSchemas.ts | 0 .../scripts/buildSourceIdentity.test.ts | 92 ++ greenfield/scripts/buildSourceIdentity.ts | 62 + greenfield/scripts/checkCoverage.test.ts | 86 ++ greenfield/scripts/checkCoverage.ts | 266 ++++ .../scripts}/checkDatabaseSchema.test.ts | 0 .../scripts}/checkDatabaseSchema.ts | 0 .../scripts}/checkSourceBoundaries.ts | 40 +- .../scripts}/documentation/artifacts.test.ts | 4 +- .../scripts}/documentation/artifacts.ts | 0 .../scripts}/documentation/bunLock.test.ts | 0 .../scripts}/documentation/bunLock.ts | 0 .../configurationMarkdown.test.ts | 0 .../scripts}/documentation/files.ts | 0 .../scripts}/documentation/jsonSchema.test.ts | 0 .../scripts}/documentation/jsonSchema.ts | 0 .../scripts}/documentation/markdown.ts | 0 .../scripts}/files/boundedFile.ts | 12 +- greenfield/scripts/frontendBuildArtifacts.ts | 349 +++++ .../scripts}/generateDocs.ts | 0 greenfield/scripts/reactCompilerPlugin.ts | 31 + greenfield/scripts/runCoverage.ts | 59 + greenfield/scripts/runTestSuite.test.ts | 78 ++ greenfield/scripts/runTestSuite.ts | 70 + .../boundaryConfiguration.test.ts | 158 +-- .../sourceBoundaries/boundaryConfiguration.ts | 142 +- .../checkerIntegration.test.ts | 0 .../externalAuthorityPolicy.ts | 42 +- .../sourceBoundaries/importGraph.test.ts | 0 .../scripts}/sourceBoundaries/importGraph.ts | 0 .../importTargetValidation.test.ts | 50 +- .../importTargetValidation.ts | 43 +- .../lintConfiguration.test.ts | 72 +- .../scripts}/sourceBoundaries/policy.test.ts | 54 +- .../scripts}/sourceBoundaries/policy.ts | 72 +- .../scripts}/sourceBoundaries/policyTypes.ts | 0 .../runtimeAuthorityAnalysis.ts | 0 .../runtimeCodeAuthorityAnalysis.ts | 0 .../sourceBoundaries/runtimeOwnerAnalysis.ts | 0 .../scripts}/sourceBoundaries/sourceAst.ts | 0 .../sourceBoundaries/sourceBoundaryPaths.ts | 0 .../sourceBoundaries/sourceDirectives.ts | 0 .../sourceBoundaries/sourceDiscovery.test.ts | 0 .../sourceBoundaries/sourceDiscovery.ts | 7 +- .../sourceBoundaries/sourceTopologyPolicy.ts | 95 +- .../scripts}/sourceBoundaries/testSupport.ts | 3 + greenfield/scripts/testOutputPolicy.test.ts | 48 + greenfield/scripts/testOutputPolicy.ts | 65 + .../src}/app/dashboardServer.test.ts | 0 .../src}/app/dashboardServer.ts | 0 .../src}/app/environmentSource.ts | 0 {src => greenfield/src}/app/server.ts | 0 .../src}/app/trpcHttpHandler.test.ts | 0 .../src}/app/trpcHttpHandler.ts | 0 .../src}/app/trpcRequestPolicy.test.ts | 0 .../src}/app/trpcRequestPolicy.ts | 0 .../frontendBuildFixture}/index.html | 2 +- .../frontendBuildFixture/src/FixtureApp.tsx | 2 +- .../frontendBuildFixture}/src/LazyPanel.tsx | 0 .../frontendBuildFixture}/src/index.css | 0 .../frontendBuildFixture}/src/main.tsx | 4 +- .../src}/contracts/accountSecurity.test.ts | 0 .../src}/contracts/accountSecurity.ts | 0 .../src}/contracts/auth.test.ts | 0 {src => greenfield/src}/contracts/auth.ts | 0 .../src}/contracts/automationSecurity.test.ts | 0 .../src}/contracts/automationSecurity.ts | 0 .../src}/contracts/contractRegistry.test.ts | 0 .../src}/contracts/contractRegistry.ts | 0 .../src}/contracts/events.test.ts | 0 {src => greenfield/src}/contracts/events.ts | 0 .../src}/contracts/monitoring.ts | 0 .../src}/contracts/monitoringRealtime.ts | 0 {src => greenfield/src}/contracts/realtime.ts | 0 {src => greenfield/src}/contracts/registry.ts | 0 .../src}/contracts/security.test.ts | 0 {src => greenfield/src}/contracts/security.ts | 0 {src => greenfield/src}/contracts/system.ts | 0 .../src}/contracts/webauthn.test.ts | 0 {src => greenfield/src}/contracts/webauthn.ts | 0 .../migrations/applyVerifiedMigrations.ts | 0 .../migrations/auditEventsSchema.test.ts | 0 .../authenticationRateLimitSchema.test.ts | 0 .../migrations/jsonObjectConstraints.test.ts | 0 .../migrations/loadVerifiedMigrations.test.ts | 0 .../migrations/loadVerifiedMigrations.ts | 0 .../server/database/migrations/manifest.ts | 0 .../migrations/mfaLifecycleSchema.test.ts | 0 .../migrationApplicationTime.test.ts | 0 .../migrations/migrationGraph.test.ts | 0 .../migrationLedgerValidation.test.ts | 0 .../migrations/monitoringSchema.test.ts | 0 .../migrations/realtimeSchema.test.ts | 0 .../securityIdentitySchema.automation.test.ts | 0 .../securityIdentitySchema.baseline.test.ts | 0 .../securityIdentitySchema.browser.test.ts | 0 .../testSupport/securityIdentitySchema.ts | 0 .../server/database/migrations/validation.ts | 0 .../verifyDatabaseIntegrity.test.ts | 0 .../migrations/verifyDatabaseIntegrity.ts | 0 .../webauthnLifecycleSchema.test.ts | 0 .../server/database/schema/auditEvents.ts | 0 .../server/database/schema/authChallenges.ts | 0 .../database/schema/authPendingLogins.ts | 0 .../database/schema/authRateLimitBuckets.ts | 0 .../server/database/schema/authSessions.ts | 0 .../database/schema/automationCredentials.ts | 0 .../schema/automationPersistence.test.ts | 0 .../schema/automationPrincipalCapabilities.ts | 0 .../database/schema/automationPrincipals.ts | 0 .../src}/server/database/schema/checks.ts | 0 .../server/database/schema/drizzleSchema.ts | 0 .../database/schema/incidentObservations.ts | 0 .../src}/server/database/schema/incidents.ts | 0 .../src}/server/database/schema/mfaFormats.ts | 0 .../server/database/schema/monitorRuns.ts | 0 .../server/database/schema/notifications.ts | 0 .../database/schema/passwordHashCheck.ts | 0 .../src}/server/database/schema/realtime.ts | 0 .../src}/server/database/schema/reports.ts | 0 .../database/schema/schemaMigrations.ts | 0 .../database/schema/userRecoveryCodes.ts | 0 .../server/database/schema/userTotpFactors.ts | 0 .../schema/userWebAuthnCredentials.ts | 0 .../src}/server/database/schema/users.ts | 0 .../schema/webauthnPersistence.test.ts | 0 .../database/validation/auditEvents.test.ts | 0 .../server/database/validation/auditEvents.ts | 0 .../validation/authChallenges.test.ts | 0 .../database/validation/authChallenges.ts | 0 .../validation/authPendingLogins.test.ts | 0 .../database/validation/authPendingLogins.ts | 0 .../validation/authRateLimitBuckets.test.ts | 0 .../validation/authRateLimitBuckets.ts | 0 .../database/validation/authSessions.test.ts | 0 .../database/validation/authSessions.ts | 0 .../validation/automationCredentials.test.ts | 0 .../validation/automationCredentials.ts | 0 .../automationPrincipalCapabilities.test.ts | 0 .../automationPrincipalCapabilities.ts | 0 .../validation/automationPrincipals.test.ts | 0 .../validation/automationPrincipals.ts | 0 .../validation/incidentObservations.test.ts | 0 .../validation/incidentObservations.ts | 0 .../database/validation/incidents.test.ts | 0 .../server/database/validation/incidents.ts | 0 .../database/validation/monitorRuns.test.ts | 0 .../server/database/validation/monitorRuns.ts | 0 .../database/validation/notifications.test.ts | 0 .../database/validation/notifications.ts | 0 .../validation/realtimeEvents.test.ts | 2 +- .../database/validation/realtimeEvents.ts | 0 .../database/validation/reports.test.ts | 0 .../server/database/validation/reports.ts | 0 .../validation/rowSchemaIntegration.test.ts | 0 .../database/validation/rowSchemas.test.ts | 0 .../server/database/validation/scalars.ts | 0 .../database/validation/schemaMigrations.ts | 0 .../database/validation/securityScalars.ts | 0 .../database/validation/testSupport/rows.ts | 0 .../validation/testSupport/securityRows.ts | 0 .../validation/userRecoveryCodes.test.ts | 0 .../database/validation/userRecoveryCodes.ts | 0 .../validation/userTotpFactors.test.ts | 0 .../database/validation/userTotpFactors.ts | 0 .../userWebAuthnCredentials.test.ts | 0 .../validation/userWebAuthnCredentials.ts | 0 .../server/database/validation/users.test.ts | 0 .../src}/server/database/validation/users.ts | 0 .../database/validation/webauthnScalars.ts | 0 .../domains/monitoring/normalization.test.ts | 0 .../domains/monitoring/normalization.ts | 3 +- .../domains/monitoring/realtimeEvents.ts | 0 .../server/domains/monitoring/repository.ts | 0 .../domains/monitoring/serialization.ts | 0 .../src}/server/domains/monitoring/service.ts | 3 +- .../monitoring/serviceBoundary.test.ts | 0 .../monitoring/serviceLifecycle.test.ts | 0 .../monitoring/serviceOrdering.test.ts | 0 .../domains/monitoring/snapshotLifecycle.ts | 0 .../testSupport/monitoringService.ts | 0 .../authenticationLeaseStream.test.ts | 0 .../realtime/authenticationLeaseStream.ts | 0 .../server/domains/realtime/errors.test.ts | 0 .../src}/server/domains/realtime/errors.ts | 0 .../domains/realtime/procedures.test.ts | 0 .../server/domains/realtime/procedures.ts | 0 .../server/domains/realtime/transport.test.ts | 0 .../src}/server/domains/realtime/transport.ts | 0 .../server/domains/security/audit.test.ts | 0 .../src}/server/domains/security/audit.ts | 0 .../domains/security/authPendingMfaRoutes.ts | 0 .../authPendingMfaRoutes.webAuthn.test.ts | 0 .../domains/security/authPublicRoutes.ts | 0 .../domains/security/authSessionRoutes.ts | 0 .../authenticationLifecycle.bootstrap.test.ts | 0 .../authenticationLifecycle.login.test.ts | 0 .../authenticationLifecycle.password.test.ts | 0 .../authenticationLifecycle.rateLimit.test.ts | 0 .../authenticationLifecycle.sessions.test.ts | 0 .../security/authenticationLifecycle.ts | 0 .../authenticationLifecycleBootstrap.ts | 0 .../authenticationLifecycleContext.ts | 0 .../security/authenticationLifecycleLogin.ts | 0 .../authenticationLifecyclePassword.ts | 0 .../authenticationLifecycleRepository.test.ts | 0 .../authenticationLifecycleRepository.ts | 0 .../authenticationLifecycleSessions.ts | 0 .../security/authenticationLifecycleTypes.ts | 0 .../security/authenticationPolicy.test.ts | 0 .../domains/security/authenticationPolicy.ts | 0 .../security/authenticationRateLimit.ts | 0 .../security/authenticationRateLimitStore.ts | 0 .../security/authenticationResolution.test.ts | 0 .../security/authenticationResolution.ts | 0 .../security/authenticationSession.test.ts | 0 .../domains/security/authenticationSession.ts | 0 .../security/authenticationWorkBudget.test.ts | 0 .../security/authenticationWorkBudget.ts | 0 .../security/authenticationWorkGate.test.ts | 0 .../security/authenticationWorkGate.ts | 0 .../authenticationWorkGate.webAuthn.test.ts | 0 .../automation/credentialOperations.ts | 0 .../domains/security/automation/lifecycle.ts | 0 .../security/automation/lifecycleContext.ts | 0 .../automation/lifecycleCredential.test.ts | 0 .../automation/lifecycleCredentials.ts | 0 .../security/automation/lifecyclePolicy.ts | 0 .../automation/lifecyclePrincipal.test.ts | 0 .../automation/lifecycleRepository.test.ts | 0 .../automation/lifecycleRepository.ts | 0 .../automation/lifecycleRepositoryReader.ts | 0 .../automation/lifecycleRepositoryRecords.ts | 0 .../automation/lifecycleRepositoryTypes.ts | 8 +- .../lifecycleRepositoryUnitOfWork.ts | 0 .../security/automation/lifecycleSummaries.ts | 0 .../security/automation/lifecycleTypes.ts | 0 .../automation/principalOperations.ts | 0 .../security/automation/procedures.test.ts | 0 .../domains/security/automation/procedures.ts | 0 .../domains/security/automation/routes.ts | 0 .../automation/testSupport/lifecycle.ts | 0 .../domains/security/browserSessionStore.ts | 0 .../security/mfa/accountFactorRoutes.ts | 0 .../mfa/accountLifecycle.factors.test.ts | 0 .../mfa/accountLifecycle.maintenance.test.ts | 0 .../mfa/accountLifecycle.proofs.test.ts | 0 .../domains/security/mfa/accountLifecycle.ts | 0 .../mfa/accountLifecycle.webAuthn.test.ts | 0 .../security/mfa/accountLifecycleContext.ts | 0 .../security/mfa/accountLifecycleCrypto.ts | 0 .../security/mfa/accountLifecycleFactors.ts | 0 .../security/mfa/accountLifecycleState.ts | 0 .../security/mfa/accountLifecycleTypes.ts | 0 .../security/mfa/accountMaintenanceRoutes.ts | 0 .../domains/security/mfa/accountMfaDisable.ts | 0 .../mfa/accountPasswordReauthentication.ts | 0 .../security/mfa/accountProcedureResponses.ts | 0 .../security/mfa/accountProofRoutes.ts | 0 .../mfa/accountRecoveryCodeRotation.ts | 0 .../security/mfa/accountRecoveryStepUp.ts | 0 .../security/mfa/accountSecuritySummary.ts | 0 .../mfa/accountTotpEnrollmentBegin.ts | 0 .../mfa/accountTotpEnrollmentConfirmation.ts | 0 .../security/mfa/accountTotpFactorRemoval.ts | 0 .../domains/security/mfa/accountTotpStepUp.ts | 0 .../mfa/accountWebAuthnCredentialRemoval.ts | 0 .../mfa/accountWebAuthnEnrollmentBegin.ts | 0 .../accountWebAuthnEnrollmentConfirmation.ts | 0 .../accountWebAuthnEnrollmentSettlement.ts | 0 .../security/mfa/accountWebAuthnOperations.ts | 0 .../security/mfa/accountWebAuthnRoutes.ts | 0 .../security/mfa/accountWebAuthnState.ts | 0 .../security/mfa/accountWebAuthnStepUp.ts | 0 .../mfa/accountWebAuthnStepUpBegin.ts | 0 .../mfa/accountWebAuthnStepUpSettlement.ts | 0 .../security/mfa/lifecycleRepository.test.ts | 0 .../security/mfa/lifecycleRepository.ts | 0 .../mfa/lifecycleRepository.webAuthn.test.ts | 0 .../security/mfa/lifecycleRepositoryReader.ts | 0 .../mfa/lifecycleRepositoryRecords.ts | 0 .../security/mfa/lifecycleRepositoryTypes.ts | 0 .../mfa/lifecycleRepositoryUnitOfWork.ts | 0 .../domains/security/mfa/loginCoordinator.ts | 0 .../mfa/loginLifecycle.pending.test.ts | 0 .../mfa/loginLifecycle.recovery.test.ts | 0 .../security/mfa/loginLifecycle.totp.test.ts | 0 .../domains/security/mfa/loginLifecycle.ts | 0 .../mfa/loginLifecycle.webAuthn.test.ts | 0 .../security/mfa/loginLifecycleContext.ts | 0 .../security/mfa/loginLifecycleTypes.ts | 0 .../security/mfa/loginPendingLifecycle.ts | 0 .../security/mfa/loginRecoveryProof.ts | 0 .../domains/security/mfa/loginTotpProof.ts | 0 .../security/mfa/loginWebAuthnChallenge.ts | 0 .../security/mfa/loginWebAuthnProof.ts | 0 .../domains/security/mfa/procedures.test.ts | 0 .../server/domains/security/mfa/procedures.ts | 0 .../security/mfa/recoveryCodes.test.ts | 0 .../domains/security/mfa/recoveryCodes.ts | 0 .../mfa/testSupport/accountLifecycle.ts | 0 .../mfa/testSupport/loginLifecycle.ts | 0 .../server/domains/security/mfa/totp.test.ts | 0 .../src}/server/domains/security/mfa/totp.ts | 0 .../security/mfa/totpSecretCipher.test.ts | 0 .../domains/security/mfa/totpSecretCipher.ts | 0 .../security/mfa/webauthn/adapter.test.ts | 0 .../domains/security/mfa/webauthn/adapter.ts | 0 .../mfa/webauthn/boundaryValidation.ts | 0 .../mfa/webauthn/credentialState.test.ts | 0 .../security/mfa/webauthn/credentialState.ts | 0 .../relyingPartyConfiguration.test.ts | 0 .../mfa/webauthn/relyingPartyConfiguration.ts | 0 .../webauthn/testSupport/ceremonyFixture.ts | 0 .../server/domains/security/password.test.ts | 0 .../src}/server/domains/security/password.ts | 0 .../domains/security/procedureSupport.ts | 0 .../domains/security/procedures.test.ts | 0 .../server/domains/security/procedures.ts | 0 .../security/recentAuthentication.test.ts | 0 .../domains/security/recentAuthentication.ts | 0 .../domains/security/requestAuthentication.ts | 0 .../requestAuthenticationAutomation.test.ts | 0 .../requestAuthenticationRepository.test.ts | 0 .../requestAuthenticationRepository.ts | 0 .../requestAuthenticationSession.test.ts | 0 .../domains/security/securityAuditStore.ts | 0 .../security/securityPersistenceTypes.ts | 11 +- .../domains/security/securityUserStore.ts | 0 .../security/testSupport/authentication.ts | 0 .../testSupport/authenticationLifecycle.ts | 0 .../src}/server/domains/system/procedures.ts | 0 .../applicationConfigurationError.ts | 0 .../configurationRegistry.test.ts | 0 .../configuration/webConfiguration.test.ts | 0 .../configuration/webConfiguration.ts | 0 .../platform/errors/safeFailure.test.ts | 0 .../server/platform/errors/safeFailure.ts | 0 .../gateway/gatewayCredentialProtocol.test.ts | 0 .../gateway/gatewayCredentialProtocol.ts | 0 .../gateway/gatewayCredentialVerifier.test.ts | 0 .../gateway/gatewayCredentialVerifier.ts | 0 .../observability/effectLogger.test.ts | 0 .../platform/observability/effectLogger.ts | 0 .../observability/structuredLogger.test.ts | 0 .../observability/structuredLogger.ts | 0 .../platform/readiness/readinessState.ts | 0 .../realtime/boundedAsyncQueue.test.ts | 0 .../platform/realtime/boundedAsyncQueue.ts | 0 .../server/platform/realtime/eventPump.ts | 0 .../realtime/eventPumpContract.test.ts | 0 .../platform/realtime/eventPumpContract.ts | 0 .../realtime/eventPumpPolling.test.ts | 0 .../platform/realtime/eventPumpPolling.ts | 0 .../platform/realtime/eventPumpService.ts | 0 .../realtime/eventPumpServiceFairness.test.ts | 0 .../eventPumpServiceLifecycle.test.ts | 0 .../realtime/eventPumpServicePolling.test.ts | 0 .../eventPumpServiceSubscription.test.ts | 2 +- .../platform/realtime/eventPumpState.ts | 0 .../realtime/eventPumpStreamErrors.ts | 10 +- .../realtime/eventPumpSubscription.ts | 0 .../eventPumpSubscriptionBackpressure.test.ts | 0 .../eventPumpSubscriptionCancellation.test.ts | 0 .../eventPumpSubscriptionReplay.test.ts | 0 .../eventPumpSubscriptionRetention.test.ts | 0 .../platform/realtime/eventStore.test.ts | 2 +- .../server/platform/realtime/eventStore.ts | 0 .../platform/realtime/eventStoreEffect.ts | 6 +- .../realtime/renewableStreamLease.test.ts | 0 .../platform/realtime/renewableStreamLease.ts | 5 +- .../realtime/testSupport/eventPump.ts | 4 +- .../realtime/testSupport/eventPumpService.ts | 2 +- .../runtime/applicationRuntime.test.ts | 0 .../platform/runtime/applicationRuntime.ts | 0 .../runtime/readRuntimeIdentity.test.ts | 0 .../platform/runtime/readRuntimeIdentity.ts | 0 .../authenticationClientSource.test.ts | 0 .../rawHttp/authenticationClientSource.ts | 0 .../rawHttp/authenticationCredentials.test.ts | 0 .../rawHttp/authenticationCredentials.ts | 0 .../src}/server/rawHttp/health.ts | 0 .../server/rawHttp/pendingLoginCookie.test.ts | 0 .../src}/server/rawHttp/pendingLoginCookie.ts | 0 .../server/rawHttp/requestSecurity.test.ts | 0 .../src}/server/rawHttp/requestSecurity.ts | 0 .../src}/server/rawHttp/sessionCookie.test.ts | 0 .../src}/server/rawHttp/sessionCookie.ts | 0 .../src}/server/shared/crypto.test.ts | 0 .../src}/server/shared/crypto.ts | 0 .../src}/server/shared/opaqueToken.test.ts | 0 .../src}/server/shared/opaqueToken.ts | 0 .../src}/server/shared/passwordHash.ts | 0 .../src}/server/shared/pendingLoginPolicy.ts | 0 .../src}/server/shared/totpSecretFormat.ts | 0 .../test/contracts/superjsonTransport.test.ts | 0 .../server/test/contracts/trpcErrors.test.ts | 0 .../test/support/authenticationWorkGate.ts | 0 .../test/support/automationHttpSystem.ts | 0 .../src}/server/test/support/freshDatabase.ts | 0 .../test/support/gatewayCredentialVerifier.ts | 0 .../src}/server/test/support/mfaHttpSystem.ts | 0 .../src}/server/test/support/promise.ts | 0 .../server/test/support/requestContext.ts | 0 .../server/test/support/securityPassword.ts | 0 .../serverAuthenticationResponses.test.ts | 0 .../serverAuthenticationTransport.test.ts | 0 .../system/serverAutomationSecurity.test.ts | 0 ...utomationSecurityLeaseInvalidation.test.ts | 0 ...rverAutomationSecurityLostResponse.test.ts | 0 .../test/system/serverFoundation.test.ts | 0 ...erverGatewayCredentialVerification.test.ts | 0 .../system/serverMfaAuthentication.test.ts | 0 .../server/test/system/serverRealtime.test.ts | 0 .../server/test/system/serverShutdown.test.ts | 0 .../serverWebAuthnAuthentication.test.ts | 0 .../src}/server/trpc/appRouter.test.ts | 0 .../src}/server/trpc/appRouter.ts | 0 .../src}/server/trpc/context.test.ts | 0 .../src}/server/trpc/context.ts | 0 .../server/trpc/procedureErrorPolicy.test.ts | 0 .../src}/server/trpc/procedureErrorPolicy.ts | 0 .../src}/server/trpc/trpc.test.ts | 0 {src => greenfield/src}/server/trpc/trpc.ts | 0 .../src}/shared/bunRuntimePolicy.ts | 0 .../applicationConfigurationRegistry.ts | 0 .../src}/shared/dateTime.test.ts | 0 {src => greenfield/src}/shared/dateTime.ts | 0 .../src}/shared/encoding.test.ts | 0 {src => greenfield/src}/shared/encoding.ts | 0 {src => greenfield/src}/shared/json.test.ts | 0 {src => greenfield/src}/shared/json.ts | 0 .../src}/shared/recoveryCodePolicy.ts | 0 .../src}/shared/validation.test.ts | 0 {src => greenfield/src}/shared/validation.ts | 0 .../build/frontendBuildScenario.test.ts | 14 +- .../build/frontendBuildScenario.ts | 46 +- .../build/runFrontendBuildEvidence.ts | 45 + .../integration}/files/boundedFile.test.ts | 6 +- .../integration}/openclaw/sourceAudit.test.ts | 11 +- .../outbox/runSqliteOutboxEvidence.ts | 6 +- .../integration}/outbox/sqliteOutboxChild.ts | 56 +- .../outbox/sqliteOutboxProtocol.ts | 2 +- .../outbox/sqliteOutboxScenario.test.ts | 69 +- .../outbox/sqliteOutboxScenario.ts | 102 +- .../integration}/outbox/sqliteOutboxStore.ts | 145 +- .../resourceBudgets}/resourceBudgetCommand.ts | 25 +- .../resourceBudgetOrchestration.test.ts | 0 .../resourceBudgetOrchestration.ts | 8 +- .../resourceBudgetPolicy.test.ts | 29 +- .../resourceBudgets}/resourceBudgetPolicy.ts | 31 +- .../resourceBudgets}/resourceBudgetUnit.ts | 0 .../runResourceBudgetEvidence.ts | 4 +- .../runSafeChildCancellationEvidence.ts | 4 +- .../integration}/resources/cgroupV2.test.ts | 0 .../test/integration}/resources/cgroupV2.ts | 0 .../resources/cgroupV2Hierarchy.test.ts | 0 .../resources/cgroupV2Hierarchy.ts | 0 .../resources/pausedTlsSseClient.test.ts | 44 +- .../resources/pausedTlsSseClient.ts | 4 +- .../resources/pausedTlsSseHandshake.test.ts | 0 .../resources/pausedTlsSseHandshake.ts | 2 +- .../resources/processMemory.test.ts | 0 .../integration}/resources/processMemory.ts | 6 +- .../resources/resourcePolicy.test.ts | 16 +- .../integration}/resources/resourcePolicy.ts | 28 +- .../resources/runSseMemoryEvidence.test.ts | 18 +- .../resources/runSseMemoryEvidence.ts | 34 +- .../resources/sseMemoryEvidence.test.ts | 57 +- .../resources/sseMemoryEvidence.ts | 4 +- .../resources/sseMemoryEvidencePolicy.ts | 46 +- .../resources/sseMemoryEvidenceSchema.ts | 24 +- .../resources/sseMemoryScenario.ts | 75 +- .../resources/systemdLauncher.test.ts | 8 +- .../integration}/resources/systemdLauncher.ts | 2 +- .../resources/systemdLauncherCommand.ts | 10 +- .../resources/systemdLauncherDeadline.ts | 2 +- .../resources/systemdLauncherOrchestration.ts | 14 +- .../resources/systemdUnitControl.ts | 0 .../resources/unitIdentity.test.ts | 0 .../integration}/resources/unitIdentity.ts | 18 +- .../runtime}/runtimeCandidate.test.ts | 2 +- .../integration/runtime}/runtimeCandidate.ts | 6 +- .../shutdown/completeShutdownScenario.test.ts | 12 +- .../shutdown/completeShutdownScenario.ts | 102 +- .../shutdown/runCompleteShutdownEvidence.ts | 6 + .../integration}/shutdown/shutdownDatabase.ts | 12 +- .../shutdown/shutdownGrandchild.ts | 0 .../shutdown/shutdownIdleHttpConnection.ts | 2 +- .../integration}/shutdown/shutdownProtocol.ts | 24 +- .../integration}/shutdown/shutdownService.ts | 24 +- .../shutdown/shutdownServiceResources.test.ts | 14 +- .../shutdown/shutdownServiceResources.ts | 80 +- .../transport}/realtime/eventFeed.test.ts | 66 +- .../transport}/realtime/eventFeed.ts | 85 +- .../topology/httpsReverseProxy.test.ts | 14 +- .../transport}/topology/httpsReverseProxy.ts | 15 +- .../topology/proxyTransport.test.ts | 74 +- .../transport}/topology/proxyTransport.ts | 23 +- .../transport}/topology/releaseReadiness.ts | 16 +- .../topology/rollingReleaseSse.test.ts | 49 +- .../transport}/topology/testTlsIdentity.ts | 4 +- .../transport}/topology/trustedFetch.ts | 6 +- .../test/integration/transport/trpc/client.ts | 81 ++ .../integration/transport}/trpc/router.ts | 34 +- .../integration/transport}/trpc/server.ts | 30 +- .../transport}/trpc/trpcFetchSse.test.ts | 54 +- .../nativeWebSocketTransport.test.ts | 12 +- .../websocket/nativeWebSocketTransport.ts | 8 +- .../websocket/rawWebSocketFixture.ts | 2 +- .../websocket/rawWebSocketProtocol.ts | 10 +- .../parity/fixtures/frontend-routes.json | 0 .../parity/fixtures/greenfield-contracts.json | 0 .../parity/fixtures/legacy-endpoints.json | 0 .../test}/parity/parityFixtureCandidate.ts | 64 - .../src/test/parity/parityInventory.test.ts | 137 ++ .../test}/parity/parityInventorySchemas.ts | 2 - .../test}/parity/reviewedParityInventory.ts | 52 +- .../test/support}/asyncCleanupStack.test.ts | 2 +- .../src/test/support}/asyncCleanupStack.ts | 8 +- .../src/test/support}/waitFor.ts | 0 .../src/test/types/bunCanaryMatchers.d.ts | 38 + greenfield/tailwind.config.ts | 39 + greenfield/tsconfig.browser.json | 15 + .../tsconfig.bun.json | 3 +- greenfield/tsconfig.json | 28 + package.json | 40 +- .../browser/queryCollectionAdapter.test.ts | 256 ---- .../browser/queryCollectionAdapter.ts | 152 --- .../build/runFrontendBuildQualification.ts | 104 -- qualification/chat/chatBatching.test.ts | 80 -- qualification/chat/chatBatchingModel.ts | 238 ---- .../chat/chatBatchingQualification.ts | 209 --- .../chat/runChatBatchingQualification.ts | 49 - qualification/database/database.test.ts | 132 -- qualification/database/database.ts | 47 - qualification/database/schema.ts | 66 - .../parity/legacyBackendRouteInventory.ts | 159 --- qualification/parity/parityInventory.test.ts | 234 ---- .../parity/sourceParityInventory.test.ts | 236 ---- qualification/parity/sourceParityInventory.ts | 550 -------- .../shutdown/runCompleteShutdownEvidence.ts | 6 - qualification/trpc/client.ts | 54 - scripts/buildFrontend.ts | 2 +- scripts/frontendBuild.ts | 6 +- .../qualification/legacyBackendRouteProbe.ts | 36 - tsconfig.browser.json | 26 - tsconfig.contracts.json | 16 - tsconfig.json | 20 +- tsconfig.node.json | 1 + tsconfig.qualification.json | 9 - tsconfig.server.json | 19 - tsconfig.worker.json | 19 - 696 files changed, 5826 insertions(+), 5251 deletions(-) create mode 100644 greenfield/.bun-version create mode 100644 greenfield/.editorconfig create mode 100644 greenfield/.gitattributes create mode 100644 greenfield/.github/CODEOWNERS create mode 100644 greenfield/.github/CONTRIBUTING.md create mode 100644 greenfield/.github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 greenfield/.github/ISSUE_TEMPLATE/config.yml create mode 100644 greenfield/.github/ISSUE_TEMPLATE/feature_task.yml create mode 100644 greenfield/.github/ISSUE_TEMPLATE/ops_deploy.yml create mode 100644 greenfield/.github/codeql/codeql-config.yml create mode 100644 greenfield/.github/dependabot.yml create mode 100644 greenfield/.github/pull_request_template.md create mode 100644 greenfield/.github/workflows/codeql.yml create mode 100644 greenfield/.github/workflows/dashboard-checks.yml create mode 100644 greenfield/.gitignore create mode 100644 greenfield/.oxfmtrc.json create mode 100644 greenfield/.oxlintrc.json create mode 100644 greenfield/CODE_OF_CONDUCT.md create mode 100644 greenfield/LICENSE create mode 100644 greenfield/README.md create mode 100644 greenfield/SECURITY.md create mode 100644 greenfield/bun.lock create mode 100644 greenfield/bunfig.toml create mode 100644 greenfield/codecov.yml rename {docs => greenfield/docs}/architecture/greenfield-rewrite.md (75%) rename {docs => greenfield/docs}/architecture/greenfield-rewrite/application-architecture.md (95%) rename {docs => greenfield/docs}/architecture/greenfield-rewrite/data-and-security.md (100%) rename {docs => greenfield/docs}/architecture/greenfield-rewrite/implementation-plan.md (98%) rename {docs => greenfield/docs}/architecture/greenfield-rewrite/progress.md (96%) rename {docs => greenfield/docs}/architecture/greenfield-rewrite/runtime-and-delivery.md (87%) create mode 100644 greenfield/docs/development/testing-and-prs.md rename {docs => greenfield/docs}/generated/README.md (100%) rename {docs => greenfield/docs}/generated/configuration.md (100%) rename {docs => greenfield/docs}/generated/packages-and-runtime.md (86%) rename {docs => greenfield/docs}/generated/procedures.md (100%) rename {docs => greenfield/docs}/generated/raw-http.md (100%) rename {docs => greenfield/docs}/generated/realtime-events.md (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.beginTotpEnrollment.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.beginTotpEnrollment.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.beginWebAuthnEnrollment.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.beginWebAuthnEnrollment.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.beginWebAuthnStepUp.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.beginWebAuthnStepUp.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.confirmTotpEnrollment.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.confirmTotpEnrollment.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.confirmWebAuthnEnrollment.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.confirmWebAuthnEnrollment.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.disableMfa.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.disableMfa.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.reauthenticatePassword.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.reauthenticatePassword.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.removeTotpFactor.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.removeTotpFactor.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.removeWebAuthnCredential.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.removeWebAuthnCredential.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.rotateRecoveryCodes.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.rotateRecoveryCodes.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.stepUpRecovery.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.stepUpRecovery.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.stepUpTotp.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.stepUpTotp.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.stepUpWebAuthn.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.stepUpWebAuthn.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.summary.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/accountSecurity.summary.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.beginWebAuthnLogin.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.beginWebAuthnLogin.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.bootstrap.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.bootstrap.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.changePassword.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.changePassword.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.login.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.login.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.loginRecovery.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.loginRecovery.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.loginTotp.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.loginTotp.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.loginWebAuthn.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.loginWebAuthn.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.logout.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.logout.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.revokeSession.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.revokeSession.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.sessions.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.sessions.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.status.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.status.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.touch.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/auth.touch.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.createCredential.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.createCredential.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.createPrincipal.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.createPrincipal.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.disablePrincipal.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.disablePrincipal.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.listCredentials.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.listCredentials.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.listPrincipals.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.listPrincipals.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.revokeCredential.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.revokeCredential.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.rotateCredential.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/automationSecurity.rotateCredential.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/events.stream.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/events.stream.output.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/health.liveness.response.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/health.readiness.response.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/system.runtimeIdentity.input.schema.json (100%) rename {docs => greenfield/docs}/generated/schemas/system.runtimeIdentity.output.schema.json (100%) create mode 100644 greenfield/docs/index.md rename {docs => greenfield/docs}/security/greenfield-phase-two-threat-model.md (98%) rename drizzle.config.ts => greenfield/drizzle.config.ts (100%) rename {migrations => greenfield/migrations}/20260804022252_dashboard-foundation/migration.sql (100%) rename {migrations => greenfield/migrations}/20260804022252_dashboard-foundation/snapshot.json (100%) create mode 100644 greenfield/package.json rename {qualification => greenfield/scripts/audits}/openclaw/fixtures/2026.7.2-beta.7/agents.json (100%) rename {qualification => greenfield/scripts/audits}/openclaw/fixtures/2026.7.2-beta.7/chat.json (100%) rename {qualification => greenfield/scripts/audits}/openclaw/fixtures/2026.7.2-beta.7/cron.json (100%) rename {qualification => greenfield/scripts/audits}/openclaw/fixtures/2026.7.2-beta.7/gateway.json (100%) rename {qualification => greenfield/scripts/audits}/openclaw/fixtures/2026.7.2-beta.7/manifest.json (100%) rename {qualification => greenfield/scripts/audits}/openclaw/fixtures/2026.7.2-beta.7/sessions.json (100%) rename {qualification => greenfield/scripts/audits}/openclaw/fixtures/2026.7.2-beta.7/tasks.json (100%) rename {qualification => greenfield/scripts/audits}/openclaw/reviewedFixtures.ts (99%) rename {qualification => greenfield/scripts/audits}/openclaw/runSourceAudit.ts (100%) rename {qualification => greenfield/scripts/audits}/openclaw/sourceAudit.ts (99%) rename {qualification => greenfield/scripts/audits}/openclaw/sourceAuditSchemas.ts (100%) create mode 100644 greenfield/scripts/buildSourceIdentity.test.ts create mode 100644 greenfield/scripts/buildSourceIdentity.ts create mode 100644 greenfield/scripts/checkCoverage.test.ts create mode 100644 greenfield/scripts/checkCoverage.ts rename {scripts => greenfield/scripts}/checkDatabaseSchema.test.ts (100%) rename {scripts => greenfield/scripts}/checkDatabaseSchema.ts (100%) rename {scripts => greenfield/scripts}/checkSourceBoundaries.ts (78%) rename {scripts => greenfield/scripts}/documentation/artifacts.test.ts (99%) rename {scripts => greenfield/scripts}/documentation/artifacts.ts (100%) rename {scripts => greenfield/scripts}/documentation/bunLock.test.ts (100%) rename {scripts => greenfield/scripts}/documentation/bunLock.ts (100%) rename {scripts => greenfield/scripts}/documentation/configurationMarkdown.test.ts (100%) rename {scripts => greenfield/scripts}/documentation/files.ts (100%) rename {scripts => greenfield/scripts}/documentation/jsonSchema.test.ts (100%) rename {scripts => greenfield/scripts}/documentation/jsonSchema.ts (100%) rename {scripts => greenfield/scripts}/documentation/markdown.ts (100%) rename {qualification => greenfield/scripts}/files/boundedFile.ts (92%) create mode 100644 greenfield/scripts/frontendBuildArtifacts.ts rename {scripts => greenfield/scripts}/generateDocs.ts (100%) create mode 100644 greenfield/scripts/reactCompilerPlugin.ts create mode 100644 greenfield/scripts/runCoverage.ts create mode 100644 greenfield/scripts/runTestSuite.test.ts create mode 100644 greenfield/scripts/runTestSuite.ts rename {scripts => greenfield/scripts}/sourceBoundaries/boundaryConfiguration.test.ts (69%) rename {scripts => greenfield/scripts}/sourceBoundaries/boundaryConfiguration.ts (76%) rename {scripts => greenfield/scripts}/sourceBoundaries/checkerIntegration.test.ts (100%) rename {scripts => greenfield/scripts}/sourceBoundaries/externalAuthorityPolicy.ts (90%) rename {scripts => greenfield/scripts}/sourceBoundaries/importGraph.test.ts (100%) rename {scripts => greenfield/scripts}/sourceBoundaries/importGraph.ts (100%) rename {scripts => greenfield/scripts}/sourceBoundaries/importTargetValidation.test.ts (83%) rename {scripts => greenfield/scripts}/sourceBoundaries/importTargetValidation.ts (71%) rename {scripts => greenfield/scripts}/sourceBoundaries/lintConfiguration.test.ts (68%) rename {scripts => greenfield/scripts}/sourceBoundaries/policy.test.ts (88%) rename {scripts => greenfield/scripts}/sourceBoundaries/policy.ts (84%) rename {scripts => greenfield/scripts}/sourceBoundaries/policyTypes.ts (100%) rename {scripts => greenfield/scripts}/sourceBoundaries/runtimeAuthorityAnalysis.ts (100%) rename {scripts => greenfield/scripts}/sourceBoundaries/runtimeCodeAuthorityAnalysis.ts (100%) rename {scripts => greenfield/scripts}/sourceBoundaries/runtimeOwnerAnalysis.ts (100%) rename {scripts => greenfield/scripts}/sourceBoundaries/sourceAst.ts (100%) rename {scripts => greenfield/scripts}/sourceBoundaries/sourceBoundaryPaths.ts (100%) rename {scripts => greenfield/scripts}/sourceBoundaries/sourceDirectives.ts (100%) rename {scripts => greenfield/scripts}/sourceBoundaries/sourceDiscovery.test.ts (100%) rename {scripts => greenfield/scripts}/sourceBoundaries/sourceDiscovery.ts (98%) rename {scripts => greenfield/scripts}/sourceBoundaries/sourceTopologyPolicy.ts (52%) rename {scripts => greenfield/scripts}/sourceBoundaries/testSupport.ts (85%) create mode 100644 greenfield/scripts/testOutputPolicy.test.ts create mode 100644 greenfield/scripts/testOutputPolicy.ts rename {src => greenfield/src}/app/dashboardServer.test.ts (100%) rename {src => greenfield/src}/app/dashboardServer.ts (100%) rename {src => greenfield/src}/app/environmentSource.ts (100%) rename {src => greenfield/src}/app/server.ts (100%) rename {src => greenfield/src}/app/trpcHttpHandler.test.ts (100%) rename {src => greenfield/src}/app/trpcHttpHandler.ts (100%) rename {src => greenfield/src}/app/trpcRequestPolicy.test.ts (100%) rename {src => greenfield/src}/app/trpcRequestPolicy.ts (100%) rename {qualification/build/fixtures/frontend => greenfield/src/browser/testSupport/frontendBuildFixture}/index.html (87%) rename qualification/build/fixtures/frontend/src/QualificationApp.tsx => greenfield/src/browser/testSupport/frontendBuildFixture/src/FixtureApp.tsx (93%) rename {qualification/build/fixtures/frontend => greenfield/src/browser/testSupport/frontendBuildFixture}/src/LazyPanel.tsx (100%) rename {qualification/build/fixtures/frontend => greenfield/src/browser/testSupport/frontendBuildFixture}/src/index.css (100%) rename {qualification/build/fixtures/frontend => greenfield/src/browser/testSupport/frontendBuildFixture}/src/main.tsx (68%) rename {src => greenfield/src}/contracts/accountSecurity.test.ts (100%) rename {src => greenfield/src}/contracts/accountSecurity.ts (100%) rename {src => greenfield/src}/contracts/auth.test.ts (100%) rename {src => greenfield/src}/contracts/auth.ts (100%) rename {src => greenfield/src}/contracts/automationSecurity.test.ts (100%) rename {src => greenfield/src}/contracts/automationSecurity.ts (100%) rename {src => greenfield/src}/contracts/contractRegistry.test.ts (100%) rename {src => greenfield/src}/contracts/contractRegistry.ts (100%) rename {src => greenfield/src}/contracts/events.test.ts (100%) rename {src => greenfield/src}/contracts/events.ts (100%) rename {src => greenfield/src}/contracts/monitoring.ts (100%) rename {src => greenfield/src}/contracts/monitoringRealtime.ts (100%) rename {src => greenfield/src}/contracts/realtime.ts (100%) rename {src => greenfield/src}/contracts/registry.ts (100%) rename {src => greenfield/src}/contracts/security.test.ts (100%) rename {src => greenfield/src}/contracts/security.ts (100%) rename {src => greenfield/src}/contracts/system.ts (100%) rename {src => greenfield/src}/contracts/webauthn.test.ts (100%) rename {src => greenfield/src}/contracts/webauthn.ts (100%) rename {src => greenfield/src}/server/database/migrations/applyVerifiedMigrations.ts (100%) rename {src => greenfield/src}/server/database/migrations/auditEventsSchema.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/authenticationRateLimitSchema.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/jsonObjectConstraints.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/loadVerifiedMigrations.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/loadVerifiedMigrations.ts (100%) rename {src => greenfield/src}/server/database/migrations/manifest.ts (100%) rename {src => greenfield/src}/server/database/migrations/mfaLifecycleSchema.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/migrationApplicationTime.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/migrationGraph.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/migrationLedgerValidation.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/monitoringSchema.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/realtimeSchema.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/securityIdentitySchema.automation.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/securityIdentitySchema.baseline.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/securityIdentitySchema.browser.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/testSupport/securityIdentitySchema.ts (100%) rename {src => greenfield/src}/server/database/migrations/validation.ts (100%) rename {src => greenfield/src}/server/database/migrations/verifyDatabaseIntegrity.test.ts (100%) rename {src => greenfield/src}/server/database/migrations/verifyDatabaseIntegrity.ts (100%) rename {src => greenfield/src}/server/database/migrations/webauthnLifecycleSchema.test.ts (100%) rename {src => greenfield/src}/server/database/schema/auditEvents.ts (100%) rename {src => greenfield/src}/server/database/schema/authChallenges.ts (100%) rename {src => greenfield/src}/server/database/schema/authPendingLogins.ts (100%) rename {src => greenfield/src}/server/database/schema/authRateLimitBuckets.ts (100%) rename {src => greenfield/src}/server/database/schema/authSessions.ts (100%) rename {src => greenfield/src}/server/database/schema/automationCredentials.ts (100%) rename {src => greenfield/src}/server/database/schema/automationPersistence.test.ts (100%) rename {src => greenfield/src}/server/database/schema/automationPrincipalCapabilities.ts (100%) rename {src => greenfield/src}/server/database/schema/automationPrincipals.ts (100%) rename {src => greenfield/src}/server/database/schema/checks.ts (100%) rename {src => greenfield/src}/server/database/schema/drizzleSchema.ts (100%) rename {src => greenfield/src}/server/database/schema/incidentObservations.ts (100%) rename {src => greenfield/src}/server/database/schema/incidents.ts (100%) rename {src => greenfield/src}/server/database/schema/mfaFormats.ts (100%) rename {src => greenfield/src}/server/database/schema/monitorRuns.ts (100%) rename {src => greenfield/src}/server/database/schema/notifications.ts (100%) rename {src => greenfield/src}/server/database/schema/passwordHashCheck.ts (100%) rename {src => greenfield/src}/server/database/schema/realtime.ts (100%) rename {src => greenfield/src}/server/database/schema/reports.ts (100%) rename {src => greenfield/src}/server/database/schema/schemaMigrations.ts (100%) rename {src => greenfield/src}/server/database/schema/userRecoveryCodes.ts (100%) rename {src => greenfield/src}/server/database/schema/userTotpFactors.ts (100%) rename {src => greenfield/src}/server/database/schema/userWebAuthnCredentials.ts (100%) rename {src => greenfield/src}/server/database/schema/users.ts (100%) rename {src => greenfield/src}/server/database/schema/webauthnPersistence.test.ts (100%) rename {src => greenfield/src}/server/database/validation/auditEvents.test.ts (100%) rename {src => greenfield/src}/server/database/validation/auditEvents.ts (100%) rename {src => greenfield/src}/server/database/validation/authChallenges.test.ts (100%) rename {src => greenfield/src}/server/database/validation/authChallenges.ts (100%) rename {src => greenfield/src}/server/database/validation/authPendingLogins.test.ts (100%) rename {src => greenfield/src}/server/database/validation/authPendingLogins.ts (100%) rename {src => greenfield/src}/server/database/validation/authRateLimitBuckets.test.ts (100%) rename {src => greenfield/src}/server/database/validation/authRateLimitBuckets.ts (100%) rename {src => greenfield/src}/server/database/validation/authSessions.test.ts (100%) rename {src => greenfield/src}/server/database/validation/authSessions.ts (100%) rename {src => greenfield/src}/server/database/validation/automationCredentials.test.ts (100%) rename {src => greenfield/src}/server/database/validation/automationCredentials.ts (100%) rename {src => greenfield/src}/server/database/validation/automationPrincipalCapabilities.test.ts (100%) rename {src => greenfield/src}/server/database/validation/automationPrincipalCapabilities.ts (100%) rename {src => greenfield/src}/server/database/validation/automationPrincipals.test.ts (100%) rename {src => greenfield/src}/server/database/validation/automationPrincipals.ts (100%) rename {src => greenfield/src}/server/database/validation/incidentObservations.test.ts (100%) rename {src => greenfield/src}/server/database/validation/incidentObservations.ts (100%) rename {src => greenfield/src}/server/database/validation/incidents.test.ts (100%) rename {src => greenfield/src}/server/database/validation/incidents.ts (100%) rename {src => greenfield/src}/server/database/validation/monitorRuns.test.ts (100%) rename {src => greenfield/src}/server/database/validation/monitorRuns.ts (100%) rename {src => greenfield/src}/server/database/validation/notifications.test.ts (100%) rename {src => greenfield/src}/server/database/validation/notifications.ts (100%) rename {src => greenfield/src}/server/database/validation/realtimeEvents.test.ts (98%) rename {src => greenfield/src}/server/database/validation/realtimeEvents.ts (100%) rename {src => greenfield/src}/server/database/validation/reports.test.ts (100%) rename {src => greenfield/src}/server/database/validation/reports.ts (100%) rename {src => greenfield/src}/server/database/validation/rowSchemaIntegration.test.ts (100%) rename {src => greenfield/src}/server/database/validation/rowSchemas.test.ts (100%) rename {src => greenfield/src}/server/database/validation/scalars.ts (100%) rename {src => greenfield/src}/server/database/validation/schemaMigrations.ts (100%) rename {src => greenfield/src}/server/database/validation/securityScalars.ts (100%) rename {src => greenfield/src}/server/database/validation/testSupport/rows.ts (100%) rename {src => greenfield/src}/server/database/validation/testSupport/securityRows.ts (100%) rename {src => greenfield/src}/server/database/validation/userRecoveryCodes.test.ts (100%) rename {src => greenfield/src}/server/database/validation/userRecoveryCodes.ts (100%) rename {src => greenfield/src}/server/database/validation/userTotpFactors.test.ts (100%) rename {src => greenfield/src}/server/database/validation/userTotpFactors.ts (100%) rename {src => greenfield/src}/server/database/validation/userWebAuthnCredentials.test.ts (100%) rename {src => greenfield/src}/server/database/validation/userWebAuthnCredentials.ts (100%) rename {src => greenfield/src}/server/database/validation/users.test.ts (100%) rename {src => greenfield/src}/server/database/validation/users.ts (100%) rename {src => greenfield/src}/server/database/validation/webauthnScalars.ts (100%) rename {src => greenfield/src}/server/domains/monitoring/normalization.test.ts (100%) rename {src => greenfield/src}/server/domains/monitoring/normalization.ts (97%) rename {src => greenfield/src}/server/domains/monitoring/realtimeEvents.ts (100%) rename {src => greenfield/src}/server/domains/monitoring/repository.ts (100%) rename {src => greenfield/src}/server/domains/monitoring/serialization.ts (100%) rename {src => greenfield/src}/server/domains/monitoring/service.ts (98%) rename {src => greenfield/src}/server/domains/monitoring/serviceBoundary.test.ts (100%) rename {src => greenfield/src}/server/domains/monitoring/serviceLifecycle.test.ts (100%) rename {src => greenfield/src}/server/domains/monitoring/serviceOrdering.test.ts (100%) rename {src => greenfield/src}/server/domains/monitoring/snapshotLifecycle.ts (100%) rename {src => greenfield/src}/server/domains/monitoring/testSupport/monitoringService.ts (100%) rename {src => greenfield/src}/server/domains/realtime/authenticationLeaseStream.test.ts (100%) rename {src => greenfield/src}/server/domains/realtime/authenticationLeaseStream.ts (100%) rename {src => greenfield/src}/server/domains/realtime/errors.test.ts (100%) rename {src => greenfield/src}/server/domains/realtime/errors.ts (100%) rename {src => greenfield/src}/server/domains/realtime/procedures.test.ts (100%) rename {src => greenfield/src}/server/domains/realtime/procedures.ts (100%) rename {src => greenfield/src}/server/domains/realtime/transport.test.ts (100%) rename {src => greenfield/src}/server/domains/realtime/transport.ts (100%) rename {src => greenfield/src}/server/domains/security/audit.test.ts (100%) rename {src => greenfield/src}/server/domains/security/audit.ts (100%) rename {src => greenfield/src}/server/domains/security/authPendingMfaRoutes.ts (100%) rename {src => greenfield/src}/server/domains/security/authPendingMfaRoutes.webAuthn.test.ts (100%) rename {src => greenfield/src}/server/domains/security/authPublicRoutes.ts (100%) rename {src => greenfield/src}/server/domains/security/authSessionRoutes.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecycle.bootstrap.test.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecycle.login.test.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecycle.password.test.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecycle.rateLimit.test.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecycle.sessions.test.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecycle.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecycleBootstrap.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecycleContext.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecycleLogin.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecyclePassword.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecycleRepository.test.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecycleRepository.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecycleSessions.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationLifecycleTypes.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationPolicy.test.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationPolicy.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationRateLimit.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationRateLimitStore.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationResolution.test.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationResolution.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationSession.test.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationSession.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationWorkBudget.test.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationWorkBudget.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationWorkGate.test.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationWorkGate.ts (100%) rename {src => greenfield/src}/server/domains/security/authenticationWorkGate.webAuthn.test.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/credentialOperations.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/lifecycle.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/lifecycleContext.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/lifecycleCredential.test.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/lifecycleCredentials.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/lifecyclePolicy.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/lifecyclePrincipal.test.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/lifecycleRepository.test.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/lifecycleRepository.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/lifecycleRepositoryReader.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/lifecycleRepositoryRecords.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/lifecycleRepositoryTypes.ts (98%) rename {src => greenfield/src}/server/domains/security/automation/lifecycleRepositoryUnitOfWork.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/lifecycleSummaries.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/lifecycleTypes.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/principalOperations.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/procedures.test.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/procedures.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/routes.ts (100%) rename {src => greenfield/src}/server/domains/security/automation/testSupport/lifecycle.ts (100%) rename {src => greenfield/src}/server/domains/security/browserSessionStore.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountFactorRoutes.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountLifecycle.factors.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountLifecycle.maintenance.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountLifecycle.proofs.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountLifecycle.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountLifecycleContext.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountLifecycleCrypto.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountLifecycleFactors.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountLifecycleState.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountLifecycleTypes.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountMaintenanceRoutes.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountMfaDisable.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountPasswordReauthentication.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountProcedureResponses.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountProofRoutes.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountRecoveryCodeRotation.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountRecoveryStepUp.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountSecuritySummary.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountTotpEnrollmentBegin.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountTotpEnrollmentConfirmation.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountTotpFactorRemoval.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountTotpStepUp.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountWebAuthnCredentialRemoval.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountWebAuthnEnrollmentBegin.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountWebAuthnEnrollmentConfirmation.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountWebAuthnEnrollmentSettlement.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountWebAuthnOperations.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountWebAuthnRoutes.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountWebAuthnState.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountWebAuthnStepUp.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountWebAuthnStepUpBegin.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/accountWebAuthnStepUpSettlement.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/lifecycleRepository.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/lifecycleRepository.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/lifecycleRepositoryReader.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/lifecycleRepositoryRecords.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/lifecycleRepositoryTypes.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/lifecycleRepositoryUnitOfWork.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/loginCoordinator.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/loginLifecycle.pending.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/loginLifecycle.recovery.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/loginLifecycle.totp.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/loginLifecycle.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/loginLifecycleContext.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/loginLifecycleTypes.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/loginPendingLifecycle.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/loginRecoveryProof.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/loginTotpProof.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/loginWebAuthnChallenge.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/loginWebAuthnProof.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/procedures.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/procedures.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/recoveryCodes.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/recoveryCodes.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/testSupport/accountLifecycle.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/testSupport/loginLifecycle.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/totp.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/totp.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/totpSecretCipher.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/totpSecretCipher.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/webauthn/adapter.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/webauthn/adapter.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/webauthn/boundaryValidation.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/webauthn/credentialState.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/webauthn/credentialState.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/webauthn/relyingPartyConfiguration.test.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/webauthn/relyingPartyConfiguration.ts (100%) rename {src => greenfield/src}/server/domains/security/mfa/webauthn/testSupport/ceremonyFixture.ts (100%) rename {src => greenfield/src}/server/domains/security/password.test.ts (100%) rename {src => greenfield/src}/server/domains/security/password.ts (100%) rename {src => greenfield/src}/server/domains/security/procedureSupport.ts (100%) rename {src => greenfield/src}/server/domains/security/procedures.test.ts (100%) rename {src => greenfield/src}/server/domains/security/procedures.ts (100%) rename {src => greenfield/src}/server/domains/security/recentAuthentication.test.ts (100%) rename {src => greenfield/src}/server/domains/security/recentAuthentication.ts (100%) rename {src => greenfield/src}/server/domains/security/requestAuthentication.ts (100%) rename {src => greenfield/src}/server/domains/security/requestAuthenticationAutomation.test.ts (100%) rename {src => greenfield/src}/server/domains/security/requestAuthenticationRepository.test.ts (100%) rename {src => greenfield/src}/server/domains/security/requestAuthenticationRepository.ts (100%) rename {src => greenfield/src}/server/domains/security/requestAuthenticationSession.test.ts (100%) rename {src => greenfield/src}/server/domains/security/securityAuditStore.ts (100%) rename {src => greenfield/src}/server/domains/security/securityPersistenceTypes.ts (95%) rename {src => greenfield/src}/server/domains/security/securityUserStore.ts (100%) rename {src => greenfield/src}/server/domains/security/testSupport/authentication.ts (100%) rename {src => greenfield/src}/server/domains/security/testSupport/authenticationLifecycle.ts (100%) rename {src => greenfield/src}/server/domains/system/procedures.ts (100%) rename {src => greenfield/src}/server/platform/configuration/applicationConfigurationError.ts (100%) rename {src => greenfield/src}/server/platform/configuration/configurationRegistry.test.ts (100%) rename {src => greenfield/src}/server/platform/configuration/webConfiguration.test.ts (100%) rename {src => greenfield/src}/server/platform/configuration/webConfiguration.ts (100%) rename {src => greenfield/src}/server/platform/errors/safeFailure.test.ts (100%) rename {src => greenfield/src}/server/platform/errors/safeFailure.ts (100%) rename {src => greenfield/src}/server/platform/gateway/gatewayCredentialProtocol.test.ts (100%) rename {src => greenfield/src}/server/platform/gateway/gatewayCredentialProtocol.ts (100%) rename {src => greenfield/src}/server/platform/gateway/gatewayCredentialVerifier.test.ts (100%) rename {src => greenfield/src}/server/platform/gateway/gatewayCredentialVerifier.ts (100%) rename {src => greenfield/src}/server/platform/observability/effectLogger.test.ts (100%) rename {src => greenfield/src}/server/platform/observability/effectLogger.ts (100%) rename {src => greenfield/src}/server/platform/observability/structuredLogger.test.ts (100%) rename {src => greenfield/src}/server/platform/observability/structuredLogger.ts (100%) rename {src => greenfield/src}/server/platform/readiness/readinessState.ts (100%) rename {src => greenfield/src}/server/platform/realtime/boundedAsyncQueue.test.ts (100%) rename {src => greenfield/src}/server/platform/realtime/boundedAsyncQueue.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPump.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpContract.test.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpContract.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpPolling.test.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpPolling.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpService.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpServiceFairness.test.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpServiceLifecycle.test.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpServicePolling.test.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpServiceSubscription.test.ts (99%) rename {src => greenfield/src}/server/platform/realtime/eventPumpState.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpStreamErrors.ts (77%) rename {src => greenfield/src}/server/platform/realtime/eventPumpSubscription.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpSubscriptionBackpressure.test.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpSubscriptionCancellation.test.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpSubscriptionReplay.test.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventPumpSubscriptionRetention.test.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventStore.test.ts (99%) rename {src => greenfield/src}/server/platform/realtime/eventStore.ts (100%) rename {src => greenfield/src}/server/platform/realtime/eventStoreEffect.ts (92%) rename {src => greenfield/src}/server/platform/realtime/renewableStreamLease.test.ts (100%) rename {src => greenfield/src}/server/platform/realtime/renewableStreamLease.ts (95%) rename {src => greenfield/src}/server/platform/realtime/testSupport/eventPump.ts (98%) rename {src => greenfield/src}/server/platform/realtime/testSupport/eventPumpService.ts (98%) rename {src => greenfield/src}/server/platform/runtime/applicationRuntime.test.ts (100%) rename {src => greenfield/src}/server/platform/runtime/applicationRuntime.ts (100%) rename {src => greenfield/src}/server/platform/runtime/readRuntimeIdentity.test.ts (100%) rename {src => greenfield/src}/server/platform/runtime/readRuntimeIdentity.ts (100%) rename {src => greenfield/src}/server/rawHttp/authenticationClientSource.test.ts (100%) rename {src => greenfield/src}/server/rawHttp/authenticationClientSource.ts (100%) rename {src => greenfield/src}/server/rawHttp/authenticationCredentials.test.ts (100%) rename {src => greenfield/src}/server/rawHttp/authenticationCredentials.ts (100%) rename {src => greenfield/src}/server/rawHttp/health.ts (100%) rename {src => greenfield/src}/server/rawHttp/pendingLoginCookie.test.ts (100%) rename {src => greenfield/src}/server/rawHttp/pendingLoginCookie.ts (100%) rename {src => greenfield/src}/server/rawHttp/requestSecurity.test.ts (100%) rename {src => greenfield/src}/server/rawHttp/requestSecurity.ts (100%) rename {src => greenfield/src}/server/rawHttp/sessionCookie.test.ts (100%) rename {src => greenfield/src}/server/rawHttp/sessionCookie.ts (100%) rename {src => greenfield/src}/server/shared/crypto.test.ts (100%) rename {src => greenfield/src}/server/shared/crypto.ts (100%) rename {src => greenfield/src}/server/shared/opaqueToken.test.ts (100%) rename {src => greenfield/src}/server/shared/opaqueToken.ts (100%) rename {src => greenfield/src}/server/shared/passwordHash.ts (100%) rename {src => greenfield/src}/server/shared/pendingLoginPolicy.ts (100%) rename {src => greenfield/src}/server/shared/totpSecretFormat.ts (100%) rename {src => greenfield/src}/server/test/contracts/superjsonTransport.test.ts (100%) rename {src => greenfield/src}/server/test/contracts/trpcErrors.test.ts (100%) rename {src => greenfield/src}/server/test/support/authenticationWorkGate.ts (100%) rename {src => greenfield/src}/server/test/support/automationHttpSystem.ts (100%) rename {src => greenfield/src}/server/test/support/freshDatabase.ts (100%) rename {src => greenfield/src}/server/test/support/gatewayCredentialVerifier.ts (100%) rename {src => greenfield/src}/server/test/support/mfaHttpSystem.ts (100%) rename {src => greenfield/src}/server/test/support/promise.ts (100%) rename {src => greenfield/src}/server/test/support/requestContext.ts (100%) rename {src => greenfield/src}/server/test/support/securityPassword.ts (100%) rename {src => greenfield/src}/server/test/system/serverAuthenticationResponses.test.ts (100%) rename {src => greenfield/src}/server/test/system/serverAuthenticationTransport.test.ts (100%) rename {src => greenfield/src}/server/test/system/serverAutomationSecurity.test.ts (100%) rename {src => greenfield/src}/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts (100%) rename {src => greenfield/src}/server/test/system/serverAutomationSecurityLostResponse.test.ts (100%) rename {src => greenfield/src}/server/test/system/serverFoundation.test.ts (100%) rename {src => greenfield/src}/server/test/system/serverGatewayCredentialVerification.test.ts (100%) rename {src => greenfield/src}/server/test/system/serverMfaAuthentication.test.ts (100%) rename {src => greenfield/src}/server/test/system/serverRealtime.test.ts (100%) rename {src => greenfield/src}/server/test/system/serverShutdown.test.ts (100%) rename {src => greenfield/src}/server/test/system/serverWebAuthnAuthentication.test.ts (100%) rename {src => greenfield/src}/server/trpc/appRouter.test.ts (100%) rename {src => greenfield/src}/server/trpc/appRouter.ts (100%) rename {src => greenfield/src}/server/trpc/context.test.ts (100%) rename {src => greenfield/src}/server/trpc/context.ts (100%) rename {src => greenfield/src}/server/trpc/procedureErrorPolicy.test.ts (100%) rename {src => greenfield/src}/server/trpc/procedureErrorPolicy.ts (100%) rename {src => greenfield/src}/server/trpc/trpc.test.ts (100%) rename {src => greenfield/src}/server/trpc/trpc.ts (100%) rename {src => greenfield/src}/shared/bunRuntimePolicy.ts (100%) rename {src => greenfield/src}/shared/configuration/applicationConfigurationRegistry.ts (100%) rename {src => greenfield/src}/shared/dateTime.test.ts (100%) rename {src => greenfield/src}/shared/dateTime.ts (100%) rename {src => greenfield/src}/shared/encoding.test.ts (100%) rename {src => greenfield/src}/shared/encoding.ts (100%) rename {src => greenfield/src}/shared/json.test.ts (100%) rename {src => greenfield/src}/shared/json.ts (100%) rename {src => greenfield/src}/shared/recoveryCodePolicy.ts (100%) rename {src => greenfield/src}/shared/validation.test.ts (100%) rename {src => greenfield/src}/shared/validation.ts (100%) rename qualification/build/frontendBuildQualification.test.ts => greenfield/src/test/integration/build/frontendBuildScenario.test.ts (95%) rename qualification/build/frontendBuildQualification.ts => greenfield/src/test/integration/build/frontendBuildScenario.ts (84%) create mode 100644 greenfield/src/test/integration/build/runFrontendBuildEvidence.ts rename {qualification => greenfield/src/test/integration}/files/boundedFile.test.ts (98%) rename {qualification => greenfield/src/test/integration}/openclaw/sourceAudit.test.ts (97%) rename {qualification => greenfield/src/test/integration}/outbox/runSqliteOutboxEvidence.ts (92%) rename {qualification => greenfield/src/test/integration}/outbox/sqliteOutboxChild.ts (79%) rename {qualification => greenfield/src/test/integration}/outbox/sqliteOutboxProtocol.ts (95%) rename qualification/outbox/sqliteOutboxQualification.test.ts => greenfield/src/test/integration/outbox/sqliteOutboxScenario.test.ts (77%) rename qualification/outbox/sqliteOutboxQualification.ts => greenfield/src/test/integration/outbox/sqliteOutboxScenario.ts (77%) rename {qualification => greenfield/src/test/integration}/outbox/sqliteOutboxStore.ts (75%) rename {qualification/budgets => greenfield/src/test/integration/resourceBudgets}/resourceBudgetCommand.ts (87%) rename {qualification/budgets => greenfield/src/test/integration/resourceBudgets}/resourceBudgetOrchestration.test.ts (100%) rename {qualification/budgets => greenfield/src/test/integration/resourceBudgets}/resourceBudgetOrchestration.ts (98%) rename {qualification/budgets => greenfield/src/test/integration/resourceBudgets}/resourceBudgetPolicy.test.ts (90%) rename {qualification/budgets => greenfield/src/test/integration/resourceBudgets}/resourceBudgetPolicy.ts (95%) rename {qualification/budgets => greenfield/src/test/integration/resourceBudgets}/resourceBudgetUnit.ts (100%) rename {qualification/budgets => greenfield/src/test/integration/resourceBudgets}/runResourceBudgetEvidence.ts (63%) rename {qualification/budgets => greenfield/src/test/integration/resourceBudgets}/runSafeChildCancellationEvidence.ts (77%) rename {qualification => greenfield/src/test/integration}/resources/cgroupV2.test.ts (100%) rename {qualification => greenfield/src/test/integration}/resources/cgroupV2.ts (100%) rename {qualification => greenfield/src/test/integration}/resources/cgroupV2Hierarchy.test.ts (100%) rename {qualification => greenfield/src/test/integration}/resources/cgroupV2Hierarchy.ts (100%) rename {qualification => greenfield/src/test/integration}/resources/pausedTlsSseClient.test.ts (86%) rename {qualification => greenfield/src/test/integration}/resources/pausedTlsSseClient.ts (99%) rename {qualification => greenfield/src/test/integration}/resources/pausedTlsSseHandshake.test.ts (100%) rename {qualification => greenfield/src/test/integration}/resources/pausedTlsSseHandshake.ts (98%) rename {qualification => greenfield/src/test/integration}/resources/processMemory.test.ts (100%) rename {qualification => greenfield/src/test/integration}/resources/processMemory.ts (94%) rename {qualification => greenfield/src/test/integration}/resources/resourcePolicy.test.ts (92%) rename {qualification => greenfield/src/test/integration}/resources/resourcePolicy.ts (78%) rename qualification/resources/runSseMemoryQualification.test.ts => greenfield/src/test/integration/resources/runSseMemoryEvidence.test.ts (78%) rename qualification/resources/runSseMemoryQualification.ts => greenfield/src/test/integration/resources/runSseMemoryEvidence.ts (77%) rename {qualification => greenfield/src/test/integration}/resources/sseMemoryEvidence.test.ts (87%) rename {qualification => greenfield/src/test/integration}/resources/sseMemoryEvidence.ts (73%) rename {qualification => greenfield/src/test/integration}/resources/sseMemoryEvidencePolicy.ts (88%) rename {qualification => greenfield/src/test/integration}/resources/sseMemoryEvidenceSchema.ts (90%) rename {qualification => greenfield/src/test/integration}/resources/sseMemoryScenario.ts (80%) rename {qualification => greenfield/src/test/integration}/resources/systemdLauncher.test.ts (98%) rename {qualification => greenfield/src/test/integration}/resources/systemdLauncher.ts (91%) rename {qualification => greenfield/src/test/integration}/resources/systemdLauncherCommand.ts (95%) rename {qualification => greenfield/src/test/integration}/resources/systemdLauncherDeadline.ts (97%) rename {qualification => greenfield/src/test/integration}/resources/systemdLauncherOrchestration.ts (84%) rename {qualification => greenfield/src/test/integration}/resources/systemdUnitControl.ts (100%) rename {qualification => greenfield/src/test/integration}/resources/unitIdentity.test.ts (100%) rename {qualification => greenfield/src/test/integration}/resources/unitIdentity.ts (74%) rename {qualification => greenfield/src/test/integration/runtime}/runtimeCandidate.test.ts (90%) rename {qualification => greenfield/src/test/integration/runtime}/runtimeCandidate.ts (66%) rename qualification/shutdown/completeShutdownQualification.test.ts => greenfield/src/test/integration/shutdown/completeShutdownScenario.test.ts (96%) rename qualification/shutdown/completeShutdownQualification.ts => greenfield/src/test/integration/shutdown/completeShutdownScenario.ts (89%) create mode 100644 greenfield/src/test/integration/shutdown/runCompleteShutdownEvidence.ts rename {qualification => greenfield/src/test/integration}/shutdown/shutdownDatabase.ts (93%) rename {qualification => greenfield/src/test/integration}/shutdown/shutdownGrandchild.ts (100%) rename {qualification => greenfield/src/test/integration}/shutdown/shutdownIdleHttpConnection.ts (99%) rename {qualification => greenfield/src/test/integration}/shutdown/shutdownProtocol.ts (88%) rename {qualification => greenfield/src/test/integration}/shutdown/shutdownService.ts (93%) rename {qualification => greenfield/src/test/integration}/shutdown/shutdownServiceResources.test.ts (93%) rename {qualification => greenfield/src/test/integration}/shutdown/shutdownServiceResources.ts (87%) rename {qualification => greenfield/src/test/integration/transport}/realtime/eventFeed.test.ts (75%) rename {qualification => greenfield/src/test/integration/transport}/realtime/eventFeed.ts (66%) rename {qualification => greenfield/src/test/integration/transport}/topology/httpsReverseProxy.test.ts (93%) rename {qualification => greenfield/src/test/integration/transport}/topology/httpsReverseProxy.ts (87%) rename {qualification => greenfield/src/test/integration/transport}/topology/proxyTransport.test.ts (61%) rename {qualification => greenfield/src/test/integration/transport}/topology/proxyTransport.ts (77%) rename {qualification => greenfield/src/test/integration/transport}/topology/releaseReadiness.ts (66%) rename {qualification => greenfield/src/test/integration/transport}/topology/rollingReleaseSse.test.ts (79%) rename {qualification => greenfield/src/test/integration/transport}/topology/testTlsIdentity.ts (94%) rename {qualification => greenfield/src/test/integration/transport}/topology/trustedFetch.ts (83%) create mode 100644 greenfield/src/test/integration/transport/trpc/client.ts rename {qualification => greenfield/src/test/integration/transport}/trpc/router.ts (70%) rename {qualification => greenfield/src/test/integration/transport}/trpc/server.ts (76%) rename {qualification => greenfield/src/test/integration/transport}/trpc/trpcFetchSse.test.ts (71%) rename qualification/websocket/nativeWebSocketQualification.test.ts => greenfield/src/test/integration/websocket/nativeWebSocketTransport.test.ts (97%) rename qualification/websocket/nativeWebSocketQualification.ts => greenfield/src/test/integration/websocket/nativeWebSocketTransport.ts (98%) rename {qualification => greenfield/src/test/integration}/websocket/rawWebSocketFixture.ts (99%) rename {qualification => greenfield/src/test/integration}/websocket/rawWebSocketProtocol.ts (97%) rename {qualification => greenfield/src/test}/parity/fixtures/frontend-routes.json (100%) rename {qualification => greenfield/src/test}/parity/fixtures/greenfield-contracts.json (100%) rename {qualification => greenfield/src/test}/parity/fixtures/legacy-endpoints.json (100%) rename {qualification => greenfield/src/test}/parity/parityFixtureCandidate.ts (54%) create mode 100644 greenfield/src/test/parity/parityInventory.test.ts rename {qualification => greenfield/src/test}/parity/parityInventorySchemas.ts (98%) rename {qualification => greenfield/src/test}/parity/reviewedParityInventory.ts (77%) rename {qualification/test => greenfield/src/test/support}/asyncCleanupStack.test.ts (98%) rename {qualification/test => greenfield/src/test/support}/asyncCleanupStack.ts (91%) rename {qualification/test => greenfield/src/test/support}/waitFor.ts (100%) create mode 100644 greenfield/src/test/types/bunCanaryMatchers.d.ts create mode 100644 greenfield/tailwind.config.ts create mode 100644 greenfield/tsconfig.browser.json rename tsconfig.scripts.json => greenfield/tsconfig.bun.json (63%) create mode 100644 greenfield/tsconfig.json delete mode 100644 qualification/browser/queryCollectionAdapter.test.ts delete mode 100644 qualification/browser/queryCollectionAdapter.ts delete mode 100644 qualification/build/runFrontendBuildQualification.ts delete mode 100644 qualification/chat/chatBatching.test.ts delete mode 100644 qualification/chat/chatBatchingModel.ts delete mode 100644 qualification/chat/chatBatchingQualification.ts delete mode 100644 qualification/chat/runChatBatchingQualification.ts delete mode 100644 qualification/database/database.test.ts delete mode 100644 qualification/database/database.ts delete mode 100644 qualification/database/schema.ts delete mode 100644 qualification/parity/legacyBackendRouteInventory.ts delete mode 100644 qualification/parity/parityInventory.test.ts delete mode 100644 qualification/parity/sourceParityInventory.test.ts delete mode 100644 qualification/parity/sourceParityInventory.ts delete mode 100644 qualification/shutdown/runCompleteShutdownEvidence.ts delete mode 100644 qualification/trpc/client.ts delete mode 100644 scripts/qualification/legacyBackendRouteProbe.ts delete mode 100644 tsconfig.browser.json delete mode 100644 tsconfig.contracts.json delete mode 100644 tsconfig.qualification.json delete mode 100644 tsconfig.server.json delete mode 100644 tsconfig.worker.json diff --git a/.github/workflows/dashboard-checks.yml b/.github/workflows/dashboard-checks.yml index ee16cc973..19df71155 100644 --- a/.github/workflows/dashboard-checks.yml +++ b/.github/workflows/dashboard-checks.yml @@ -13,66 +13,6 @@ concurrency: cancel-in-progress: true jobs: - server-foundation: - name: server-foundation - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Set up Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Check source boundaries - run: bun run check:boundaries - - - name: Test source-boundary checker - run: bun run test:boundaries - - - name: Type-check browser source - run: bun run typecheck:browser - - - name: Type-check contracts and shared source - run: bun run typecheck:contracts - - - name: Type-check qualification probes - run: bun run typecheck:qualification - - - name: Type-check repository scripts - run: bun run typecheck:scripts - - - name: Type-check server source - run: bun run typecheck:server - - - name: Type-check worker source - run: bun run typecheck:worker - - - name: Test qualification probes - run: bun run test:qualification - - - name: Test server source - run: bun run test:server - - - name: Test documentation generator - run: bun run test:server:docs - - - name: Test server tooling - run: bun run test:server:tooling - - - name: Verify generated documentation - run: bun run docs:check - - - name: Verify migration graph - run: bun run db:check - frontend-checks: name: frontend-checks runs-on: ubuntu-latest @@ -176,3 +116,88 @@ jobs: flags: backend name: backend fail_ci_if_error: true + + dashboard-checks: + name: dashboard-checks + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + CODECOV_TOKEN_PRESENT: ${{ secrets.CODECOV_TOKEN != '' }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: greenfield/.bun-version + + - name: Copy the future root into isolation + run: | + mkdir -p "${RUNNER_TEMP}/mira-dashboard-greenfield" + cp -a greenfield/. "${RUNNER_TEMP}/mira-dashboard-greenfield/" + + - name: Install isolated dependencies + working-directory: ${{ runner.temp }}/mira-dashboard-greenfield + run: bun install --frozen-lockfile + + - name: Check isolated source boundaries + working-directory: ${{ runner.temp }}/mira-dashboard-greenfield + run: bun run check:boundaries + + - name: Type-check isolated source and tests + working-directory: ${{ runner.temp }}/mira-dashboard-greenfield + run: bun run typecheck + + - name: Lint isolated source and tests + working-directory: ${{ runner.temp }}/mira-dashboard-greenfield + run: bun run lint + + - name: Check isolated formatting + working-directory: ${{ runner.temp }}/mira-dashboard-greenfield + run: bun run format:check + + - name: Test isolated future root with coverage + working-directory: ${{ runner.temp }}/mira-dashboard-greenfield + run: bun run test:coverage + + - name: Prefix isolated LCOV paths for the coexistence checkout + run: | + lcov_path="${RUNNER_TEMP}/mira-dashboard-greenfield/coverage/lcov.info" + perl -0pi -e 's{^SF:(?:\./)?src/}{SF:greenfield/src/}mg' "${lcov_path}" + if grep '^SF:' "${lcov_path}" | grep --quiet --invert-match '^SF:greenfield/src/'; then + echo "LCOV contains a source path outside greenfield/src" >&2 + exit 1 + fi + grep --quiet '^SF:greenfield/src/' "${lcov_path}" + + - name: Upload isolated coverage artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: dashboard-coverage-lcov + path: ${{ runner.temp }}/mira-dashboard-greenfield/coverage/lcov.info + if-no-files-found: error + retention-days: 14 + + - name: Upload isolated coverage to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + if: ${{ env.CODECOV_TOKEN_PRESENT == 'true' }} + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + token: ${{ env.CODECOV_TOKEN }} + files: ${{ runner.temp }}/mira-dashboard-greenfield/coverage/lcov.info + flags: dashboard + name: dashboard + fail_ci_if_error: true + + - name: Verify isolated generated documentation + working-directory: ${{ runner.temp }}/mira-dashboard-greenfield + run: bun run docs:check + + - name: Verify isolated migration graph + working-directory: ${{ runner.temp }}/mira-dashboard-greenfield + run: bun run db:check diff --git a/.oxfmtrc.json b/.oxfmtrc.json index e6b378bf1..91e730581 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -5,8 +5,6 @@ "**/coverage/**", "**/data/**", "**/dist/**", - "docs/generated/**", - "migrations/**", "**/node_modules/**", "**/*.min.css", "**/*.min.js" diff --git a/.oxlintrc.json b/.oxlintrc.json index bc8ebc76d..19b21568e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -37,7 +37,7 @@ "denyWarnings": true, "reportUnusedDisableDirectives": "error", "typeAware": true, - "typeCheck": false + "typeCheck": true }, "plugins": [ "eslint", @@ -111,73 +111,18 @@ "files": [ "backend/**/*.ts", "frontend/src/test/**/*.{ts,tsx}", - "qualification/**/*.ts", - "scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "src/app/dashboardServer.ts", - "src/app/environmentSource.ts", - "src/app/server.ts", - "src/app/trpcHttpHandler.ts", - "src/app/trpcRequestPolicy.ts", - "src/app/worker.ts", - "src/server/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "*.config.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + "scripts/**/*.ts", + "*.config.{js,mjs,cjs,ts}" ], "globals": { "Bun": "readonly" } }, - { - "excludeFiles": [ - "**/*.spec.*", - "**/*.test.*", - "**/__tests__/**", - "**/test/**", - "**/testSupport/**", - "src/app/environmentSource.ts" - ], - "files": [ - "src/app/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "src/server/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" - ], - "rules": { - "no-restricted-properties": [ - "error", - { - "message": "Read the process environment only through the typed environment source.", - "object": "process", - "property": "env" - }, - { - "message": "Read the process environment only through the typed environment source.", - "object": "Bun", - "property": "env" - }, - { - "message": "Read the process environment only through the typed environment source.", - "object": "Deno", - "property": "env" - } - ] - } - }, { "env": { "browser": true }, - "excludeFiles": [ - "**/*.spec.*", - "**/*.test.*", - "**/__tests__/**", - "**/test/**", - "**/testSupport/**" - ], - "files": [ - "frontend/src/**/*.{js,jsx,ts,tsx}", - "src/app/browser.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "src/browser/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" - ], + "files": ["frontend/src/**/*.{js,jsx,ts,tsx}"], "jsPlugins": ["oxlint-tailwindcss"], "rules": { "no-restricted-imports": [ @@ -213,243 +158,7 @@ } }, { - "excludeFiles": [ - "**/*.spec.*", - "**/*.test.*", - "**/__tests__/**", - "**/test/**", - "**/testSupport/**" - ], - "files": [ - "src/contracts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "src/shared/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" - ], - "rules": { - "no-restricted-globals": [ - "error", - { - "checkGlobalObject": true, - "globals": [ - "Bun", - "Buffer", - "Deno", - "document", - "navigator", - "process", - "window" - ] - } - ], - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": [ - "**/app/**", - "**/browser/**", - "**/qualification/**", - "**/scripts/**", - "**/server/**", - "**/worker/**", - "bun", - "bun:*", - "node:*" - ], - "message": "Contracts and shared source must remain environment-neutral." - } - ] - } - ] - } - }, - { - "excludeFiles": [ - "**/*.spec.*", - "**/*.test.*", - "**/__tests__/**", - "**/test/**", - "**/testSupport/**" - ], - "files": [ - "src/app/browser.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "src/browser/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" - ], - "rules": { - "no-restricted-globals": [ - "error", - { - "checkGlobalObject": true, - "globals": ["Bun", "Buffer", "Deno", "process"] - } - ], - "no-restricted-imports": [ - "error", - { - "paths": [ - { - "importNames": ["memo", "useCallback", "useMemo"], - "message": "React Compiler owns routine memoization; keep explicit memoization out of application code.", - "name": "react" - } - ], - "patterns": [ - { - "group": [ - "**/app/**", - "**/qualification/**", - "**/scripts/**", - "**/server/**", - "**/worker/**", - "@simplewebauthn/server", - "@simplewebauthn/server/**", - "@trpc/server", - "@trpc/server/**", - "bun", - "bun:*", - "drizzle-orm", - "drizzle-orm/**", - "node:*" - ], - "message": "Browser source may import only browser, contract, and environment-neutral shared modules." - } - ] - } - ] - } - }, - { - "excludeFiles": [ - "**/*.spec.*", - "**/*.test.*", - "**/__tests__/**", - "**/test/**", - "**/testSupport/**" - ], - "files": ["src/server/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}"], - "rules": { - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": [ - "**/app/**", - "**/browser/**", - "**/qualification/**", - "**/scripts/**", - "**/worker/**" - ], - "message": "Server source may import only server, contract, and environment-neutral shared modules." - } - ] - } - ] - } - }, - { - "files": [ - "src/app/dashboardServer.ts", - "src/app/server.ts", - "src/app/trpcHttpHandler.ts", - "src/app/trpcRequestPolicy.ts" - ], - "rules": { - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": [ - "**/browser/**", - "**/qualification/**", - "**/scripts/**", - "**/worker/**" - ], - "message": "The web composition root may not import browser, worker, qualification, or script source." - } - ] - } - ] - } - }, - { - "excludeFiles": [ - "**/*.spec.*", - "**/*.test.*", - "**/__tests__/**", - "**/test/**", - "**/testSupport/**" - ], - "files": [ - "src/app/worker.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" - ], - "rules": { - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": [ - "**/app/**", - "**/browser/**", - "**/qualification/**", - "**/scripts/**", - "**/server/**" - ], - "message": "Worker source may import only worker, contract, and environment-neutral shared modules." - } - ] - } - ] - } - }, - { - "excludeFiles": [ - "**/*.spec.*", - "**/*.test.*", - "**/__tests__/**", - "**/test/**", - "**/testSupport/**" - ], - "files": [ - "*.config.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" - ], - "rules": { - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": [ - "**/qualification/**", - "**/src/app/**", - "**/src/browser/**", - "**/src/server/**", - "**/src/worker/**" - ], - "message": "Repository scripts may import only script, contract, and environment-neutral shared source." - } - ] - } - ] - } - }, - { - "files": [ - "backend/src/**/*.ts", - "src/app/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "src/server/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" - ], - "rules": { - "no-console": "error" - } - }, - { - "files": ["qualification/**/*.ts"], + "files": ["backend/src/**/*.ts"], "rules": { "no-console": "error" } diff --git a/README.md b/README.md index 875195452..89db78729 100644 --- a/README.md +++ b/README.md @@ -43,22 +43,16 @@ dist/ Bun production frontend build output ## Local development -The repository selects Bun Canary through `.bun-version`. Install or update that channel before -installing dependencies: - -```bash -bun upgrade --canary -``` - -Then install all frontend, backend, and tooling dependencies from the repo root: +Install all frontend, backend, and tooling dependencies from the repo root: ```bash bun install --frozen-lockfile ``` -The application targets the Bun 1.4 runtime API. Immutable release manifests still record the -exact Bun revision used for each build. The committed Bun config keeps the runtime-only -`bun-plugin-tailwind` peer from installing a second, stale Bun executable into `node_modules`. +The repository tracks Bun's moving Canary channel in `.bun-version`. Install or +upgrade that runtime before working in the repository; the committed Bun config +keeps the runtime-only `bun-plugin-tailwind` peer from installing a second, +stale Bun executable into `node_modules`. Run the complete local dev stack: diff --git a/backend/src/requestPolicy/evaluator.ts b/backend/src/requestPolicy/evaluator.ts index 9c69a2f4d..008e2d511 100644 --- a/backend/src/requestPolicy/evaluator.ts +++ b/backend/src/requestPolicy/evaluator.ts @@ -99,9 +99,6 @@ async function callHandler( server: Server ): Promise { if (handler instanceof Response) { - // The scripts graph sees Node's Undici clone return alongside Bun's - // stricter Response headers, while both are the same runtime object. - // oxlint-disable-next-line typescript/no-unnecessary-type-assertion return handler.clone() as Response; } return handler(request, server); diff --git a/backend/test/utilityBehavior.test.ts b/backend/test/utilityBehavior.test.ts index 9f9297f7f..453baf476 100644 --- a/backend/test/utilityBehavior.test.ts +++ b/backend/test/utilityBehavior.test.ts @@ -1278,7 +1278,7 @@ describe("backend service utilities", () => { ); expect(response.status).toBe(503); - const payload: unknown = await response.json(); + const payload = await response.json(); expect(payload).toMatchObject({ checks: { database: { diff --git a/bun.lock b/bun.lock index 7ef75ad02..693494094 100644 --- a/bun.lock +++ b/bun.lock @@ -10,27 +10,21 @@ "@dnd-kit/react": "^0.5.0", "@dnd-kit/sortable": "^10.0.0", "@headlessui/react": "^2.2.10", - "@microlink/react-json-view": "^1.31.26", + "@microlink/react-json-view": "^1.31.25", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.2", "@tailwindcss/typography": "^0.5.20", - "@tanstack/db": "0.6.17", "@tanstack/query-core": "5.101.4", - "@tanstack/query-db-collection": "1.2.1", - "@tanstack/react-db": "0.1.95", + "@tanstack/query-db-collection": "^1.2.1", + "@tanstack/react-db": "^0.1.95", "@tanstack/react-form": "^1.33.3", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.18", "@tanstack/react-store": "^0.11.0", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.14.9", - "@trpc/client": "11.18.0", - "@trpc/server": "11.18.0", - "@trpc/tanstack-react-query": "11.18.0", "clsx": "^2.1.1", "date-fns": "^4.4.0", - "drizzle-orm": "1.0.0-rc.4", - "effect": "4.0.0-beta.103", "json5": "^2.2.3", "lucide-react": "^1.28.0", "otplib": "13.4.1", @@ -45,7 +39,6 @@ "rehype-sanitize": "^6.0.0", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", - "superjson": "2.2.6", "tailwind-merge": "^3.6.0", "valibot": "^1.4.2", }, @@ -65,17 +58,13 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", - "@valibot/to-json-schema": "1.7.1", "babel-plugin-react-compiler": "^1.0.0", "bun-plugin-tailwind": "^0.1.2", - "bun-types": "1.4.0-canary.20260519T150915", - "drizzle-kit": "1.0.0-rc.4", - "eventsource": "4.1.0", + "bun-types": "canary", "happy-dom": "^20.11.1", - "jsonc-parser": "3.3.1", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", - "oxlint-config-presets": "^0.1.18", + "oxlint-config-presets": "^0.1.17", "oxlint-tailwindcss": "^1.6.0", "oxlint-tsgolint": "^7.0.2001", "tailwindcss": "^4.3.3", @@ -140,60 +129,6 @@ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - "@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="], @@ -220,23 +155,9 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], - "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], - "@microlink/react-json-view": ["@microlink/react-json-view@1.31.26", "", { "dependencies": { "react-base16-styling": "~0.10.0", "react-lifecycles-compat": "~3.0.4", "react-textarea-autosize": "~8.5.9" }, "peerDependencies": { "react": ">= 15", "react-dom": ">= 15" } }, "sha512-2sYpys438sineOJDkqk70F0rFRnZ0hpNgv0k8sEpUg3qafP5j7HrZWApj8DGi5rG7dlSRKdzgzRk4LNCsxk2MQ=="], - - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], - - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], - - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], - - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], - - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], - - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + "@microlink/react-json-view": ["@microlink/react-json-view@1.31.25", "", { "dependencies": { "react-base16-styling": "~0.10.0", "react-lifecycles-compat": "~3.0.4", "react-textarea-autosize": "~8.5.9" }, "peerDependencies": { "react": ">= 15", "react-dom": ">= 15" } }, "sha512-eVRAzd6GpIWhUwZkk1BHVtyS3Sgcw9eVJYvV/RGxahmV10TD3Z50cZNg4EQOzlEWDw+FeitTtr/Aj1yp7fRYXA=="], "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], @@ -510,12 +431,6 @@ "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], - "@trpc/client": ["@trpc/client@11.18.0", "", { "peerDependencies": { "@trpc/server": "11.18.0", "typescript": ">=5.7.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow=="], - - "@trpc/server": ["@trpc/server@11.18.0", "", { "peerDependencies": { "typescript": ">=5.7.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q=="], - - "@trpc/tanstack-react-query": ["@trpc/tanstack-react-query@11.18.0", "", { "peerDependencies": { "@tanstack/react-query": "^5.80.3", "@trpc/client": "11.18.0", "@trpc/server": "11.18.0", "react": ">=18.2.0", "typescript": ">=5.7.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-dm5xIlN0SEzJAbQ34EHdb1Kgd/zWZ63ZMviQEw3WCWCIPz8MjLKAhquncPcn+YxFUJa+Qi4qatdGFymmM4HmAQ=="], - "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -602,8 +517,6 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@valibot/to-json-schema": ["@valibot/to-json-schema@1.7.1", "", { "peerDependencies": { "valibot": "^1.4.0" } }, "sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A=="], - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -656,8 +569,6 @@ "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], - "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], - "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], "cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], @@ -680,12 +591,6 @@ "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], - "drizzle-kit": ["drizzle-kit@1.0.0-rc.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.12.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-KZCpjRyu+oYHLj/UJogfFlOkWhHVkaEI2EOT1U3NDVXUzLoTyPjqwFxwOrlQxsY6jzRyxcz4EacqboGfhEeYrA=="], - - "drizzle-orm": ["drizzle-orm@1.0.0-rc.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-d1": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-libsql": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-mysql2": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-pg": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-pglite": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-bun": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-do": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-node": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-wasm": ">=4.0.0-beta.83 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.6.0-pre.28 || >=0.6.0", "@tursodatabase/database-common": ">=0.6.0-pre.28 || >=0.6.0", "@tursodatabase/database-wasm": ">=0.6.0-pre.28 || >=0.6.0", "@tursodatabase/serverless": ">=1.1.3", "@tursodatabase/sync": ">=0.6.0-pre.28 || >=0.6.0", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.83 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-d1", "@effect/sql-libsql", "@effect/sql-mysql2", "@effect/sql-pg", "@effect/sql-pglite", "@effect/sql-sqlite-bun", "@effect/sql-sqlite-do", "@effect/sql-sqlite-node", "@effect/sql-sqlite-wasm", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@tursodatabase/serverless", "@tursodatabase/sync", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-BT+pf+qoiYHqltoA88Jmf6ilGMXPlpfE0hEJKc2adRtMCAl25Swk/t5gXcWxZNAwdtf3F5gCd2FpeOyP/pT0Hw=="], - - "effect": ["effect@4.0.0-beta.103", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-pE8TxF4m2tQzVI+77dIlm3s+81TACV1AiX1JEkvY+zVuxgQQ8aGSkqXNJF6b/ST+coCSk5cUdbULpQ7sm4oHyw=="], - "electron-to-chromium": ["electron-to-chromium@1.5.387", "", {}, "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ=="], "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], @@ -694,34 +599,22 @@ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], - "eventsource": ["eventsource@4.1.0", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ=="], - - "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], - "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], - "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="], - "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], "fractional-indexing": ["fractional-indexing@3.2.0", "", {}, "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - "get-tsconfig": ["get-tsconfig@4.14.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A=="], - "goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -756,8 +649,6 @@ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], - "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], - "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -772,24 +663,16 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], - "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], - "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - - "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -924,14 +807,6 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="], - - "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], - - "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], - - "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], - "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], @@ -942,7 +817,7 @@ "oxlint": ["oxlint@1.77.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.77.0", "@oxlint/binding-android-arm64": "1.77.0", "@oxlint/binding-darwin-arm64": "1.77.0", "@oxlint/binding-darwin-x64": "1.77.0", "@oxlint/binding-freebsd-x64": "1.77.0", "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", "@oxlint/binding-linux-arm-musleabihf": "1.77.0", "@oxlint/binding-linux-arm64-gnu": "1.77.0", "@oxlint/binding-linux-arm64-musl": "1.77.0", "@oxlint/binding-linux-ppc64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-musl": "1.77.0", "@oxlint/binding-linux-s390x-gnu": "1.77.0", "@oxlint/binding-linux-x64-gnu": "1.77.0", "@oxlint/binding-linux-x64-musl": "1.77.0", "@oxlint/binding-openharmony-arm64": "1.77.0", "@oxlint/binding-win32-arm64-msvc": "1.77.0", "@oxlint/binding-win32-ia32-msvc": "1.77.0", "@oxlint/binding-win32-x64-msvc": "1.77.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg=="], - "oxlint-config-presets": ["oxlint-config-presets@0.1.18", "", { "peerDependencies": { "oxlint": ">=0.15.0" }, "optionalPeers": ["oxlint"] }, "sha512-f0Iie2XIjvAopd5kv5xuVUPspCpWy3pJVLeQ1yWfFpBl1QAkiQEcPCOOqpkUKm+k6zqWJL+PyNCI+TimnBUFvw=="], + "oxlint-config-presets": ["oxlint-config-presets@0.1.17", "", { "peerDependencies": { "oxlint": ">=0.15.0" }, "optionalPeers": ["oxlint"] }, "sha512-LMLU+yweNquiQ74koTOn0rP/9FXjkgK9yp6dkFpwTM9lau6oVks2WupOjjrQUB4PGu92UoHu/R7MH9a0mY0Flw=="], "oxlint-tailwindcss": ["oxlint-tailwindcss@1.6.0", "", { "dependencies": { "@tailwindcss/node": "^4.3.3", "tailwindcss": "^4.3.3" } }, "sha512-Y1ZWJLI8Szu4Bl2OuZWH1lUv9VfDzD14CqlZBCU3Qxiz5AsthRqXWaYTshUPBFF1WXazsFfRBkWHRO+tIezfQA=="], @@ -962,8 +837,6 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], - "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], @@ -1010,8 +883,6 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -1038,8 +909,6 @@ "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], - "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], - "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -1050,8 +919,6 @@ "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], - "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], - "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -1088,8 +955,6 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], - "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -1104,8 +969,6 @@ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@happy-dom/global-registrator/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], diff --git a/bunfig.toml b/bunfig.toml index 822bc082e..4de6cdc6e 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -14,5 +14,5 @@ coveragePathIgnorePatterns = [ ] [serve.static] -plugins = ["./scripts/reactCompilerPlugin.ts", "bun-plugin-tailwind"] +plugins = ["bun-plugin-tailwind", "./scripts/reactCompilerPlugin.ts"] environment = "PUBLIC_*" diff --git a/codecov.yml b/codecov.yml index 41dcd75a2..e6eb12f54 100644 --- a/codecov.yml +++ b/codecov.yml @@ -4,6 +4,16 @@ coverage: range: 20..85 status: project: + dashboard: + target: 85% + threshold: 0% + informational: false + if_ci_failed: error + if_not_found: failure + flags: + - dashboard + paths: + - greenfield/src/ backend: target: 85% threshold: 0% @@ -23,6 +33,16 @@ coverage: paths: - frontend/src/ patch: + dashboard: + target: 85% + threshold: 0% + informational: false + if_ci_failed: error + if_not_found: failure + flags: + - dashboard + paths: + - greenfield/src/ backend: target: 85% threshold: 0% @@ -48,6 +68,9 @@ comment: require_changes: false flags: + dashboard: + paths: + - greenfield/src/ frontend: paths: - frontend/src/ diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index dd29cc583..16b3c1b38 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -1,10 +1,5 @@ # Endpoint Reference -> **Current-production parity input — not the greenfield API contract.** This route inventory -> describes the legacy backend and exists so the rewrite does not lose operator-visible behavior. -> Greenfield contracts are defined by their explicit tRPC/raw-HTTP registries and current generated -> references; legacy route names and payloads are not compatibility requirements. - This page lists the backend route table. It is intentionally concise: use the route files under `backend/src/routes/` for exact validation details. @@ -13,9 +8,7 @@ route files under `backend/src/routes/` for exact validation details. | Method | Path | Purpose | | ------ | ------------------------- | ------------------------------------------------------ | | `GET` | `/api/health/live` | Public web-process liveness. | -| `HEAD` | `/api/health/live` | Bodyless public web-process liveness probe. | | `GET` | `/api/health/ready` | Public activation readiness; `503` when not ready. | -| `HEAD` | `/api/health/ready` | Bodyless readiness probe with the same status as GET. | | `GET` | `/api/health/diagnostics` | Authenticated readiness details and dependency status. | | `GET` | `/api/sessions` | Normalized session snapshot from Gateway. | @@ -202,13 +195,6 @@ already-running execution finishes cooperatively. | `POST` | `/api/backup` | Creates config backup. | | `POST` | `/api/restart` | Queues an OpenClaw Gateway restart and waits for its persisted result. | -## Dashboard Settings - -| Method | Path | Purpose | -| ------ | --------------- | ------------------------------------------------------------- | -| `GET` | `/api/settings` | Reads Dashboard preferences plus current Gateway connection. | -| `PUT` | `/api/settings` | Updates the validated Dashboard preference subset atomically. | - ## Files, Config Files, Logs, Media | Method | Path | Purpose | @@ -219,9 +205,8 @@ already-running execution finishes cooperatively. | `GET` | `/api/config-files` | Lists OpenClaw config files. | | `GET` | `/api/config-files/*` | Reads a config file under OpenClaw root. | | `PUT` | `/api/config-files/*` | Writes a config file under OpenClaw root. | -| `GET` | `/api/logs/dashboard` | Reads the bounded Dashboard service log tail. | -| `GET` | `/api/logs/openclaw/files` | Lists readable OpenClaw log files and metadata. | -| `GET` | `/api/logs/openclaw/content` | Reads a bounded tail from one allowlisted OpenClaw log file. | +| `GET` | `/api/logs/info` | Lists log files/metadata. | +| `GET` | `/api/logs/content` | Reads log content. | | `GET` | `/api/media` | Serves or safely previews media bytes from OpenClaw media roots. | | `GET` | `/api/chat/media/outgoing/*` | Proxies an exact managed Gateway media path with backend-held auth. | @@ -242,33 +227,31 @@ upstream download metadata. ## Docker -| Method | Path | Purpose | -| -------- | ------------------------------------------------ | ------------------------------------------- | -| `GET` | `/api/docker/containers` | Lists containers. | -| `GET` | `/api/docker/containers/stats` | Reads the current container stats snapshot. | -| `GET` | `/api/docker/containers/:containerId` | Reads container details. | -| `POST` | `/api/docker/containers/:containerId/action` | Queues a container start/stop/restart. | -| `GET` | `/api/docker/containers/:containerId/logs` | Reads container logs. | -| `POST` | `/api/docker/exec/start` | Queues a worker-owned container exec job. | -| `GET` | `/api/docker/exec/:jobId` | Reads persisted exec output/state. | -| `POST` | `/api/docker/exec/:jobId/stop` | Requests exec cancellation. | -| `GET` | `/api/docker/images` | Lists images. | -| `DELETE` | `/api/docker/images/:imageId` | Queues image deletion. | -| `GET` | `/api/docker/volumes` | Lists volumes. | -| `DELETE` | `/api/docker/volumes/:volumeName` | Queues volume deletion. | -| `POST` | `/api/docker/prune` | Queues a Docker prune target. | -| `POST` | `/api/docker/stack/action` | Queues a Compose stack action. | -| `GET` | `/api/docker/updater/services` | Lists managed update services. | -| `GET` | `/api/docker/updater/events` | Lists update events. | -| `POST` | `/api/docker/updater/run` | Queues an updater scan. | -| `POST` | `/api/docker/updater/services/:serviceId/update` | Queues one managed service update. | +| Method | Path | Purpose | +| -------- | ------------------------------------------------ | ----------------------------------------- | +| `GET` | `/api/docker/containers` | Lists containers. | +| `GET` | `/api/docker/containers/:containerId` | Reads container details. | +| `POST` | `/api/docker/containers/:containerId/action` | Queues a container start/stop/restart. | +| `GET` | `/api/docker/containers/:containerId/logs` | Reads container logs. | +| `POST` | `/api/docker/exec/start` | Queues a worker-owned container exec job. | +| `GET` | `/api/docker/exec/:jobId` | Reads persisted exec output/state. | +| `POST` | `/api/docker/exec/:jobId/stop` | Requests exec cancellation. | +| `GET` | `/api/docker/images` | Lists images. | +| `DELETE` | `/api/docker/images/:imageId` | Queues image deletion. | +| `GET` | `/api/docker/volumes` | Lists volumes. | +| `DELETE` | `/api/docker/volumes/:volumeName` | Queues volume deletion. | +| `POST` | `/api/docker/prune` | Queues a Docker prune target. | +| `POST` | `/api/docker/stack/action` | Queues a Compose stack action. | +| `GET` | `/api/docker/updater/services` | Lists managed update services. | +| `GET` | `/api/docker/updater/events` | Lists update events. | +| `POST` | `/api/docker/updater/run` | Queues an updater scan. | +| `POST` | `/api/docker/updater/services/:serviceId/update` | Queues one managed service update. | ## Pull Requests And Deployments | Method | Path | Purpose | | ------ | -------------------------------------------- | ---------------------------------------------------------- | | `GET` | `/api/pull-requests` | Lists Dashboard PRs. | -| `POST` | `/api/pull-requests/stacks` | Creates one reviewed native GitHub PR stack. | | `POST` | `/api/pull-requests/:number/approve` | Queues merge, optionally followed by deploy. | | `POST` | `/api/pull-requests/:number/reject` | Queues reject/close. | | `POST` | `/api/pull-requests/:number/review-approval` | Queues review approval. | diff --git a/docs/architecture/gateway-and-chat.md b/docs/architecture/gateway-and-chat.md index 8aace5225..955271496 100644 --- a/docs/architecture/gateway-and-chat.md +++ b/docs/architecture/gateway-and-chat.md @@ -1,12 +1,5 @@ # Gateway And Chat Runtime -> **Current-production parity input — not the greenfield protocol specification.** This document -> describes the legacy Dashboard implementation that must be accounted for during parity work. -> Before any greenfield OpenClaw integration, inspect the source and protocol of the OpenClaw -> version currently installed; that installed behavior is authoritative and this implementation -> may be replaced. The Phase 2 greenfield slice qualifies only a one-shot bootstrap credential -> verifier, not the persistent Gateway/chat runtime described below. - Dashboard has two WebSocket layers: ```text diff --git a/docs/development/local-dev.md b/docs/development/local-dev.md index 498916c70..1cbb45e1a 100644 --- a/docs/development/local-dev.md +++ b/docs/development/local-dev.md @@ -6,22 +6,17 @@ and chat all run through the same application paths. ## Install -`.bun-version` selects Bun Canary. Install or update that channel before dependencies: - -```bash -bun upgrade --canary -``` - -Then install from the repository or PR worktree: +From the repository or PR worktree: ```bash bun install --frozen-lockfile ``` -Dashboard targets Bun 1.4 and accepts newer Canary revisions within that runtime version. The -root Bun config deliberately does not auto-install peer dependencies: required library peers are -direct dependencies, while the `bun-plugin-tailwind` Bun peer is supplied by the selected -runtime. This prevents a stable npm Bun executable from shadowing Canary in repository scripts. +`.bun-version` selects Bun Canary. The root Bun config deliberately does not +auto-install peer dependencies: required library peers are direct dependencies, +while the `bun-plugin-tailwind` Bun peer is supplied by the selected runtime. +This prevents a stable npm Bun executable from shadowing Canary in repository +scripts. When Bun 1.4 is stable, re-test installs with peer auto-installation enabled. Remove `[install] peer = false` once `bun-plugin-tailwind` no longer installs a diff --git a/docs/development/testing-and-prs.md b/docs/development/testing-and-prs.md index e4e303204..eff7375c3 100644 --- a/docs/development/testing-and-prs.md +++ b/docs/development/testing-and-prs.md @@ -84,26 +84,6 @@ Test files live under domain-owned directories: - each `support` directory for reusable fixtures and harnesses, while fixture payloads remain under `fixtures`. -### Greenfield Server Tests - -Keep tests owned by one greenfield server module beside that module. Name a -single suite after its production module. When one production module needs -several concern-focused suites, use `.test.ts`, such as -`eventPumpSubscriptionReplay.test.ts` for `eventPumpSubscription.ts`, without -recreating an omnibus test file. - -- Put shared helpers and harnesses in the owning module's `testSupport/` - directory. Production modules must never import from `testSupport/`. -- Put genuinely cross-domain test infrastructure in `src/server/test/support/`; - keep domain-specific helpers with their owner. -- Reserve `fixtures/` for static payloads; executable builders and lifecycle - helpers belong in `testSupport/`. -- Put contracts spanning multiple modules in `src/server/test/contracts/` and - composition-root behavior in `src/server/test/system/`. -- Keep tests for import-safe shared modules beside them in `src/shared/` or - `src/contracts/`; `test:server` discovers all three greenfield roots. -- Keep runtime qualification tests separate from system composition tests. - Keep mutable mock, timer, collection, and cleanup state inside a per-suite harness factory. Keep pure builders and assertions at module scope so they are not recreated for every suite. Prefer event- or dependency-driven test timing diff --git a/docs/index.md b/docs/index.md index 6427c1f91..979c32a5f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,12 +13,6 @@ away from the system it describes. checks, and rollback notes. - [Architecture overview](architecture/overview.md) - how the frontend, backend, SQLite store, OpenClaw Gateway, and background jobs fit together. -- [Greenfield rewrite blueprint](architecture/greenfield-rewrite.md) - entry - point for the implementation progress, architecture, data/security, - runtime/delivery, and phased cutover plan. -- [Generated Dashboard reference](generated/README.md) - deterministic - procedure, raw HTTP, realtime, schema, package, and runtime facts emitted - from source registries. - [Gateway and chat runtime](architecture/gateway-and-chat.md) - Gateway token validation, browser WebSocket behavior, and chat event handling. - [Frontend feature map](architecture/frontend-feature-map.md) - route/page diff --git a/docs/security/auth-and-trust-boundaries.md b/docs/security/auth-and-trust-boundaries.md index cc4d9ff1d..187448174 100644 --- a/docs/security/auth-and-trust-boundaries.md +++ b/docs/security/auth-and-trust-boundaries.md @@ -9,90 +9,41 @@ important trust boundaries: - Dashboard backend to host shell/Docker operations; - Dashboard backend to local SQLite state. -The consolidated server-side assets, misuse cases, executable controls, and residual risks are in -the [greenfield Phase 2 threat model](greenfield-phase-two-threat-model.md). - ## Route Authentication -The greenfield server exposes only these public authentication operations: - -- `auth.status` -- `auth.bootstrap` -- `auth.login` -- `auth.logout` - -`system.runtimeIdentity` and the raw `GET|HEAD /api/health/{live,ready}` probes -are also public. `auth.sessions`, `auth.touch`, `auth.revokeSession`, and -`auth.changePassword` require a browser-session principal; an automation -principal is rejected even if it is otherwise authenticated. Other procedures -declare their session/automation capability policy in the generated contract -registry. - -An automation credential can replace the session only for a procedure or -current-production route that explicitly declares its exact capability. It -cannot authenticate browser-session-only security administration. The -greenfield and current-production credential systems are documented separately -below because their token formats, capability catalogs, and persistence models -are intentionally incompatible. Account-security routes are never public merely +All `/api/*` routes require a Dashboard session except the exact public +bootstrap/login surface: + +- `GET|HEAD /api/health/live` +- `GET|HEAD /api/health/ready` +- `GET /api/auth/bootstrap` +- `GET /api/auth/session` +- `POST /api/auth/register-first-user` +- `POST /api/auth/login` +- `POST /api/auth/login/{totp,recovery}` +- `POST /api/auth/login/webauthn/{options,verify}` +- `POST /api/auth/logout` + +An explicitly scoped automation credential can replace the session only for the +small route allowlist documented below. It cannot authenticate WebSockets or +other Dashboard route families. Account-security routes are never public merely because they contain authentication functionality. -The browser session is stored in the `__Host-mira_dashboard_session` HTTP-only cookie. -The cookie is always `Secure`, `SameSite=Strict`, host-only, and scoped to `/`; -local browser development therefore uses HTTPS. Sessions use a 30-day absolute -lifetime and a configurable 30-minute idle lifetime +The browser session is stored in the `mira_dashboard_session` HTTP-only cookie. +The cookie is SameSite Strict and is Secure only when the request is HTTPS or a +trusted forwarded proto says HTTPS. Sessions use a 30-day absolute lifetime and +a configurable 30-minute idle lifetime (`MIRA_DASHBOARD_SESSION_IDLE_MINUTES`). Polling does not extend idle time; frontend requests only touch activity after recent keyboard, pointer, touch, or focus activity. Each token combines a non-secret 128-bit selector with an independent 256-bit validator. Client-readable session responses expose only the selector for identity and revocation; the validator remains in the HTTP-only cookie and is stored by Dashboard only as a SHA-256 hash. -Each user has at most 16 active browser sessions. Session creation -transactionally removes expired, idle, and authentication-version-stale rows -before evicting the oldest overflow rows. - -Bootstrap, password login, and account-password failures use persistent hashed -buckets with progressive cooldowns. Login and bootstrap layer a direct-client -source budget over a higher global circuit; neither budget uses an -attacker-controlled username. The Bun boundary accepts forwarded client -identity only from an exact configured proxy peer, canonicalizes the address, -and exposes only its SHA-256 source identifier to authentication services. -Source-scoped bucket kinds discard rows untouched for 24 hours and cap their -cardinality at 256 while retaining the just-written bucket. Cleanup is -opportunistic when that bucket kind next records a failure; it is not a -background retention promise. A successful first-user bootstrap closes the -endpoint and removes every persisted bootstrap source/global bucket. - -Argon2 work uses one active operation plus a bounded three-request queue and a -process-wide rolling budget of 30 work units per minute, including successful -password verification and hashing. Persisted password hashes must use the one -canonical Bun Argon2id PHC form `v=19,m=65536,t=3,p=1` with 32-byte salt and -digest fields. Dashboard rejects every other PHC form before calling Bun, so a -corrupt or future write path cannot select an attacker-sized Argon2 cost. -Gateway verification has a separate two-operation/four-item gate, a five-second -deadline, and request-abort propagation. The production adapter performs one -native protocol-v4 handshake audited against installed OpenClaw -`2026.7.2-beta.7 (dabe191)` over an explicit literal-loopback `ws://` endpoint; -it is not a persistent Gateway client. The handshake requests `operator.admin` -only to expose and require token auth mode in the current `hello-ok`, sends no -post-connect RPC, and allows exactly one text challenge plus its matching text -response. The challenge is capped at 4 KiB and the current installed hello at -25 MiB. Binary, unknown, duplicate, out-of-order, wrong-ID, contradictory, or -additional frames fail immediately. The upgrade has no Origin, authorization, -forwarding, or subprotocol header and no token-bearing URL. - -The verifier never reconnects or retries internally, including when OpenClaw -reports `startup-sidecars`; the operator/client retries the complete HTTP -bootstrap request under durable cooldown. Success, rejection, listener setup, -transport error, and abort initiate native close. Once a socket exists, the -Promise and its Effect permit settle only after close is observed. A timed-out -or otherwise non-cooperative verifier therefore cannot accumulate unbounded -orphan work by releasing permits while sockets remain live. -Overflow or an exhausted budget fails with `TOO_MANY_REQUESTS`; queued attempts -recheck durable cooldown state before consuming resources. TOTP decrypt and HMAC -work uses a separate two-operation/four-item gate plus a shared rolling budget of -60 work units per minute across login and account-security flows. These gates are -process-scoped Effect services, propagate request cancellation, and retain an -active permit until non-cooperative underlying work actually settles. + +Auth routes are rate-limited more tightly than general API routes. Password, +second-factor, and account-password failures also use persistent, hashed, +account-scoped buckets with progressive cooldowns. Pending MFA logins expire +after five minutes and are consumed after success or eight failed attempts. ## Two-Step Login And Step-Up @@ -107,11 +58,8 @@ Supported second factors: - **Security key (YubiKey/WebAuthn/FIDO2):** origin-bound, phishing-resistant public-key authentication with user verification required. Register two named keys and store the backup separately. Dashboard stores credential public keys, - counters, transports, fixed device type, mutable backup state, labels, and - timestamps; it does not store a YubiKey secret. Credentials bound to an older - RP ID stay listed and removable but are marked unusable and cannot - authenticate until a separately approved migration or re-enrollment replaces - them. + counters, transports, device/backup state, labels, and timestamps; it does not + store a YubiKey secret. - **Authenticator app (RFC 6238 TOTP):** interoperable SHA-1, six-digit, 30-second codes. Each seed is encrypted with versioned AES-256-GCM and context-bound associated data using @@ -125,13 +73,9 @@ Supported second factors: atomically. The first enrolled factor enables MFA, revokes every other session, rotates the -current session, and returns the recovery-code set. Removing a factor always -preserves at least one persisted possession factor, and removing a currently -usable factor must also preserve another TOTP or current-RP WebAuthn factor. -This permits old-RP cleanup without letting drifted credentials strand the next -login. Disabling MFA requires both a recent second factor and the current -password, removes all factors/codes and outstanding WebAuthn challenges, and -rotates the current session while removing every other session. +current session, and returns the recovery-code set. The final active factor +cannot be removed. Disabling MFA requires both a recent second factor and the +current password, removes all factors/codes, and revokes all sessions. Host-control actions require a second-factor verification within the configurable recent-auth window (`MIRA_DASHBOARD_RECENT_AUTH_MINUTES`, 10 minutes @@ -141,18 +85,25 @@ operations, session mutations, job cancellation, and other centrally classified privileged mutations. A user without MFA receives `mfa_enrollment_required`; a stale MFA session receives `step_up_required`. The frontend opens one global verification dialog when the server-relative verification lifetime expires; the -client clock is not an MFA authority. If an HTTP mutation races that deadline, -the shared HTTP client may hold only an explicitly replay-safe action, complete -step-up, reconcile the same-user session rotation, and retry once. One-shot -request bodies, session-bound selectors, WebAuthn responses, and expiring TOTP -enrollment proofs never replay. Cross-tab completion also requires a coherent -fresh security summary. Each tab's authenticated tRPC SSE subscription -reconnects through normal Fetch/EventSource behavior with the rotated session -cookie and resumes from its tracked cursor; there is no browser application -WebSocket to reconnect. Future chat sends remain ordinary HTTP mutations and -chat updates use this SSE path. The recent-auth window is fixed rather than -extended by general page activity, so an active or compromised browser cannot -keep privileged access fresh indefinitely. +client clock is not an MFA authority. If a request races that deadline, the +shared HTTP/WebSocket clients hold the rejected action, complete step-up, +reconnect WebSockets in every open tab with the rotated session cookie, and retry +replay-safe requests once. Held actions remain bound to their authenticated user +and browser-session identity, except for an explicitly signaled, short-lived +same-user rotation whose previous and replacement session selectors are +reconciled. Each rotation signal reconnects a socket only once, while cross-tab +step-up completion also requires a coherent fresh security summary. One-shot +request bodies never replay. Session-bound selectors and WebAuthn responses opt +out of both recovery paths. Expiring TOTP enrollment codes opt out of +post-verification replay, but a replayable JSON request can be sent once after a +signaled same-user stale `401` because request policy rejected it before the +handler. Chat keeps its optimistic message during this flow and restores unsent +composer input if delivery still fails. The recent-auth window is fixed rather +than extended by general page activity, so an active or compromised browser +cannot keep privileged access fresh indefinitely. Socket retry waiters inherit +the originating request's reconnect deadline (including indefinite operations) +and reject instead of reopening a connection after a terminal authorization +failure. Changing the Dashboard password requires the current password plus recent MFA when enabled, rotates the current session, and revokes every other session. @@ -168,92 +119,7 @@ an interactive TTY, never accepts password material through arguments or environment variables, preserves MFA by default, revokes all sessions and pending ceremonies, and writes an audit event. -## Greenfield Automation Principals - -The rewrite stores named automation principals, their exact capability sets, -and their independently rotatable credentials in SQLite. The only capabilities -implemented by the current greenfield contract surface are -`notifications:read` and `reports:read`. There are no wildcards, prefix grants, -credential-specific grants, or implication rules. Ordinary procedures use -`capabilityProcedure(capability)` to require exact membership. - -The canonical greenfield bearer token is: - -```http -Authorization: Bearer <32-lowercase-hex-prefix>.<64-lowercase-hex-validator> -``` - -The prefix is a non-secret 128-bit lookup value. The 256-bit validator is -hashed with SHA-256 over a versioned, automation-domain-bound input that also -includes the prefix. SQLite stores only the prefix, validator version, and -validator hash. The complete token appears once in a successful principal, -credential, or staged-rotation creation response; list responses, audits, -errors, and logs cannot reconstruct or expose it. - -Browser-session operators manage this state through exactly these procedures: - -- `automationSecurity.listPrincipals` -- `automationSecurity.listCredentials` -- `automationSecurity.createPrincipal` -- `automationSecurity.createCredential` -- `automationSecurity.rotateCredential` -- `automationSecurity.revokeCredential` -- `automationSecurity.replaceCapabilities` -- `automationSecurity.disablePrincipal` - -The two list operations require a browser session. Every mutation requires -recent MFA; an operator without MFA receives `mfa_enrollment_required`, and an -expired recent-MFA window receives `step_up_required`. The lifecycle revalidates -the session, authentication version, MFA state, and recent-MFA timestamp after -acquiring the SQLite immediate-transaction lock. It never relies on only the -earlier request-context snapshot. An automation principal cannot call this -administration surface even when it has every application capability. - -At most 32 principals may be enabled and at most four non-revoked, -non-expired credentials may be usable for one principal. Disabled principals -and revoked or expired credentials remain stable, newest-first history through -bounded `(created_at, id)` cursor pages. Authentication and renewable-lease -checks are deliberately read-only; the greenfield credential table has no -`last_used_at` field or per-request write path. - -Existing-principal mutations carry `expectedAuthorizationVersion`. Replacing -capabilities applies a real set diff, retains the original `granted_at` for -unchanged grants, timestamps only additions, and increments the version once. -Submitting the same set changes nothing and creates no audit event. Request -authentication and lease renewal fail closed when a grant predates principal -creation, follows its current `updated_at`, or is in the future. - -Rotation is staged so a lost HTTP response cannot lock out an automation. -`rotateCredential` creates one linked replacement and leaves the predecessor -usable until explicit `revokeCredential` after installation. The replacement -is visible by non-secret metadata; if its one-time token response is lost, the -operator revokes it and retries while the predecessor still works. A partial -unique index permits one unrevoked replacement per predecessor, and SQLite -triggers reject invalid or cross-principal replacement links. Revocation is -idempotent. Disabling a principal is terminal in this slice, increments its -authorization version, and revokes every then-usable credential in the same -transaction. The disabled principal invalidates every historical token whether -or not an already expired row receives a redundant revoke timestamp. Clock -rollback cannot block terminal containment: a credential created ahead of the -current clock may remain physically unrevoked, but the disabled principal keeps -it invalid when the clock catches up. Repeat no-ops do not grow the append-only -audit ledger. - -Generation, domain-bound hashing, lifecycle policy, and SQLite transactions are -bounded synchronous work and do not receive a dedicated Effect service. -Effect remains for cancellation, deadlines, asynchronous concurrency, and -scoped resources where those semantics materially improve correctness. - -## Current Production Scoped Credentials (Legacy During Rewrite) - -The following environment-owned credential model is the **current production -implementation only**. It remains operational until greenfield cutover, but its -`.` token, per-credential scopes, hash-only Doppler -configuration, provisioner, and raw-route allowlist are incompatible with the -greenfield database-owned model above. Do not insert its generated tokens or -hashes into greenfield tables. At cutover, clients must install server-issued -greenfield tokens through a separately reviewed secret-file workflow; the -legacy provisioner does not create greenfield principal or credential state. +## Scoped Automation Credentials Configure hash-only credentials in the Dashboard runtime: @@ -302,12 +168,11 @@ restart, backup actions, cache refreshes, log rotation, scheduled-job mutation, and all other unmapped routes are denied even if a credential contains every known scope. Add a new route or capability only through a reviewed code change. -Requests carrying both an `Authorization` header and any occurrence of the -Dashboard session cookie are rejected before authentication, including -malformed or duplicate cookie values. A lone invalid bearer returns `401`. A -valid credential without the exact route scope returns `403`; neither case -falls back to broader authentication. Allowed and denied automation mutations -use the credential id as the append-only audit actor. +On protected routes, a bearer header takes precedence over cookie +authentication. An invalid bearer returns `401`. A valid credential without the +exact route scope returns `403`. Neither falls back to broader authentication. +Allowed and denied automation mutations use the credential id as the +append-only audit actor. The Dashboard repository tracks a fixed local wrapper and one credential profile per OpenClaw caller. On the current host the runtime layout is: @@ -379,16 +244,14 @@ mutations are rejected before authentication or route execution. Direct API clients that do not emit browser provenance headers remain supported and still require a scoped credential or session. -Only configure trusted proxy addresses when each named proxy strips or -overwrites untrusted forwarding headers. The default trusted-proxy list is -empty; loopback is not implicitly trusted. Untrusted peers cannot influence -authentication source identity with `X-Real-IP` or `X-Forwarded-For`. +Only set `MIRA_DASHBOARD_TRUSTED_PROXY_IPS` when the proxy strips or overwrites +untrusted forwarding headers. A misconfigured trusted proxy can make rate limits +and secure-cookie decisions trust attacker-controlled headers. -For an exact trusted peer, Dashboard accepts one canonical address from -`X-Real-IP` or a single-value `X-Forwarded-For`. If both are present they must -canonicalize to the same address. Duplicates, chains, conflicts, and malformed -values fall back to the immediate proxy peer's source bucket. A misconfigured -trusted proxy can still make rate limits trust attacker-controlled headers. +Loopback proxy peers are trusted by default even when +`MIRA_DASHBOARD_TRUSTED_PROXY_IPS` is unset. If Dashboard is behind a same-host +reverse proxy, that proxy must still strip or overwrite client-supplied +forwarding headers before forwarding to Dashboard. The tracked frontend development proxy overwrites forwarding identity with the actual client peer. If Bun cannot resolve that peer, it forwards an explicit @@ -406,23 +269,6 @@ credentials. Dashboard responses also set a central browser policy: -- Every application-handled tRPC response, including raw auth transport - rejections, uses `Cache-Control: no-store`. Authentication procedure bodies - have a 16 KiB raw byte ceiling enforced before JSON parsing; every current - request has a Bun-level 64 KiB ceiling, and a tRPC request contains at most - eight procedures. Bodyless tRPC `HEAD` requests are rejected at the raw - boundary instead of bypassing response metadata. A future route that genuinely needs a - larger payload must introduce and qualify a deliberate bounded raw/streaming - transport instead of silently raising the process-wide allowance. Bun - buffers an incoming body before invoking this Fetch boundary, so the trusted - reverse proxy must also enforce a total body-read deadline. The production - Dashboard composition is hard-bound to `127.0.0.1`, so it cannot be exposed - remotely while bypassing that ingress prerequisite. Bun's listener uses a - 10-second general idle timeout. Once an auth body has been bounded, its handler - receives a 120-second idle budget so the maximum reviewed Gateway queue and - verification deadline can complete; the authenticated `events.stream` SSE - route disables that socket timeout and owns its heartbeat lifecycle. - - CSP defaults resources to self, blocks object/embed and framing, and keeps the existing same-origin WebSocket, HTTPS image/media preview, inline style, and same-origin microphone flows available. @@ -447,11 +293,6 @@ Cross-origin requests, missing sessions on protected routes, and requests rejected by rate limiting are stopped before audit insertion so they cannot grow the immutable table without reaching a handler. Permitted authentication route attempts and authenticated route-level denials retain their outcome. -Session listing and revocation revalidate the caller inside the same database -transaction as their read/write. Revoking a missing session returns -`revoked: false` without appending a repeatable no-op event; a successful -deletion is audited atomically. Repeating logout after its session is gone is -likewise not appended to the immutable ledger. Worker-owned execution rows add their own lifecycle events: @@ -464,9 +305,9 @@ Worker-owned execution rows add their own lifecycle events: Async job events inherit the initiating request actor and `X-Request-ID`. Automatic schedule/startup/system work uses an explicit system actor. Scoped credentials use a distinct automation actor type, separate from users. Callers -select only operational lifecycle fields for audit metadata. The authentication -audit boundary persists only an explicit allowlist of reason and revocation -fields; unknown fields are dropped rather than heuristically treated as safe. Command +select only operational lifecycle fields for audit metadata. The persistence +layer also bounds depth/size and defensively redacts keys that look like +credentials, request bodies, payloads, content, or process output. Command arguments, file content, config bodies, cookies, tokens, stdout, and stderr are never copied into the audit table. @@ -483,19 +324,16 @@ First-user bootstrap is special because it is unauthenticated by design. It must stay narrow: - reject once users exist; -- validate the submitted OpenClaw Gateway credential through the native direct-loopback one-shot - v4 Gateway verifier, including operator role, negotiated handshake scope, and token auth mode, - without persisting the submitted credential; -- bound overlapping Gateway and Argon2 work; +- validate the submitted OpenClaw Gateway token, then encrypt and persist it, + before creating the first user; +- serialize overlapping attempts; - avoid publishing a usable Dashboard user while Gateway validation is pending; -- recheck the empty-user invariant inside the immediate creation transaction; - and -- atomically create the user, hashed-validator session, audit event, and - cooldown cleanup. +- roll back submitted token and user/session state on failure; +- restore the previously active Gateway token, or shut Gateway down if no + previous token existed. -After bootstrap is complete, `auth.bootstrap` returns `CONFLICT`. Verification sends the submitted -candidate only in the one `connect` handshake and never switches a persistent Dashboard Gateway -client or stores Gateway credentials. +After bootstrap is complete, `/api/auth/register-first-user` should behave as a +closed setup endpoint and must not switch Gateway tokens. Bootstrap still creates an ordinary password-authenticated session after Gateway validation. It does not require a physical key during initial setup. @@ -513,14 +351,20 @@ Other allowed config files retain their existing bounded file policy. ## Gateway Token Handling -Do not print Gateway credential values. Infrastructure credentials remain in -the environment/Doppler composition boundary and outside SQLite. First-user -bootstrap passes the submitted value directly to the bounded Gateway verifier, -then discards it. Dashboard stores neither plaintext nor an encrypted copy of -that bootstrap credential. The Phase 2 verifier is a one-shot installed-protocol check, not -evidence for persistent Gateway lifecycle behavior. Before later OpenClaw integration, audit the -then-installed source and protocol again; current-production Dashboard integrations are parity -evidence only. +Do not print Gateway token values. `app_config.gateway_token` contains a +versioned AES-256-GCM envelope bound to its storage context; the external +`MIRA_DASHBOARD_SECRET_ENCRYPTION_KEY` remains outside SQLite. Persisted values +must already use the authenticated envelope format; unsupported plaintext or +malformed values fail startup closed. Inspect only metadata such as length and +timestamps. + +Startup token precedence: + +1. `OPENCLAW_GATEWAY_TOKEN` +2. persisted `app_config.gateway_token` + +If an environment token exists, it should be considered the source of truth for +production. ## Host Operations diff --git a/docs/setup/new-vps.md b/docs/setup/new-vps.md index 6144b72ec..938d35d46 100644 --- a/docs/setup/new-vps.md +++ b/docs/setup/new-vps.md @@ -38,16 +38,13 @@ git clone https://github.com/rajohan/Mira-Dashboard.git \ cd /home/ubuntu/projects/mira-dashboard/production/checkout ``` -Select the repository runtime channel before bootstrapping a release: +Select the repository runtime channel: ```bash bun upgrade --canary bun --revision ``` -The repository targets Bun 1.4 while `.bun-version` remains on Canary. Each immutable Dashboard -release records and reuses the exact Bun revision that built it. - ## Configure Secrets Dashboard reads production secrets through Doppler project/config diff --git a/greenfield/.bun-version b/greenfield/.bun-version new file mode 100644 index 000000000..be1bd4184 --- /dev/null +++ b/greenfield/.bun-version @@ -0,0 +1 @@ +canary diff --git a/greenfield/.editorconfig b/greenfield/.editorconfig new file mode 100644 index 000000000..f2702aee3 --- /dev/null +++ b/greenfield/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 90 +trim_trailing_whitespace = true + +[*.md] +max_line_length = 0 +trim_trailing_whitespace = false + +[COMMIT_EDITMSG] +max_line_length = 0 \ No newline at end of file diff --git a/greenfield/.gitattributes b/greenfield/.gitattributes new file mode 100644 index 000000000..225b2715b --- /dev/null +++ b/greenfield/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf + +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf \ No newline at end of file diff --git a/greenfield/.github/CODEOWNERS b/greenfield/.github/CODEOWNERS new file mode 100644 index 000000000..51ce9308d --- /dev/null +++ b/greenfield/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Automatically request Raymond as reviewer for all repository changes. +* @rajohan diff --git a/greenfield/.github/CONTRIBUTING.md b/greenfield/.github/CONTRIBUTING.md new file mode 100644 index 000000000..06bb3161c --- /dev/null +++ b/greenfield/.github/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing to Mira Dashboard + +Thanks for helping improve Mira Dashboard. + +## Development flow + +- Create a branch from `main`. +- Open a pull request for every change. +- Do not push directly to `main`. +- Keep pull requests focused and small enough to review comfortably. +- Use squash merge when merging accepted pull requests. + +## Before opening a pull request + +Run the relevant checks locally when possible: + +```bash +bun run lint +bun run format:check +bun run check:boundaries +bun run typecheck +bun run test +bun run docs:check +bun run db:check +``` + +Run focused tests while iterating, then run the applicable coverage suite before +handoff. For visible behavior, add a short manual smoke result or screenshot. If +a check cannot be run locally, explain why in the pull request. + +## Pull request requirements + +Pull requests must satisfy the repository rules before merging: + +- Required status checks must pass. +- Every changed runtime boundary must retain focused regression coverage. +- CodeQL/code scanning checks must pass. +- Code owner review is required. +- Conversations should be resolved before merge. +- Merge commits are avoided; use squash merge. + +## Security + +Do not open public issues for vulnerabilities. Use GitHub private vulnerability reporting when available, or follow the instructions in `SECURITY.md`. + +Never commit secrets, tokens, private keys, production data, or `.env` files. diff --git a/greenfield/.github/ISSUE_TEMPLATE/bug_report.yml b/greenfield/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..7405a5e37 --- /dev/null +++ b/greenfield/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,86 @@ +name: Bug report +description: Report broken or incorrect dashboard behavior +title: "bug: " +labels: + - "type: bugfix" + - "status: needs-review" +body: + - type: markdown + attributes: + value: | + Thanks for keeping this concrete. Do not include secrets, tokens, raw `.env` files, database dumps, or private runtime logs. + - type: textarea + id: summary + attributes: + label: Summary + description: What is broken? + placeholder: The sessions page crashes when... + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Exact steps, commands, or UI path. + placeholder: | + 1. Go to ... + 2. Click ... + 3. See ... + validations: + required: true + - type: input + id: version + attributes: + label: Version or commit + description: Dashboard version/commit, if known. + placeholder: abc1234 or the version shown in Settings + validations: + required: false + - type: textarea + id: environment + attributes: + label: Environment + description: Browser/device, viewport, route, session type, and relevant service state. + placeholder: Firefox on Android, /chat, main session, after Gateway reconnect + validations: + required: false + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should happen instead? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: Error message, screenshot, or sanitized log excerpt. + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - "area: frontend" + - "area: backend" + - "area: auth" + - "area: chat" + - "area: sessions" + - "area: tasks" + - "area: docker" + - "area: database" + - "area: files" + - "area: settings" + - "area: notifications" + - "area: openclaw" + - "area: ops" + - "area: ci" + validations: + required: false + - type: textarea + id: verification + attributes: + label: Verification notes + description: Reproduction frequency, regression range, and any sanitized checks already run. diff --git a/greenfield/.github/ISSUE_TEMPLATE/config.yml b/greenfield/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..cc27a62f6 --- /dev/null +++ b/greenfield/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Private security report + url: mailto:mira-2026@agentmail.to + about: Do not open public issues for secrets, auth bypasses, or sensitive operational details. diff --git a/greenfield/.github/ISSUE_TEMPLATE/feature_task.yml b/greenfield/.github/ISSUE_TEMPLATE/feature_task.yml new file mode 100644 index 000000000..b29c37635 --- /dev/null +++ b/greenfield/.github/ISSUE_TEMPLATE/feature_task.yml @@ -0,0 +1,84 @@ +name: Feature or task +description: Request a feature, refactor, test task, or maintenance item +title: "task: " +labels: + - "type: feature" + - "status: needs-review" +body: + - type: textarea + id: goal + attributes: + label: Goal + description: What outcome do we want? + placeholder: Add coverage for... + validations: + required: true + - type: textarea + id: context + attributes: + label: Context + description: Why this matters, links to prior work, constraints, or screenshots. + validations: + required: false + - type: textarea + id: scope + attributes: + label: Scope and non-goals + description: State what is included and what should deliberately remain unchanged. + validations: + required: false + - type: checkboxes + id: type + attributes: + label: Work type + options: + - label: Feature/user-visible change + - label: Bugfix + - label: Refactor + - label: Tests/coverage + - label: Security/auth/trust boundary + - label: Maintenance/cleanup + - label: Performance/resource usage + - type: dropdown + id: area + attributes: + label: Primary area + options: + - "area: frontend" + - "area: backend" + - "area: auth" + - "area: chat" + - "area: sessions" + - "area: tasks" + - "area: docker" + - "area: database" + - "area: files" + - "area: settings" + - "area: notifications" + - "area: openclaw" + - "area: ops" + - "area: ci" + validations: + required: false + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: What must be true before this is done? + placeholder: | + - [ ] ... + - [ ] ... + validations: + required: true + - type: textarea + id: verification + attributes: + label: Suggested verification + description: Tests, builds, screenshots, service health checks, or manual smoke checks. + - type: textarea + id: rollout + attributes: + label: Rollout and compatibility + description: Deployment order, data/config impact, backward compatibility, or rollback notes. + validations: + required: false diff --git a/greenfield/.github/ISSUE_TEMPLATE/ops_deploy.yml b/greenfield/.github/ISSUE_TEMPLATE/ops_deploy.yml new file mode 100644 index 000000000..69e239512 --- /dev/null +++ b/greenfield/.github/ISSUE_TEMPLATE/ops_deploy.yml @@ -0,0 +1,68 @@ +name: Ops or deploy task +description: Track deployment, service restart, config, backup, or production operations +title: "ops: " +labels: + - "area: ops" + - "status: needs-review" +body: + - type: markdown + attributes: + value: | + Keep operational details sanitized. Never paste secrets, tokens, private keys, full `.env` files, or sensitive personal data. + - type: textarea + id: summary + attributes: + label: Summary + description: What operation is needed? + placeholder: Deploy latest dashboard main and restart mira-dashboard.service... + validations: + required: true + - type: dropdown + id: risk + attributes: + label: Risk level + options: + - Low - reversible / no user impact expected + - Medium - restart or brief interruption possible + - High - production data, auth, secrets, or destructive action involved + validations: + required: true + - type: dropdown + id: environment + attributes: + label: Environment + options: + - Production + - Local development + - CI or automation + - Other + validations: + required: true + - type: textarea + id: plan + attributes: + label: Plan + description: Commands, dashboard actions, rollout order, or rollback notes. + placeholder: | + 1. Pull latest main + 2. Build and verify the release + 3. Restart service + 4. Verify /api/health/ready + validations: + required: true + - type: checkboxes + id: checklist + attributes: + label: Safety checklist + options: + - label: Production checkout/root verified + - label: Secrets/config changes identified without pasting secret values + - label: Database schema and backup impact checked + - label: Rollback or recovery path is clear + - label: Health check or smoke test is defined + - label: Required restart or interruption is explicitly authorized + - type: textarea + id: verification + attributes: + label: Completion evidence + description: Fill this in when done with commit IDs, health checks, service status, or screenshots. diff --git a/greenfield/.github/codeql/codeql-config.yml b/greenfield/.github/codeql/codeql-config.yml new file mode 100644 index 000000000..6ee65d47a --- /dev/null +++ b/greenfield/.github/codeql/codeql-config.yml @@ -0,0 +1,10 @@ +# CodeQL analysis configuration for Mira Dashboard + +name: Mira Dashboard CodeQL Config + +# Exclude test files and generated code from analysis. +paths-ignore: + - "**/node_modules/**" + - "**/dist/**" + - "**/*.test.ts" + - "**/*.test.tsx" diff --git a/greenfield/.github/dependabot.yml b/greenfield/.github/dependabot.yml new file mode 100644 index 000000000..4e11d52d8 --- /dev/null +++ b/greenfield/.github/dependabot.yml @@ -0,0 +1,53 @@ +version: 2 + +updates: + - package-ecosystem: "bun" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Oslo" + open-pull-requests-limit: 5 + labels: + - "type: dependencies" + commit-message: + prefix: "deps" + groups: + application-major: + patterns: + - "*" + update-types: + - "major" + application-minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:30" + timezone: "Europe/Oslo" + open-pull-requests-limit: 4 + labels: + - "type: dependencies" + - "area: ci" + commit-message: + prefix: "ci" + groups: + github-actions-major: + patterns: + - "*" + update-types: + - "major" + github-actions-minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" diff --git a/greenfield/.github/pull_request_template.md b/greenfield/.github/pull_request_template.md new file mode 100644 index 000000000..cfcc0defc --- /dev/null +++ b/greenfield/.github/pull_request_template.md @@ -0,0 +1,41 @@ +## Summary + + + +## Behavior and regression coverage + + + +## Verification + + + +- [ ] Repository lint: `bun run lint` +- [ ] Repository formatting: `bun run format:check` +- [ ] Source boundaries: `bun run check:boundaries` +- [ ] TypeScript graphs: `bun run typecheck` +- [ ] Dashboard tests: `bun run test` +- [ ] Coverage threshold: `bun run test:coverage` +- [ ] Generated docs and database graph: `bun run docs:check && bun run db:check` +- [ ] Focused regression tests: +- [ ] Manual UI/API smoke check, if relevant + +## Risk checklist + +- [ ] No secrets, tokens, `.env` files, database dumps, or runtime state committed +- [ ] Auth, Gateway, terminal, file, Docker, or settings changes were reviewed carefully +- [ ] New/changed API routes enforce the expected authentication and validation +- [ ] Migrations or data-shape changes include a rollout/rollback note, if relevant +- [ ] Runtime/reconnect behavior preserves ordering, idempotency, and recovery +- [ ] UI changes include screenshots or a short description of visible changes + +## Deployment / operations + +- [ ] No deploy/restart needed +- [ ] Deploy/restart needed after merge: +- [ ] Config/secrets changes needed: +- [ ] Rollback path verified: + +## Notes for reviewers + + diff --git a/greenfield/.github/workflows/codeql.yml b/greenfield/.github/workflows/codeql.yml new file mode 100644 index 000000000..0a52f3b63 --- /dev/null +++ b/greenfield/.github/workflows/codeql.yml @@ -0,0 +1,38 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: {} + schedule: + - cron: "35 3 * * 1" + +permissions: + actions: read + contents: read + security-events: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze JavaScript and TypeScript + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Initialize CodeQL + uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + with: + languages: javascript-typescript + queries: +security-extended,security-and-quality + config-file: .github/codeql/codeql-config.yml + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + with: + category: /language:javascript-typescript diff --git a/greenfield/.github/workflows/dashboard-checks.yml b/greenfield/.github/workflows/dashboard-checks.yml new file mode 100644 index 000000000..befaa0f9b --- /dev/null +++ b/greenfield/.github/workflows/dashboard-checks.yml @@ -0,0 +1,76 @@ +name: Dashboard checks + +on: + pull_request: {} + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + dashboard-checks: + name: dashboard-checks + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + CODECOV_TOKEN_PRESENT: ${{ secrets.CODECOV_TOKEN != '' }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check source boundaries + run: bun run check:boundaries + + - name: Type-check all source and tests + run: bun run typecheck + + - name: Lint + run: bun run lint + + - name: Check formatting + run: bun run format:check + + - name: Test with coverage + run: bun run test:coverage + + - name: Upload coverage artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: dashboard-coverage-lcov + path: coverage/lcov.info + if-no-files-found: error + retention-days: 14 + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + if: ${{ env.CODECOV_TOKEN_PRESENT == 'true' }} + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + token: ${{ env.CODECOV_TOKEN }} + files: coverage/lcov.info + flags: dashboard + name: dashboard + fail_ci_if_error: true + + - name: Verify generated documentation + run: bun run docs:check + + - name: Verify migration graph + run: bun run db:check diff --git a/greenfield/.gitignore b/greenfield/.gitignore new file mode 100644 index 000000000..e93f3a4f2 --- /dev/null +++ b/greenfield/.gitignore @@ -0,0 +1,33 @@ +# Logs +/logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +.env +.env.local + +node_modules +dist +release-manifest.json +data/ +.test-openclaw/ +.test-data +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +coverage/ +test-results/ diff --git a/greenfield/.oxfmtrc.json b/greenfield/.oxfmtrc.json new file mode 100644 index 000000000..2b6ab7ad0 --- /dev/null +++ b/greenfield/.oxfmtrc.json @@ -0,0 +1,25 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "endOfLine": "lf", + "ignorePatterns": [ + "**/coverage/**", + "**/data/**", + "**/dist/**", + "docs/generated/**", + "migrations/**", + "**/node_modules/**", + "**/*.min.css", + "**/*.min.js" + ], + "printWidth": 90, + "semi": true, + "singleQuote": false, + "sortImports": true, + "sortPackageJson": true, + "sortTailwindcss": { + "functions": ["clsx", "cn", "twMerge"] + }, + "tabWidth": 4, + "trailingComma": "es5", + "useTabs": false +} diff --git a/greenfield/.oxlintrc.json b/greenfield/.oxlintrc.json new file mode 100644 index 000000000..abcaf541d --- /dev/null +++ b/greenfield/.oxlintrc.json @@ -0,0 +1,442 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "error" + }, + "env": { + "builtin": true, + "es2026": true + }, + "extends": [ + "./node_modules/oxlint-config-presets/@eslint/recommended.json", + "./node_modules/oxlint-config-presets/@typescript-eslint/recommended-type-checked.json", + "./node_modules/oxlint-config-presets/import/recommended.json", + "./node_modules/oxlint-config-presets/unicorn/recommended.json", + "./node_modules/oxlint-config-presets/react/recommended.json", + "./node_modules/oxlint-config-presets/react/jsx-runtime.json", + "./node_modules/oxlint-config-presets/react-hooks/recommended.json", + "./node_modules/oxlint-config-presets/react-refresh/recommended.json", + "./node_modules/oxlint-config-presets/jsx-a11y/recommended.json", + "./node_modules/oxlint-config-presets/jest/recommended.json", + "./node_modules/oxlint-config-presets/jsdoc/recommended-typescript-error.json", + "./node_modules/oxlint-config-presets/n/recommended-module.json", + "./node_modules/oxlint-config-presets/promise/recommended.json" + ], + "ignorePatterns": [ + "**/coverage/**", + "**/data/**", + "**/dist/**", + "**/node_modules/**", + "**/*.log", + "**/*.tsbuildinfo", + ".git/**", + ".vscode/**", + "build/**" + ], + "options": { + "denyWarnings": true, + "reportUnusedDisableDirectives": "error", + "typeAware": true, + "typeCheck": false + }, + "plugins": [ + "eslint", + "import", + "jest", + "jsdoc", + "jsx-a11y", + "node", + "oxc", + "promise", + "react", + "react-perf", + "typescript", + "unicorn" + ], + "rules": { + "jsdoc/require-param": [ + "error", + { + "checkDestructured": false, + "checkDestructuredRoots": false, + "interfaceExemptsParamsCheck": true + } + ], + "jsdoc/require-throws-description": "error", + "jsdoc/require-yields-description": "error", + "react/react-compiler": "error", + "require-await": "off", + "typescript/require-await": "error", + "unicorn/no-null": "off", + "unicorn/no-useless-undefined": [ + "error", + { + "checkArguments": false + } + ], + "unicorn/filename-case": [ + "error", + { + "cases": { + "camelCase": true, + "pascalCase": true + } + } + ] + }, + "settings": { + "react": { + "version": "19.2.0" + }, + "tailwindcss": { + "entryPoint": "src/browser/index.css" + } + }, + "overrides": [ + { + "files": ["src/contracts/**/*.ts", "src/test/parity/**/*Schemas.ts"], + "rules": { + "unicorn/max-nested-calls": [ + "error", + { + "max": 6 + } + ] + } + }, + { + "env": { + "node": true + }, + "files": [ + "scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/app/dashboardServer.ts", + "src/app/environmentSource.ts", + "src/app/server.ts", + "src/app/trpcHttpHandler.ts", + "src/app/trpcRequestPolicy.ts", + "src/app/worker.ts", + "src/server/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/test/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "*.config.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], + "globals": { + "Bun": "readonly" + } + }, + { + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**", + "src/app/environmentSource.ts" + ], + "files": [ + "src/app/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/server/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], + "rules": { + "no-restricted-properties": [ + "error", + { + "message": "Read the process environment only through the typed environment source.", + "object": "process", + "property": "env" + }, + { + "message": "Read the process environment only through the typed environment source.", + "object": "Bun", + "property": "env" + }, + { + "message": "Read the process environment only through the typed environment source.", + "object": "Deno", + "property": "env" + } + ] + } + }, + { + "env": { + "browser": true + }, + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**" + ], + "files": [ + "src/app/browser.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/browser/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], + "jsPlugins": ["oxlint-tailwindcss"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "importNames": ["memo", "useCallback", "useMemo"], + "message": "React Compiler owns routine memoization; keep explicit memoization out of application code.", + "name": "react" + } + ] + } + ], + "tailwindcss/consistent-variant-order": "error", + "tailwindcss/enforce-canonical": "error", + "tailwindcss/enforce-consistent-important-position": "error", + "tailwindcss/enforce-consistent-variable-syntax": "error", + "tailwindcss/enforce-negative-arbitrary-values": "error", + "tailwindcss/enforce-shorthand": "error", + "tailwindcss/no-conflicting-classes": "error", + "tailwindcss/no-dark-without-light": "error", + "tailwindcss/no-deprecated-classes": "error", + "tailwindcss/no-duplicate-classes": "error", + "tailwindcss/no-unknown-classes": [ + "error", + { + "ignorePrefixes": ["language-"] + } + ], + "tailwindcss/no-unnecessary-arbitrary-value": "error", + "tailwindcss/no-unnecessary-whitespace": "error" + } + }, + { + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**" + ], + "files": [ + "src/contracts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/shared/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], + "rules": { + "no-restricted-globals": [ + "error", + { + "checkGlobalObject": true, + "globals": [ + "Bun", + "Buffer", + "Deno", + "document", + "navigator", + "process", + "window" + ] + } + ], + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/app/**", + "**/browser/**", + "**/scripts/**", + "**/server/**", + "**/worker/**", + "bun", + "bun:*", + "node:*" + ], + "message": "Contracts and shared source must remain environment-neutral." + } + ] + } + ] + } + }, + { + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**" + ], + "files": [ + "src/app/browser.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/browser/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], + "rules": { + "no-restricted-globals": [ + "error", + { + "checkGlobalObject": true, + "globals": ["Bun", "Buffer", "Deno", "process"] + } + ], + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "importNames": ["memo", "useCallback", "useMemo"], + "message": "React Compiler owns routine memoization; keep explicit memoization out of application code.", + "name": "react" + } + ], + "patterns": [ + { + "group": [ + "**/app/**", + "**/scripts/**", + "**/server/**", + "**/worker/**", + "@simplewebauthn/server", + "@simplewebauthn/server/**", + "@trpc/server", + "@trpc/server/**", + "bun", + "bun:*", + "drizzle-orm", + "drizzle-orm/**", + "node:*" + ], + "message": "Browser source may import only browser, contract, and environment-neutral shared modules." + } + ] + } + ] + } + }, + { + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**" + ], + "files": ["src/server/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/app/**", + "**/browser/**", + "**/scripts/**", + "**/worker/**" + ], + "message": "Server source may import only server, contract, and environment-neutral shared modules." + } + ] + } + ] + } + }, + { + "files": [ + "src/app/dashboardServer.ts", + "src/app/server.ts", + "src/app/trpcHttpHandler.ts", + "src/app/trpcRequestPolicy.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/browser/**", + "**/scripts/**", + "**/worker/**" + ], + "message": "The web composition root may not import browser, worker, or script source." + } + ] + } + ] + } + }, + { + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**" + ], + "files": [ + "src/app/worker.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/app/**", + "**/browser/**", + "**/scripts/**", + "**/server/**" + ], + "message": "Worker source may import only worker, contract, and environment-neutral shared modules." + } + ] + } + ] + } + }, + { + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**" + ], + "files": [ + "*.config.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/src/app/**", + "**/src/browser/**", + "**/src/server/**", + "**/src/worker/**" + ], + "message": "Repository scripts may import only script, contract, and environment-neutral shared source." + } + ] + } + ] + } + }, + { + "files": [ + "src/app/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/server/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], + "rules": { + "no-console": "error" + } + } + ] +} diff --git a/greenfield/CODE_OF_CONDUCT.md b/greenfield/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..8e08b5fe0 --- /dev/null +++ b/greenfield/CODE_OF_CONDUCT.md @@ -0,0 +1,112 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards +of acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, or +harmful. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all project spaces, and also applies when an +individual is officially representing the project in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported using the security or contact instructions in `SECURITY.md`. All +complaints will be reviewed and investigated promptly and fairly. + +All project maintainers are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Project maintainers will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact:** Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence:** A private, written warning from maintainers, providing clarity +around the nature of the violation and an explanation of why the behavior was +inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact:** A violation through a single incident or series of +actions. + +**Consequence:** A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. Violating +these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact:** A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence:** A temporary ban from any interaction or public communication +with the community for a specified period of time. Violating these terms may +lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact:** Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence:** A permanent ban from any public interaction within the project +community. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 2.1, +available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +Community Impact Guidelines were inspired by Mozilla's code of conduct +enforcement ladder. diff --git a/greenfield/LICENSE b/greenfield/LICENSE new file mode 100644 index 000000000..c0e7d143e --- /dev/null +++ b/greenfield/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Raymond Johannessen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/greenfield/README.md b/greenfield/README.md new file mode 100644 index 000000000..633fe6537 --- /dev/null +++ b/greenfield/README.md @@ -0,0 +1,42 @@ +# Mira Dashboard + +This directory is the self-contained future repository root for the Dashboard rewrite. +Its contents use their final post-cutover paths: application source lives in `src/`, +repository tooling in `scripts/`, migrations in `migrations/`, and project configuration +at this directory root. + +Until cutover, the current production application remains outside this directory. Greenfield +source, tests, tooling, configuration, and documentation must not import, read, or resolve files +from that parent tree. + +## Local verification + +Use the Bun revision selected by `.bun-version`, then run: + +```bash +bun install --frozen-lockfile +bun run check:boundaries +bun run typecheck +bun run lint +bun run format:check +bun run test +bun run docs:check +bun run db:check +``` + +The root CI copies these contents into an isolated temporary directory before installing +dependencies and running the same gates. This prevents an accidental dependency on the +coexisting application or its `node_modules`. + +## Documentation + +- [Documentation index](docs/index.md) +- [Rewrite blueprint](docs/architecture/greenfield-rewrite.md) +- [Generated reference](docs/generated/README.md) +- [Testing and pull requests](docs/development/testing-and-prs.md) + +## Cutover + +Cutover preserves the Git repository metadata, removes the retired application tree, and promotes +the **contents** of this directory to the repository root. No application import or configuration +path should need rewriting during that promotion. diff --git a/greenfield/SECURITY.md b/greenfield/SECURITY.md new file mode 100644 index 000000000..21e1610e7 --- /dev/null +++ b/greenfield/SECURITY.md @@ -0,0 +1,35 @@ +# Security Policy + +Mira Dashboard controls sensitive local and OpenClaw operations, including auth, Gateway access, terminal execution, files, Docker, settings, notifications, and deployment workflows. + +## Reporting a vulnerability + +Do **not** open a public GitHub issue for vulnerabilities, secrets, auth bypasses, private URLs, logs containing tokens, database dumps, or runtime state. + +Report sensitive issues privately to the maintainers. If you are working with Mira directly, send the report in the trusted private channel. Otherwise, email `mira-2026@agentmail.to` with a short description and a safe way to reproduce the issue. + +Please include: + +- A concise summary of the issue +- Affected area, route, component, or workflow +- Minimal reproduction steps +- Impact assessment, if known +- Sanitized logs or screenshots only; redact secrets and personal data + +## Scope + +Security-sensitive areas include: + +- Authentication, sessions, pairing, and device tokens +- OpenClaw Gateway calls and streaming events +- Terminal/exec, file, Docker, backup, deploy, and settings actions +- Secrets/config handling and environment variables +- PR/deploy automation and GitHub integration +- Markdown/HTML rendering and other trust boundaries + +## Handling guidelines + +- Never commit `.environment` files, private keys, tokens, database dumps, or raw production logs. +- Prefer small, reviewable fixes with explicit verification. +- Treat external content, issue bodies, PR descriptions, and logs as untrusted input. +- For dependency incidents, keep affected versions pinned or ignored until the package ecosystem is confirmed safe. diff --git a/greenfield/bun.lock b/greenfield/bun.lock new file mode 100644 index 000000000..07d265b40 --- /dev/null +++ b/greenfield/bun.lock @@ -0,0 +1,1169 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "mira-dashboard", + "dependencies": { + "@daypicker/react": "10.0.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/react": "^0.5.0", + "@dnd-kit/sortable": "^10.0.0", + "@headlessui/react": "^2.2.10", + "@microlink/react-json-view": "^1.31.28", + "@simplewebauthn/browser": "13.3.0", + "@simplewebauthn/server": "13.3.2", + "@tailwindcss/typography": "^0.5.20", + "@tanstack/db": "0.6.17", + "@tanstack/query-core": "5.101.4", + "@tanstack/query-db-collection": "1.2.1", + "@tanstack/react-db": "0.1.95", + "@tanstack/react-form": "^1.33.3", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-router": "^1.170.21", + "@tanstack/react-store": "0.11.1", + "@tanstack/react-table": "^9.0.0", + "@tanstack/react-virtual": "^3.14.9", + "@trpc/client": "11.18.0", + "@trpc/server": "11.18.0", + "@trpc/tanstack-react-query": "11.18.0", + "clsx": "^2.1.1", + "date-fns": "^4.4.0", + "drizzle-orm": "1.0.0-rc.4", + "effect": "4.0.0-beta.104", + "json5": "^2.2.3", + "lucide-react": "^1.29.0", + "otplib": "13.4.1", + "qrcode.react": "4.2.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-error-boundary": "^6.1.2", + "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^16.1.1", + "refractor": "^5.0.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", + "superjson": "2.2.6", + "tailwind-merge": "^3.6.0", + "valibot": "^1.4.2", + }, + "devDependencies": { + "@babel/core": "^8.0.1", + "@happy-dom/global-registrator": "^20.11.1", + "@tanstack/react-devtools": "^0.10.9", + "@tanstack/react-form-devtools": "^0.2.32", + "@tanstack/react-query-devtools": "^5.101.4", + "@tanstack/react-router-devtools": "1.167.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", + "@types/babel__core": "^7.20.5", + "@types/node": "26.1.2", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@types/react-syntax-highlighter": "^15.5.13", + "@valibot/to-json-schema": "1.7.1", + "babel-plugin-react-compiler": "^1.0.0", + "bun-plugin-tailwind": "^0.1.2", + "bun-types": "1.4.0-canary.20260519T150915", + "drizzle-kit": "1.0.0-rc.4", + "eventsource": "4.1.1", + "happy-dom": "^20.11.1", + "jsonc-parser": "3.3.1", + "oxfmt": "^0.62.0", + "oxlint": "^1.77.0", + "oxlint-config-presets": "^0.1.18", + "oxlint-tailwindcss": "^1.7.0", + "oxlint-tsgolint": "^7.0.2001", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + }, + }, + }, + "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + + "@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], + + "@babel/compat-data": ["@babel/compat-data@8.0.0", "", {}, "sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw=="], + + "@babel/core": ["@babel/core@8.0.1", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-compilation-targets": "^8.0.0", "@babel/helpers": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0", "@types/gensync": "^1.0.5", "convert-source-map": "^2.0.0", "empathic": "^2.0.1", "gensync": "^1.0.0-beta.2", "import-meta-resolve": "^4.2.0", "json5": "^2.2.3", "obug": "^2.1.1", "semver": "^7.7.3" } }, "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw=="], + + "@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@8.0.0", "", { "dependencies": { "@babel/compat-data": "^8.0.0", "@babel/helper-validator-option": "^8.0.0", "browserslist": "^4.24.0", "lru-cache": "^11.0.0", "semver": "^7.7.3" } }, "sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew=="], + + "@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@8.0.0", "", {}, "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q=="], + + "@babel/helpers": ["@babel/helpers@8.0.0", "", { "dependencies": { "@babel/template": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg=="], + + "@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], + + "@babel/traverse": ["@babel/traverse@8.0.4", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.4", "@babel/template": "^8.0.0", "@babel/types": "^8.0.4", "obug": "^2.1.1" } }, "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg=="], + + "@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], + + "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], + + "@daypicker/react": ["@daypicker/react@10.0.1", "", { "dependencies": { "react-day-picker": "10.0.1" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-lH4YQz4iMBWP8hsI1bD9Eg0T7t503IkSUR/WDGGkV5mKZvwVv+ukCkJz7yN+uVFBv7vHTK+ww7a5EvlkeFwPYQ=="], + + "@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="], + + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="], + + "@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="], + + "@dnd-kit/react": ["@dnd-kit/react@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-abQPLI8lmfVE+v/n+pqy5WFxrw6T2Yg0UQZsL78dp5DKci7dKTVDjvLWqvass+XTFtzJmsZEjk1NdqE6xG8Jiw=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/react": ["@floating-ui/react@0.26.28", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.2", "@floating-ui/utils": "^0.2.8", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.11.1", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.11.1" } }, "sha512-1C0wwHiyMlEdsbrJqff5ORE2PwaXBOam5rMqkZVOReAezd5nt6Tl/WGg28UAo75Ziuwchs0KaF8lvnn2oAtvJA=="], + + "@headlessui/react": ["@headlessui/react@2.2.10", "", { "dependencies": { "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.20.2", "@react-aria/interactions": "^3.25.0", "@tanstack/react-virtual": "^3.13.9", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA=="], + + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + + "@internationalized/date": ["@internationalized/date@3.12.3", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q=="], + + "@internationalized/number": ["@internationalized/number@3.6.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="], + + "@internationalized/string": ["@internationalized/string@3.2.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], + + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + + "@microlink/react-json-view": ["@microlink/react-json-view@1.31.28", "", { "dependencies": { "react-base16-styling": "~0.10.0", "react-lifecycles-compat": "~3.0.4", "react-textarea-autosize": "~8.5.9" }, "peerDependencies": { "react": ">= 15", "react-dom": ">= 15" } }, "sha512-G4lQITsTrf+yZGrP6171dCb4dGusFrrSrNMaJ6TTISx2Cfp5qYDYb2eI3DepwDkdB+HzTq2OV52m1jzc+riNuw=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@noble/hashes": ["@noble/hashes@2.3.0", "", {}, "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ=="], + + "@otplib/core": ["@otplib/core@13.4.1", "", {}, "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA=="], + + "@otplib/hotp": ["@otplib/hotp@13.4.1", "", { "dependencies": { "@otplib/core": "13.4.1", "@otplib/uri": "13.4.1" } }, "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA=="], + + "@otplib/plugin-base32-scure": ["@otplib/plugin-base32-scure@13.4.1", "", { "dependencies": { "@otplib/core": "13.4.1", "@scure/base": "^2.2.0" } }, "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA=="], + + "@otplib/plugin-crypto-noble": ["@otplib/plugin-crypto-noble@13.4.1", "", { "dependencies": { "@noble/hashes": "^2.2.0", "@otplib/core": "13.4.1" } }, "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg=="], + + "@otplib/totp": ["@otplib/totp@13.4.1", "", { "dependencies": { "@otplib/core": "13.4.1", "@otplib/hotp": "13.4.1", "@otplib/uri": "13.4.1" } }, "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ=="], + + "@otplib/uri": ["@otplib/uri@13.4.1", "", { "dependencies": { "@otplib/core": "13.4.1" } }, "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA=="], + + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="], + + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="], + + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="], + + "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="], + + "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="], + + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="], + + "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="], + + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="], + + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="], + + "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="], + + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="], + + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="], + + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="], + + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="], + + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="], + + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="], + + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.62.0", "", { "os": "android", "cpu": "arm" }, "sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.62.0", "", { "os": "android", "cpu": "arm64" }, "sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.62.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.62.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.62.0", "", { "os": "linux", "cpu": "arm" }, "sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.62.0", "", { "os": "linux", "cpu": "arm" }, "sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.62.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.62.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.62.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.62.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.62.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.62.0", "", { "os": "linux", "cpu": "x64" }, "sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.62.0", "", { "os": "none", "cpu": "arm64" }, "sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.62.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.62.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.62.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ=="], + + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2001", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w=="], + + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2001", "", { "os": "darwin", "cpu": "x64" }, "sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw=="], + + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2001", "", { "os": "linux", "cpu": "arm64" }, "sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A=="], + + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2001", "", { "os": "linux", "cpu": "x64" }, "sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ=="], + + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2001", "", { "os": "win32", "cpu": "arm64" }, "sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q=="], + + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2001", "", { "os": "win32", "cpu": "x64" }, "sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.77.0", "", { "os": "android", "cpu": "arm" }, "sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.77.0", "", { "os": "android", "cpu": "arm64" }, "sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.77.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.77.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.77.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.77.0", "", { "os": "linux", "cpu": "arm" }, "sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.77.0", "", { "os": "linux", "cpu": "arm" }, "sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.77.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.77.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.77.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.77.0", "", { "os": "linux", "cpu": "none" }, "sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.77.0", "", { "os": "linux", "cpu": "none" }, "sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.77.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.77.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.77.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.77.0", "", { "os": "none", "cpu": "arm64" }, "sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.77.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.77.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.77.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw=="], + + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA=="], + + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "@peculiar/asn1-x509-attr": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.8.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pkcs8": "^2.8.0", "@peculiar/asn1-rsa": "^2.8.0", "@peculiar/asn1-schema": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.8.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pfx": "^2.8.0", "@peculiar/asn1-pkcs8": "^2.8.0", "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "@peculiar/asn1-x509-attr": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], + + "@preact/signals-core": ["@preact/signals-core@1.14.4", "", {}, "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA=="], + + "@react-aria/focus": ["@react-aria/focus@3.22.1", "", { "dependencies": { "@swc/helpers": "^0.5.0", "react-aria": "^3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-CPxtkyrBi/HYY5P3lE/57sQ6qfa0lN8E55TOm89H0kNGv0lKt+/0zP7lWERzBjRr5IxBVrQX4gFEowBN52LPaA=="], + + "@react-aria/interactions": ["@react-aria/interactions@3.28.1", "", { "dependencies": { "@react-types/shared": "^3.34.0", "@swc/helpers": "^0.5.0", "react-aria": "^3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-Bqb+HrD5I5MHS2SKBhISYqo2SW8Y2dfzgF/Y1lIJq7xqLxheo9vzxPGEHhz+XzkgGfoqEJx8A6a3C7uiqS3HWA=="], + + "@react-types/shared": ["@react-types/shared@3.36.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag=="], + + "@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="], + + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="], + + "@simplewebauthn/server": ["@simplewebauthn/server@13.3.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ=="], + + "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.6", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw=="], + + "@solid-primitives/keyboard": ["@solid-primitives/keyboard@1.3.7", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.6", "@solid-primitives/rootless": "^1.5.4", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-558RPNYnXx4nGh537DSqAn4xMrC8iFipl/5+xzgzWoTNFst4RnUN3BOLmtDjJ0UGGoQXVMALYR3bNOHM0xnt1Q=="], + + "@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.2.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.6", "@solid-primitives/rootless": "^1.5.4", "@solid-primitives/static-store": "^0.1.4", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9Fuu/EWBeGj+atGHRJp70HKhdfalmpjwxY8a32NZixdLNmfCJ45AfhLQNr6uOzETbbiMx4iCKlTrJ8KZCHC2Ww=="], + + "@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-TOIZa1VUfVJ+9nkCcRajw3U4t9vBOP1HxX1WHNTbXq32mXwlqTvUnC4CRIilohcryBkT9u2ZkhUDSHRTaGp55g=="], + + "@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA=="], + + "@solid-primitives/utils": ["@solid-primitives/utils@6.4.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.20", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw=="], + + "@tanstack/db": ["@tanstack/db@0.6.17", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@tanstack/db-ivm": "0.1.18", "@tanstack/pacer-lite": "^0.2.1" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-/i6+dedEkOCVQbTQtCjQHx3Nqlkqe6zvi+9/JPnSgm47o+akoaPqcSwxTmesDZxc/efHokUtzeD2ocow257RdQ=="], + + "@tanstack/db-ivm": ["@tanstack/db-ivm@0.1.18", "", { "dependencies": { "fractional-indexing": "^3.2.0", "sorted-btree": "^1.8.1" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-+pZJiRKdoKRM5Epq9T7otD9ZJl82pRFauo7LKuJGrarjVKQ7r+QQlPe3kGdN9LEKSnuNGIWjX9OOY4M8kH4eLw=="], + + "@tanstack/devtools": ["@tanstack/devtools@0.13.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.8", "@tanstack/devtools-event-bus": "0.4.2", "@tanstack/devtools-ui": "0.6.0", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" }, "bin": { "intent": "./bin/intent.js" } }, "sha512-p/nOH9bS/OO/u3402zPjoGu+Mz6Fzi/iRqJuYghuuYRUY32kZt+C0/d+pP/bi6/2JTi1FdT6oEXI2lWlA5tXxw=="], + + "@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.8", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.5.0" } }, "sha512-cG3iZkGWCwN330bLBKa8+9r4Of2AXNoz2zUqcsy/4XsD3105ghVBx78cGyvJj9fSclNomPxoqAnDGXXhg1WLvA=="], + + "@tanstack/devtools-event-bus": ["@tanstack/devtools-event-bus@0.4.2", "", { "dependencies": { "ws": "^8.18.3" } }, "sha512-2LHzhwBFlKHCcklsQrGe8TeyjHd4XAF8nuCO6wHmva5fePUkJUULbu6CsCNAlGlCi0KkEsMXZSvRdR4HgMq4yA=="], + + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.4", "", { "bin": { "intent": "./bin/intent.js" } }, "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw=="], + + "@tanstack/devtools-ui": ["@tanstack/devtools-ui@0.6.0", "", { "dependencies": { "clsx": "^2.1.1", "dayjs": "^1.11.19", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-CVaM6rT6Nl5ijo83vJYFa2SjofvpuOl/uOvbYGhBrRgUhhelNHhx8zZX+hnZCHmIr0/lzM65hsocnZ72592Rvg=="], + + "@tanstack/devtools-utils": ["@tanstack/devtools-utils@0.4.0", "", { "peerDependencies": { "@types/react": ">=17.0.0", "preact": ">=10.0.0", "react": ">=17.0.0", "solid-js": ">=1.9.7", "vue": ">=3.2.0" }, "optionalPeers": ["@types/react", "preact", "react", "solid-js", "vue"], "bin": { "intent": "bin/intent.js" } }, "sha512-KsGzYhA8L/fCNgyyMyoUy+TKtx+DjNbzWwqH6wXL48Llzo7kvV9RynYJlaO8Qkzwm+NdHXSgsljQNjQ3CKPpZA=="], + + "@tanstack/form-core": ["@tanstack/form-core@1.33.3", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.11.0" } }, "sha512-htLxe/50GpUxbi2arJleh6uQkw72UOy+3Q0d1AadO3lfBTjs1e51GzyrKk/w8I7qXSkaSnF/JbYlyE+cwbJGNw=="], + + "@tanstack/form-devtools": ["@tanstack/form-devtools@0.2.32", "", { "dependencies": { "@tanstack/devtools-ui": "^0.5.1", "@tanstack/devtools-utils": "^0.4.0", "@tanstack/form-core": "1.33.3", "clsx": "^2.1.1", "dayjs": "^1.11.18", "goober": "^2.1.16" }, "peerDependencies": { "solid-js": ">=1.9.9" } }, "sha512-eJX7L7KH0nAYEExg6sWcwYT8fv0vrFUivmw0PKNnDU1hw6juLUjvur4vcaNrLLt/0edimnuIHUgfQyVEaH/jKg=="], + + "@tanstack/history": ["@tanstack/history@1.162.1", "", {}, "sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w=="], + + "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.2.2", "", {}, "sha512-eQ1MyLKCHyXiH7NbdmB80W77OhiMgGBUb+qDx/8WMGbwg5Lf/NlfD0TfNYAqY77i8V3AxoDoYdICrQE5ADw4Yw=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.101.4", "", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="], + + "@tanstack/query-db-collection": ["@tanstack/query-db-collection@1.2.1", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@tanstack/db": "0.6.17" }, "peerDependencies": { "@tanstack/query-core": "^5.0.0", "typescript": ">=4.7" } }, "sha512-2IwtxdolgPMwLoV7TKaB+1qVGU7ukulacCQqhCt1/50+x/r9sLXF2668fy6OnLd0mEkkEthzCa+J3ZbiA4kNbg=="], + + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.101.4", "", {}, "sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA=="], + + "@tanstack/react-db": ["@tanstack/react-db@0.1.95", "", { "dependencies": { "@tanstack/db": "0.6.17", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-Om2qgKtoK+iTcE3nR+MaPQsM4JUsDWB8LqFt4yFjwuSawwMJ7bcPnc7gP0V06YKOtm5MzrIu8IV/OexMJbraKQ=="], + + "@tanstack/react-devtools": ["@tanstack/react-devtools@0.10.9", "", { "dependencies": { "@tanstack/devtools": "0.13.0" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw=="], + + "@tanstack/react-form": ["@tanstack/react-form@1.33.3", "", { "dependencies": { "@tanstack/form-core": "1.33.3", "@tanstack/react-store": "^0.11.0" }, "peerDependencies": { "@tanstack/react-start": "*", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@tanstack/react-start"] }, "sha512-lkzI/y15fHC8lKvzsLXFLLqGWroa+okvV2cKRCGAL+d0Kdf040fdMZbKh6uCXDMc08Ngpl8G3VZFnZ5KVUkUIw=="], + + "@tanstack/react-form-devtools": ["@tanstack/react-form-devtools@0.2.32", "", { "dependencies": { "@tanstack/devtools-utils": "^0.4.0", "@tanstack/form-devtools": "0.2.32" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpoNvwFIlJPzTrwIV/Lw6/yr+8IqGUEqAAaBwhlZwGKWgm9YYsxcJoFxZXReL84+BM0rQbkJ2EDNzjkqb4SJ3A=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.101.4", "", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="], + + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.101.4", "", { "dependencies": { "@tanstack/query-devtools": "5.101.4" }, "peerDependencies": { "@tanstack/react-query": "^5.101.4", "react": "^18 || ^19" } }, "sha512-VeK2gtmfj7kvRBjtxS7TKxt/6qKhn8VzabY4UiYMr7NV9CddjSRYRgeYyld+NpjAkgMV9dd+2Qdr8ah5I03NeA=="], + + "@tanstack/react-router": ["@tanstack/react-router@1.170.21", "", { "dependencies": { "@tanstack/history": "1.162.1", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.18", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-uF7FhIhBvoVz0dr+JgoYr13KirUbwd7prVcdikv3ITxMsaHnfGe5FUM7wM1caufAgJg1yO71sfTNafTcefLiwg=="], + + "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.167.1", "", { "dependencies": { "@tanstack/router-devtools-core": "1.168.1" }, "peerDependencies": { "@tanstack/react-router": "^1.170.19", "@tanstack/router-core": "^1.171.16", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-pjfGrmjj4d7naEPM7oshqFfwBoxDPNo/UxltlHH5ePbHsJ+plBhd+JaAewm1ueYOjZ0js9hckjWWDYXpCrSfKw=="], + + "@tanstack/react-store": ["@tanstack/react-store@0.11.1", "", { "dependencies": { "@tanstack/store": "0.11.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ=="], + + "@tanstack/react-table": ["@tanstack/react-table@9.0.0", "", { "dependencies": { "@tanstack/react-store": "^0.11.0", "@tanstack/table-core": "9.0.0" }, "peerDependencies": { "react": ">=18" } }, "sha512-Q/Z49MQcdMwge67U+LTjSEQJCnQE9/tNWK5IpiYex9JFDzfjNkLIi7yzGA4dfW4UpuMBrtqbSnSzn7oPr60T+w=="], + + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.9", "", { "dependencies": { "@tanstack/virtual-core": "3.17.7" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ=="], + + "@tanstack/router-core": ["@tanstack/router-core@1.171.18", "", { "dependencies": { "@tanstack/history": "1.162.1", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-F91BWGqxOhDRubSaoAA3FDkzbr/WPrW6v3NcmTrI2T0AwtAL8qvY9FEDhTYwFoFiicBsRtt1NaNWKNTFNGQrqA=="], + + "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.168.1", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.171.16", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-qr4voa4cpSMwQvS3867xkU3AB3MtJbTuovKIy+btjJ/Faju6er9w0nDylmD+005Mk/3YKw9/iueZJl2JAB7JOA=="], + + "@tanstack/store": ["@tanstack/store@0.11.1", "", {}, "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA=="], + + "@tanstack/table-core": ["@tanstack/table-core@9.0.0", "", { "dependencies": { "@tanstack/store": "^0.11.0" } }, "sha512-IyKCc4D7d/+I9euQntlVDQ7lnilmFRKpIROBeI5/866adFy+65xWvCFPz76NZ5j2lXe/RFDvFVV7bfETmwHEMA=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.7", "", {}, "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11" } }, "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.3", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g=="], + + "@trpc/client": ["@trpc/client@11.18.0", "", { "peerDependencies": { "@trpc/server": "11.18.0", "typescript": ">=5.7.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow=="], + + "@trpc/server": ["@trpc/server@11.18.0", "", { "peerDependencies": { "typescript": ">=5.7.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q=="], + + "@trpc/tanstack-react-query": ["@trpc/tanstack-react-query@11.18.0", "", { "peerDependencies": { "@tanstack/react-query": "^5.80.3", "@trpc/client": "11.18.0", "@trpc/server": "11.18.0", "react": ">=18.2.0", "typescript": ">=5.7.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-dm5xIlN0SEzJAbQ34EHdb1Kgd/zWZ63ZMviQEw3WCWCIPz8MjLKAhquncPcn+YxFUJa+Qi4qatdGFymmM4HmAQ=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/gensync": ["@types/gensync@1.0.5", "", {}, "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], + + "@types/lodash": ["@types/lodash@4.17.25", "", {}, "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], + + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], + + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + + "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="], + + "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], + + "@valibot/to-json-schema": ["@valibot/to-json-schema@1.7.1", "", { "peerDependencies": { "valibot": "^1.4.0" } }, "sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA=="], + + "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], + + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + + "bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="], + + "bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="], + + "bun-types": ["bun-types@1.4.0-canary.20260519T150915", "", { "dependencies": { "@types/node": "*" } }, "sha512-Wz9GvDClQ8quNXqvtm1QgDu20/Mj4x1qtJZlnczSMVPmGtdA16P06I2kxReuRafNu9UWya+mvrLVUGtz3NWNNw=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001807", "", {}, "sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + + "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], + + "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "drizzle-kit": ["drizzle-kit@1.0.0-rc.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.12.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-KZCpjRyu+oYHLj/UJogfFlOkWhHVkaEI2EOT1U3NDVXUzLoTyPjqwFxwOrlQxsY6jzRyxcz4EacqboGfhEeYrA=="], + + "drizzle-orm": ["drizzle-orm@1.0.0-rc.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-d1": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-libsql": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-mysql2": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-pg": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-pglite": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-bun": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-do": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-node": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-wasm": ">=4.0.0-beta.83 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.6.0-pre.28 || >=0.6.0", "@tursodatabase/database-common": ">=0.6.0-pre.28 || >=0.6.0", "@tursodatabase/database-wasm": ">=0.6.0-pre.28 || >=0.6.0", "@tursodatabase/serverless": ">=1.1.3", "@tursodatabase/sync": ">=0.6.0-pre.28 || >=0.6.0", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.83 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-d1", "@effect/sql-libsql", "@effect/sql-mysql2", "@effect/sql-pg", "@effect/sql-pglite", "@effect/sql-sqlite-bun", "@effect/sql-sqlite-do", "@effect/sql-sqlite-node", "@effect/sql-sqlite-wasm", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@tursodatabase/serverless", "@tursodatabase/sync", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-BT+pf+qoiYHqltoA88Jmf6ilGMXPlpfE0hEJKc2adRtMCAl25Swk/t5gXcWxZNAwdtf3F5gCd2FpeOyP/pT0Hw=="], + + "effect": ["effect@4.0.0-beta.104", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "uuid": "^14.0.1" } }, "sha512-YSSaaMc8gBoHnabYXlgHpKVctsj4ezTSoojdd8SA3NWHoZ7LMPiUDhCnP1ZSOfQ7ly6P6XLhAw216NfLEHfg2A=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.402", "", {}, "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA=="], + + "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], + + "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "eventsource": ["eventsource@4.1.1", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w=="], + + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + + "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="], + + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + + "fractional-indexing": ["fractional-indexing@3.4.0", "", {}, "sha512-8J3glhz2rrpKG6KmI7wmJo3zH1VjeOpN+vTJSw1fOyO+Viqq3zX6/5NGh6oaZB2qIAYdOYuu5Dz9xp4faOO0Pg=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-tsconfig": ["get-tsconfig@4.14.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A=="], + + "goober": ["goober@2.1.19", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "happy-dom": ["happy-dom@20.11.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + + "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="], + + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], + + "isbot": ["isbot@5.2.1", "", {}, "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + + "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="], + + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "lucide-react": ["lucide-react@1.29.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Xs9QFG5+9sNX04MdKVT4++umA+hJ2qsJVlRlRWHQ7qZobXgMiNHSpZ5eZm8JUoGCdNyoEdXoEwa8HVr0DNjOQg=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], + + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + + "otplib": ["otplib@13.4.1", "", { "dependencies": { "@otplib/core": "13.4.1", "@otplib/hotp": "13.4.1", "@otplib/plugin-base32-scure": "13.4.1", "@otplib/plugin-crypto-noble": "13.4.1", "@otplib/totp": "13.4.1", "@otplib/uri": "13.4.1" } }, "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ=="], + + "oxfmt": ["oxfmt@0.62.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.62.0", "@oxfmt/binding-android-arm64": "0.62.0", "@oxfmt/binding-darwin-arm64": "0.62.0", "@oxfmt/binding-darwin-x64": "0.62.0", "@oxfmt/binding-freebsd-x64": "0.62.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.62.0", "@oxfmt/binding-linux-arm-musleabihf": "0.62.0", "@oxfmt/binding-linux-arm64-gnu": "0.62.0", "@oxfmt/binding-linux-arm64-musl": "0.62.0", "@oxfmt/binding-linux-ppc64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-musl": "0.62.0", "@oxfmt/binding-linux-s390x-gnu": "0.62.0", "@oxfmt/binding-linux-x64-gnu": "0.62.0", "@oxfmt/binding-linux-x64-musl": "0.62.0", "@oxfmt/binding-openharmony-arm64": "0.62.0", "@oxfmt/binding-win32-arm64-msvc": "0.62.0", "@oxfmt/binding-win32-ia32-msvc": "0.62.0", "@oxfmt/binding-win32-x64-msvc": "0.62.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ=="], + + "oxlint": ["oxlint@1.77.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.77.0", "@oxlint/binding-android-arm64": "1.77.0", "@oxlint/binding-darwin-arm64": "1.77.0", "@oxlint/binding-darwin-x64": "1.77.0", "@oxlint/binding-freebsd-x64": "1.77.0", "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", "@oxlint/binding-linux-arm-musleabihf": "1.77.0", "@oxlint/binding-linux-arm64-gnu": "1.77.0", "@oxlint/binding-linux-arm64-musl": "1.77.0", "@oxlint/binding-linux-ppc64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-musl": "1.77.0", "@oxlint/binding-linux-s390x-gnu": "1.77.0", "@oxlint/binding-linux-x64-gnu": "1.77.0", "@oxlint/binding-linux-x64-musl": "1.77.0", "@oxlint/binding-openharmony-arm64": "1.77.0", "@oxlint/binding-win32-arm64-msvc": "1.77.0", "@oxlint/binding-win32-ia32-msvc": "1.77.0", "@oxlint/binding-win32-x64-msvc": "1.77.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg=="], + + "oxlint-config-presets": ["oxlint-config-presets@0.1.18", "", { "peerDependencies": { "oxlint": ">=0.15.0" }, "optionalPeers": ["oxlint"] }, "sha512-f0Iie2XIjvAopd5kv5xuVUPspCpWy3pJVLeQ1yWfFpBl1QAkiQEcPCOOqpkUKm+k6zqWJL+PyNCI+TimnBUFvw=="], + + "oxlint-tailwindcss": ["oxlint-tailwindcss@1.7.0", "", { "dependencies": { "@tailwindcss/node": "^4.3.3", "tailwindcss": "^4.3.3" } }, "sha512-cH+Qml5tUuVgAPC88S3vbjaqj6wNw5EF8l4zsLmQx6x+wXKRGWy1+AzKi3wmLHMy9m/QXkpJHyzMsuEnLSRpQQ=="], + + "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2001", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2001", "@oxlint-tsgolint/darwin-x64": "7.0.2001", "@oxlint-tsgolint/linux-arm64": "7.0.2001", "@oxlint-tsgolint/linux-x64": "7.0.2001", "@oxlint-tsgolint/win32-arm64": "7.0.2001", "@oxlint-tsgolint/win32-x64": "7.0.2001" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + + "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], + + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + + "react-aria": ["react-aria@3.51.0", "", { "dependencies": { "@internationalized/date": "^3.12.3", "@internationalized/number": "^3.6.7", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.36.1", "@swc/helpers": "^0.5.0", "aria-hidden": "^1.2.3", "clsx": "^2.0.0", "react-stately": "3.49.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ=="], + + "react-base16-styling": ["react-base16-styling@0.10.0", "", { "dependencies": { "@types/lodash": "^4.17.0", "color": "^4.2.3", "csstype": "^3.1.3", "lodash-es": "^4.17.21" } }, "sha512-H1k2eFB6M45OaiRru3PBXkuCcn2qNmx+gzLb4a9IPMR7tMH8oBRXU5jGbPDYG1Hz+82d88ED0vjR8BmqU3pQdg=="], + + "react-day-picker": ["react-day-picker@10.0.1", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w=="], + + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + + "react-error-boundary": ["react-error-boundary@6.1.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng=="], + + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-lifecycles-compat": ["react-lifecycles-compat@3.0.4", "", {}, "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="], + + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + + "react-stately": ["react-stately@3.49.0", "", { "dependencies": { "@internationalized/date": "^3.12.3", "@internationalized/number": "^3.6.7", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.36.1", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A=="], + + "react-syntax-highlighter": ["react-syntax-highlighter@16.1.1", "", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA=="], + + "react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + + "refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], + + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="], + + "remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "seroval": ["seroval@1.6.2", "", {}, "sha512-mPT+SD2TrlB6wvte1KkYOYUkubaTbd6pZ/6Kk3C9nxzrHmCZyhxOO7XGAeL7f+yLKZglzGtM9odUVvg/EhO+vQ=="], + + "seroval-plugins": ["seroval-plugins@1.6.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-TfxuUjlbBESzUOWdTkTKqvSmav0ABym+itetDXLK6mDz8SmrpdI30aF8RTXE8Bvq+tH/1yIDkvy3W0lfQb1ipQ=="], + + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + + "solid-js": ["solid-js@1.9.14", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ=="], + + "sorted-btree": ["sorted-btree@1.8.1", "", {}, "sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], + + "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], + + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w=="], + + "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], + + "use-latest": ["use-latest@1.3.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], + + "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "ws": ["ws@8.21.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@tanstack/devtools-client/@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.5.0", "", { "bin": { "intent": "./bin/intent.js" } }, "sha512-H+OH3zC6Vhu/K0NaVfQKknEKawc/+2PT+D3SB3Ox0V8SiMlTo0abbmH2rH0721R2aNYbjdMXA1oENOd8E2UVoA=="], + + "@tanstack/form-core/@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], + + "@tanstack/form-devtools/@tanstack/devtools-ui": ["@tanstack/devtools-ui@0.5.3", "", { "dependencies": { "clsx": "^2.1.1", "dayjs": "^1.11.19", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-iJjwWtdXhUGpeHyyW9+3NhXhmlVFhh3v3UBNKCouykG9UFXEtneVVNXgSRpd70DeYJFmvKOY19LafKRI5/cM7A=="], + + "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + + "@testing-library/dom/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "@types/babel__core/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@types/babel__core/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@types/babel__generator/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@types/babel__template/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@types/babel__template/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@types/babel__traverse/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "babel-plugin-react-compiler/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "micromark-extension-frontmatter/fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "solid-js/seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="], + + "solid-js/seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="], + + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + + "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + + "@testing-library/dom/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@testing-library/dom/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@types/babel__core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@types/babel__core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@types/babel__generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@types/babel__generator/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@types/babel__template/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@types/babel__template/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@types/babel__traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@types/babel__traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "babel-plugin-react-compiler/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "babel-plugin-react-compiler/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + } +} diff --git a/greenfield/bunfig.toml b/greenfield/bunfig.toml new file mode 100644 index 000000000..6bed54f64 --- /dev/null +++ b/greenfield/bunfig.toml @@ -0,0 +1,18 @@ +[install] +peer = false + +[test] +coverageReporter = ["text", "lcov"] +coverageDir = "coverage" +coverageSkipTestFiles = true +coveragePathIgnorePatterns = [ + "scripts/**", + "src/**/*.d.ts", + "src/**/testSupport/**", + "src/server/test/**", + "src/test/**", +] + +[serve.static] +plugins = ["./scripts/reactCompilerPlugin.ts", "bun-plugin-tailwind"] +environment = "PUBLIC_*" diff --git a/greenfield/codecov.yml b/greenfield/codecov.yml new file mode 100644 index 000000000..ddb593ac7 --- /dev/null +++ b/greenfield/codecov.yml @@ -0,0 +1,37 @@ +coverage: + precision: 2 + round: down + range: 20..85 + status: + project: + dashboard: + target: 85% + threshold: 0% + informational: false + if_ci_failed: error + if_not_found: failure + flags: + - dashboard + paths: + - src/ + patch: + dashboard: + target: 85% + threshold: 0% + informational: false + if_ci_failed: error + if_not_found: failure + flags: + - dashboard + paths: + - src/ + +comment: + layout: "reach,diff,flags,tree" + behavior: default + require_changes: false + +flags: + dashboard: + paths: + - src/ diff --git a/docs/architecture/greenfield-rewrite.md b/greenfield/docs/architecture/greenfield-rewrite.md similarity index 75% rename from docs/architecture/greenfield-rewrite.md rename to greenfield/docs/architecture/greenfield-rewrite.md index 8a6ed53f9..a6f2060c8 100644 --- a/docs/architecture/greenfield-rewrite.md +++ b/greenfield/docs/architecture/greenfield-rewrite.md @@ -2,8 +2,11 @@ > **Status:** implementation active. Phase 0 evidence is complete and Phase 2 is complete for its > stated server scope; the remaining foundation, browser, domain, Gateway/chat, privileged, -> hardening, and cutover phases are incomplete. The rewrite is built beside the current -> production implementation and targets a fresh database with no compatibility layer. +> hardening, and cutover phases are incomplete. Until cutover, the rewrite is staged under +> `greenfield/` beside the current production implementation. The contents of `greenfield/` are a +> self-contained future repository root: they do not import the old implementation or depend on +> compatibility wrappers. Cutover promotes those contents to the repository root and removes the +> old tree. The application targets a fresh database with no compatibility layer. > > **Audit date:** 2026-08-06. Package versions and the Bun canary snapshot in this document > are point-in-time facts. They are rechecked during an explicit candidate-promotion round, diff --git a/docs/architecture/greenfield-rewrite/application-architecture.md b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md similarity index 95% rename from docs/architecture/greenfield-rewrite/application-architecture.md rename to greenfield/docs/architecture/greenfield-rewrite/application-architecture.md index ab1abad32..965be3929 100644 --- a/docs/architecture/greenfield-rewrite/application-architecture.md +++ b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md @@ -126,8 +126,10 @@ privileged mutation. ## Source Layout and Boundaries -Use one private Bun package and explicit source boundaries instead of publishable internal -packages: +The paths below are relative to the self-contained future repository root, currently staged as +`greenfield/`. Cutover promotes that directory's contents to the repository root; it does not +merge source trees or preserve imports to the old implementation. Use one private Bun package and +explicit source boundaries instead of publishable internal packages: ```text src/ @@ -176,20 +178,26 @@ Architectural dependency rules: imported only by Drizzle Kit or a database composition root. Domain modules import tables and validators directly. -The rewrite now has separate strict TypeScript graphs for contracts/shared, browser, server, -worker, and scripts. An authoritative Babel-AST policy check discovers JavaScript, JSX, ESM/CJS, -and TypeScript extension variants across `src`, repository scripts, and the reviewed root -configurations for Drizzle and Tailwind. It permits `.tsx` only in the strict browser graph and -`.ts` in every other scanned role, rejects unknown root executables and top-level source -directories, and requires reviewed relative extensions resolving to exact contained targets. The -same binding-aware analysis classifies every composition root, enforces the dependency directions -above, and rejects nonliteral production loads, unreviewed module schemes and aliases, runtime -environment escape paths, code/module loaders, and process-execution authorities outside their -explicit roles. Source-tree symlinks are prohibited and the temporary script edges into the legacy -tree are frozen exactly. Fast Oxlint restrictions provide earlier feedback for supported import -and global patterns; the AST check is the path-aware policy gate for the source surfaces it -explicitly scans, not a replacement for runtime sandboxing. The browser and worker graphs are ready -for their composition roots, which are not yet implemented. +TypeScript uses two compiler graphs behind one three-file solution. `tsconfig.json` owns all shared +strict compiler rules, has `files: []`, and references the browser and Bun child configurations. +Both children extend it. `tsconfig.bun.json` checks server, worker, scripts, and non-browser tests +with Bun/Node types and `ESNext` without DOM. `tsconfig.browser.json` adds only the DOM, JSX, narrow +type declarations, browser source, and browser-test membership it needs. There is no +server-specific config or per-role configuration proliferation. + +An authoritative Babel-AST policy check discovers JavaScript, JSX, ESM/CJS, and TypeScript +extension variants across `src`, repository scripts, and the reviewed root configurations for +Drizzle and Tailwind. It permits `.tsx` only in the strict browser graph and `.ts` in every other +scanned role, rejects unknown root executables and top-level source directories, and requires +reviewed relative extensions resolving to exact contained targets. The same binding-aware analysis +classifies every composition root, enforces the dependency directions above, and rejects +nonliteral production loads, unreviewed module schemes and aliases, runtime environment escape +paths, code/module loaders, and process-execution authorities outside their explicit roles. +Source-tree symlinks and imports outside the future root are prohibited. Fast Oxlint restrictions +provide earlier feedback for supported import and global patterns; the AST check is the path-aware +policy gate for the source surfaces it explicitly scans, not a replacement for runtime sandboxing. +The browser and worker roles are already classified even where their composition remains future +work. ## Application API diff --git a/docs/architecture/greenfield-rewrite/data-and-security.md b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md similarity index 100% rename from docs/architecture/greenfield-rewrite/data-and-security.md rename to greenfield/docs/architecture/greenfield-rewrite/data-and-security.md diff --git a/docs/architecture/greenfield-rewrite/implementation-plan.md b/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md similarity index 98% rename from docs/architecture/greenfield-rewrite/implementation-plan.md rename to greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md index a6039251e..16b4ada43 100644 --- a/docs/architecture/greenfield-rewrite/implementation-plan.md +++ b/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md @@ -191,9 +191,9 @@ not package memory alone: ### Effect - [Effect-oriented coding-agent workflow](https://www.effect.website/blog/the-one-weird-git-trick-that-makes-coding-agents-more-effect-ive) -- [Effect 4 migration and beta API map](https://github.com/Effect-TS/effect/blob/effect%404.0.0-beta.103/MIGRATION.md) -- [Scoped `acquireRelease` resources](https://github.com/Effect-TS/effect/blob/effect%404.0.0-beta.103/ai-docs/src/01_effect/05_resources/10_acquire-release.ts) -- [Schema-backed tagged errors and `catchTags`](https://github.com/Effect-TS/effect/blob/effect%404.0.0-beta.103/ai-docs/src/01_effect/04_errors/10_catch-tags.ts) +- [Effect 4 migration and beta API map](https://github.com/Effect-TS/effect/blob/effect%404.0.0-beta.104/MIGRATION.md) +- [Scoped `acquireRelease` resources](https://github.com/Effect-TS/effect/blob/effect%404.0.0-beta.104/ai-docs/src/01_effect/05_resources/10_acquire-release.ts) +- [Schema-backed tagged errors and `catchTags`](https://github.com/Effect-TS/effect/blob/effect%404.0.0-beta.104/ai-docs/src/01_effect/04_errors/10_catch-tags.ts) ### React and TanStack diff --git a/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md similarity index 96% rename from docs/architecture/greenfield-rewrite/progress.md rename to greenfield/docs/architecture/greenfield-rewrite/progress.md index 41e6fc2a1..5d2baf17e 100644 --- a/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -38,12 +38,12 @@ closes a phase; dated entries below provide the evidence, not a second status so while the VPS is already saturated. - The rewrite dependency baseline is installed in the worktree: tRPC `11.18.0`, Drizzle ORM/Kit `1.0.0-rc.4`, `@valibot/to-json-schema` `1.7.1`, and the Bun-compatible - `eventsource` `4.1.0` test ponyfill. `@valibot/to-json-schema` is build-time documentation + `eventsource` `4.1.1` test ponyfill. `@valibot/to-json-schema` is build-time documentation tooling, not part of tRPC validation. Bun 1.4 does not expose a global `EventSource`, so the ponyfill is test-only; browsers use their native implementation. Both packages are development dependencies and stay out of the production runtime and browser bundle. - The same install refreshed `oxlint-config-presets` to `0.1.18` and - `@microlink/react-json-view` to `1.31.26`. The lockfile was regenerated with the qualified + `@microlink/react-json-view` to `1.31.28`. The lockfile was regenerated with the qualified Bun canary. No production service or release was changed. - The executable qualification suite passes on the exact candidate revision: twelve Bun tests across runtime identity, Drizzle/Bun SQLite, and tRPC Fetch/SSE. The database evidence @@ -201,7 +201,7 @@ closes a phase; dated entries below provide the evidence, not a second status so objects or bytes already accepted by tRPC transforms, Bun HTTP/Fetch, TLS, the proxy, or kernel buffers; the cgroup is the hard safety boundary for those layers, and process/cgroup observations measure their combined cost. The sustained probe is an explicit - local command and is not part of ordinary hosted CI or the general qualification test command. + local command and is not part of ordinary hosted CI or the general integration test command. Pure parsers, policy checks, feed limits, evidence rules, launcher construction, and one bounded native-socket mechanism test remain deterministic CI tests. - Review hardening now rejects every signal-terminated launcher instead of treating `exited` as a @@ -598,20 +598,24 @@ closes a phase; dated entries below provide the evidence, not a second status so ### 2026-08-06 — Phase 0 evidence and qualification closed - Bun `1.4.0-canary.1+17d684360`, full revision - `17d6843606d76620cb55d31424d7fb0aed51c367`, passes qualification typecheck and the complete - qualification suite: 151 tests, 758 assertions, zero failures, and 31 files. This is the exact - audited candidate for the round, not a repository-wide source-revision pin. + `17d6843606d76620cb55d31424d7fb0aed51c367`, passed the then-current dedicated qualification + typecheck and suite: 151 tests, 758 assertions, zero failures, and 31 files. This is a historical + result for the exact audited candidate, not a repository-wide source-revision pin or a current + directory layout. Retained mechanisms have since moved into the normal integration, parity, and + audit structure. - The selected frontend path is one compiler-first Bun HTML AOT build. Executable fixture and actual-build evidence cover Tailwind, lazy chunks, fail-closed inline event/style/base and URL-bearing attribute CSP policy, hashes, precompression, absent production source maps, and - bundle budgets. The exact-pinned TanStack DB adapter covers snapshot replacement, direct batch - writes, query-cache synchronization, optimistic conflicts, cancellation, and - route-subscription teardown. + bundle budgets. The exact-pinned TanStack DB adapter result remains historical candidate + evidence; its provisional adapter and dependencies were not retained. The browser data-layer + slice must qualify snapshot replacement, batch writes, cache synchronization, optimistic + conflicts, cancellation, and route teardown against the real implementation. - File-backed WAL evidence uses separate web and worker processes and covers reader/writer and writer/writer behavior, observed busy/locked classification, no-gap/no-duplicate outbox delivery, hard-kill claim recovery, savepoints, prepared-statement disposal, checkpoint, backup, restore, - and integrity. Chat qualification selects 150 ms ordered token/thinking batches for one, four, - and eight concurrent runs, with immediate tool/item, terminal, cancel, and completion flushes. + and integrity. The 150 ms ordered chat-delta batching result is also historical candidate + evidence rather than retained executable coverage. The chat slice must re-qualify batching and + immediate tool/item, terminal, cancel, and completion flushes against its production path. Source inputs are read through held no-follow descriptors with deterministic shrink, growth, overwrite, and requested-path replacement rejection. - Raw RFC 6455 tests cover continuation reassembly, a UTF-8 code point split across three frames, @@ -665,16 +669,18 @@ closes a phase; dated entries below provide the evidence, not a second status so unreviewed URL schemes, repository escapes, source-tree symlinks, test imports, forbidden cross-process directions, and binding-aware environment, module-loader, code-evaluation, or process-execution authority outside its explicit role. This is source-policy enforcement rather - than a runtime sandbox. Coexistence scripts retain only their reviewed authorities and explicit - environment reads. -- The only script imports into the legacy backend/frontend are frozen as an exact 18-edge - coexistence allowlist. New legacy edges fail CI. -- Strict TypeScript graphs now isolate contracts/shared, browser, server, worker, and scripts and - are checked independently rather than exposed as incomplete composite project references. A - broad root compatibility graph supplies repository-wide type-aware Oxlint; supported Oxlint - restricted-import/global rules provide a fast guard, while the AST checker and the separate - TypeScript graphs are authoritative. The server-foundation job runs both checker tests and every - greenfield typecheck. + than a runtime sandbox. The isolated future root now rejects imports outside itself; no import + allowance into the old backend or frontend exists. +- TypeScript now uses exactly three configuration files and two child compiler graphs. + `tsconfig.json` owns the strict shared rules, has `files: []`, and references the browser and Bun + configs; both children extend it. The Bun child checks server, worker, scripts, and non-browser + tests with Bun/Node types and no DOM. The browser child adds DOM/JSX and explicit browser + source/test membership. There is no server config or per-role configuration proliferation. + Supported Oxlint restricted-import/global rules provide a fast guard, while the AST checker and + the TypeScript solution are authoritative. +- `greenfield/` is a self-contained future repository root. Cutover promotes its contents to the + repository root and deletes the old implementation rather than merging the source trees or + preserving compatibility code. ### 2026-08-06 — Typed configuration, errors, and observability boundary diff --git a/docs/architecture/greenfield-rewrite/runtime-and-delivery.md b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md similarity index 87% rename from docs/architecture/greenfield-rewrite/runtime-and-delivery.md rename to greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md index 285f89f16..27bdb590d 100644 --- a/docs/architecture/greenfield-rewrite/runtime-and-delivery.md +++ b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md @@ -58,10 +58,11 @@ environment against the exact candidate binary: 6. `bun test --isolate` tests for fake timers, leaked handles, deterministic shutdown, and bounded concurrency. -The 2026-08-06 qualification round passes on exact revision -`17d6843606d76620cb55d31424d7fb0aed51c367`: qualification typecheck passes, and the full suite -reports 151 tests, 758 assertions, and zero failures across 31 files. Its executable evidence -includes: +The historical 2026-08-06 Phase 0 qualification round passed on exact revision +`17d6843606d76620cb55d31424d7fb0aed51c367`: its then-current dedicated typecheck and full evidence +suite reported 151 tests, 758 assertions, and zero failures across 31 files. Most retained +mechanism evidence now lives in the normal test and audit structure rather than a top-level +qualification tree. It includes: - compiler-first Bun HTML AOT output with Tailwind, lazy chunks, fail-closed inline-code and URL-bearing-attribute CSP checks, hashes, precompression, no production source maps, and @@ -72,16 +73,17 @@ includes: attempt without reconnect; - WAL SQLite with separate web and worker processes, actual busy/locked behavior, durable outbox delivery and lease recovery, statement disposal, checkpoint, backup, restore, and integrity; -- the exact-pinned TanStack DB adapter, snapshot/cache synchronization, batch writes, optimistic - conflict handling, cancellation, and route-subscription teardown; -- 150 ms chat-delta batching for one, four, and eight concurrent runs with immediate boundary and - terminal flushes; - a two-generation shutdown with readiness withdrawal, SSE and Gateway closure, statement and database disposal, bounded non-cooperative stream cancellation, worker-lease recovery, child-process-group cleanup, WAL recovery, and no leaked process; and - source-derived parity for 156 current HTTP operations plus `/ws`, together with 23 hash-pinned, redacted OpenClaw protocol and Control UI audit artifacts. +The exact-pinned TanStack DB adapter result and the 150 ms chat-delta batching result are historical +candidate evidence only; their Phase 0 spikes and provisional dependencies were deliberately not +retained as product code. The browser data layer and chat implementation slices must qualify those +behaviors again against their real production implementations before relying on either decision. + The candidate intentionally makes `server.stop(false)` wait for idle keep-alive connections. The shutdown qualification therefore uses an Effect-scoped graceful-stop fiber with a bounded wait and a separately bounded `server.stop(true)` escalation. The exact candidate records @@ -145,9 +147,9 @@ build path: releases contain prebuilt assets and production never compiles the f The greenfield web configuration parser accepts only its registered-key projection. The future web and worker composition roots must invoke their role-specific parser exactly once; that startup wiring is not implemented by this slice. App, server, and worker source has no scattered -runtime-environment reads and no truthy-string parsing. Existing repository scripts remain a -separately typed coexistence surface and retain their explicit environment reads until their target -composition flows replace them. +runtime-environment reads and no truthy-string parsing. Repository scripts are greenfield-owned +tools checked by the Bun graph and source-boundary policy; they do not import code outside the +self-contained future root. Every registered field declares: - name, type, allowed values, and default; @@ -167,14 +169,15 @@ field is only a lexically normalized absolute staging value: the future process resolve its real path and enforce the managed-filesystem containment policy before opening host paths. Startup wiring and that filesystem validation are not claimed by this slice. -The target repository uses a base TypeScript configuration plus separate strict browser, -contracts/shared, server, worker, and script configurations so browser libraries are unavailable -to server code and Bun/filesystem types are unavailable to browser code. These are independently -checked with `tsc -p`; they are deliberately not advertised as declaration-emitting composite -project references. The root configuration is a broad compatibility graph for repository-wide -type-aware Oxlint only, while the separate graphs and authoritative path-aware boundary check own -ambient authority and import policy. Browser and worker composition roots remain unimplemented, -but adding an unclassified `src/app` root or a forbidden edge fails the boundary gate. +The target repository has exactly three TypeScript configuration files. `tsconfig.json` owns all +shared strict compiler options, has `files: []`, and references only `tsconfig.browser.json` and +`tsconfig.bun.json`. Both child configurations extend it. The Bun child adds `ESNext`, Bun/Node +types, catch-all membership, and browser-path exclusions for server, worker, scripts, and +non-browser tests. The browser child adds DOM/DOM iterable libraries, JSX, narrow type declarations, +and explicit browser source/test membership. There is no server-specific configuration or +per-role configuration proliferation. The two child graphs are checked independently; the root +solution checks both together, while the path-aware boundary gate owns finer runtime/import +authority. Adding an unclassified composition root or forbidden edge fails that gate. `bunfig.toml` contains only shared Bun test and selected serve-plugin configuration; operational policy lives in typed source, not hidden shell environment. @@ -327,17 +330,18 @@ Additional safeguards: ### Required scripts -The exact naming may change. This is the **target** Bun command-role inventory, not a claim that -the current `package.json` already exposes every alias. Today the rewrite uses separate strict -browser, contracts/shared, server, worker, scripts, and qualification typecheck commands plus the -existing frontend/backend lanes. `check:boundaries` and `test:boundaries` are required by the -server-foundation CI lane. A single top-level target `typecheck` alias remains future command -consolidation, not missing boundary enforcement. +The exact naming may change as product areas arrive. The future-root package exposes one +`typecheck` gate over the root solution; focused browser and Bun child checks may remain as +developer aliases. There are no per-domain, server, worker, script, or qualification TypeScript +projects. `check:boundaries` and `test:boundaries` enforce the finer source roles. Retained Phase 0 +mechanisms run through the ordinary integration, parity, or audit suites. Product and +cross-process integration tests live under `src/`; a focused test of a repository script may +remain colocated with that script. ```text dev local Bun server + worker + frontend development build deterministic browser and server/worker artifacts -typecheck target project-reference partitions, no emit (future cutover gate) +typecheck root TypeScript solution (browser + Bun), no emit lint fast oxlint rules lint:typed oxlint type-aware rules in a separately budgeted process format / format:check oxfmt @@ -348,6 +352,7 @@ test:realtime SSE/outbox/reconnect/race/backpressure tests test:frontend Happy DOM + Testing Library behavior tests test:integration Bun server/worker/Gateway fixture tests test:parity named current-feature acceptance suite +test:coverage all tests + 85% executable-source line gate and LCOV docs:generate/check deterministic generated documentation verify sequential local gate with explicit resource caps ``` @@ -385,7 +390,7 @@ boundary check unless an evaluated Oxc type-check mode proves equivalent for thi Hosted CI may parallelize independent jobs within runner limits. On the VPS, `verify` is sequential and capped; deployment runs only lightweight artifact, schema-copy, and readiness checks. Every pull request and `main` run resolves the selected Canary channel and executes the -qualification job before a release can be promoted. +required future-root test and evidence gates before a release can be promoted. ## Deployment and Runtime Layout @@ -393,6 +398,14 @@ Keep the host-native deployment. Dashboard needs controlled access to systemd, l Docker, OpenClaw, Git worktrees, and host databases; putting the application itself in a container would add mounts and privilege plumbing without isolating the important child jobs. +The future repository root must ship new `systemd/` web and worker units as part of the delivery +slice. The legacy units are deliberately not copied into `greenfield/`: they change into a +`backend` working directory, execute legacy `dist/*Start.js` entrypoints through the legacy +release wrapper, and retain pre-measurement multi-gigabyte limits. Add the replacement units only +after the rewritten executable roots and immutable release wrapper exist, then validate every +referenced path and the measured resource limits in CI. Until then, the absence of +`greenfield/systemd/` is an explicit incomplete delivery item rather than a compatibility link. + Recommended layout: ```text @@ -448,7 +461,7 @@ against an arbitrary schema is forbidden. | `@valibot/to-json-schema` | 1.7.1 | generated contract JSON Schema | | `drizzle-orm` | 1.0.0-rc.4 candidate | typed Bun SQLite schema/query layer and Valibot integration | | `drizzle-kit` | 1.0.0-rc.4 candidate | reviewed SQL migration generation from the schema | -| `effect` | 4.0.0-beta.103 | server typed errors, cancellation, schedules, and scoped resources | +| `effect` | 4.0.0-beta.104 | server typed errors, cancellation, schedules, and scoped resources | | `superjson` | 2.2.6 | symmetric tRPC transformer for deliberately richer API types | ### Keep as architectural dependencies @@ -462,16 +475,19 @@ against an arbitrary schema is forbidden. - `oxlint`, `oxlint-tsgolint`, the selected Oxc plugins/presets, and `oxfmt`; - Testing Library and Happy DOM under `bun test`; - Markdown/GFM/sanitization packages; and -- small UI packages that have a verified import, accessible behavior, and acceptable bundle - cost. +- the existing Dashboard date-picker, DnD, headless-component, JSON-view, icon, QR, + error-boundary, and class-composition packages needed by the planned parity surface. TanStack DB is exact-pinned and accessed through a narrow local adapter because its current version is pre-1.0. This is not a compatibility wrapper: it isolates a volatile dependency from domain code. +The retained browser packages are a future-root dependency baseline, not permission to reproduce +legacy components. Each browser slice must still verify its imports, accessible behavior, and +bundle cost, and remove packages it replaces or does not adopt. + ### Remove or do not introduce -- `@dnd-kit/react`, which has no current code import; keep only the used DnD packages; - handwritten REST client types and the browser `/ws` protocol/client; - JWT session/access tokens, Axios, or `dotenv`; opaque revocable validators, native `fetch`, and composition-root configuration parsing already own those concerns; diff --git a/greenfield/docs/development/testing-and-prs.md b/greenfield/docs/development/testing-and-prs.md new file mode 100644 index 000000000..c2a7a5e6f --- /dev/null +++ b/greenfield/docs/development/testing-and-prs.md @@ -0,0 +1,87 @@ +# Testing and Pull Requests + +## Standard gates + +Before cutover, run these commands from `greenfield/`. That directory is the self-contained +future repository root; after cutover, the same commands run from the repository root without a +compatibility wrapper or path translation: + +```bash +bun run check:boundaries +bun run typecheck +bun run lint +bun run format:check +bun run test +bun run test:coverage +bun run docs:check +bun run db:check +git diff --check +``` + +Use focused Bun tests while iterating, then run the full affected suite before handoff. The +coverage gate requires at least 85% aggregate production line coverage, rejects executable +`src/` modules missing entirely from LCOV, and publishes the same report for Codecov's 85% patch +gate. + +## TypeScript graphs + +The project has exactly three TypeScript configurations in one solution: + +- `tsconfig.json` owns every shared strict compiler rule. It has `files: []` and references only + `tsconfig.browser.json` and `tsconfig.bun.json`, making it the conventional solution entry point + for editors and `tsc -b` without claiming source files itself. +- `tsconfig.bun.json` extends the root rules and checks server, worker, repository scripts, + non-browser tests, and non-browser test support. Its catch-all membership excludes the browser + paths. It adds Bun and Node types with `ESNext` only, so DOM globals are unavailable. +- `tsconfig.browser.json` also extends the root rules and owns React/browser source and browser + tests. It adds DOM/DOM iterable libraries, JSX, its narrow type declarations, and its explicit + browser membership without exposing Bun or Node ambient types to production browser code. + +There is no `tsconfig.server.json` or per-role configuration proliferation. Runtime and import +authority inside the two referenced compiler graphs is enforced by the path-aware source-boundary +policy. + +Browser tests are checked by the browser graph with DOM/JSX and the narrow `bun:test` declaration. +All remaining tests are included by the Bun graph. Every `*.test.ts(x)`, `*.spec.ts(x)`, +`__tests__/`, and `testSupport/` file must therefore remain type-checked. + +## Test ownership + +Keep a module's tests beside that module. If one production module needs multiple concern-focused +suites, use `.test.ts` rather than creating an omnibus suite. + +- Put reusable executable helpers in the owning module's `testSupport/` directory. +- Put genuinely cross-domain server harnesses in `src/server/test/support/`. +- Reserve `fixtures/` for immutable payloads and reviewed evidence. +- Put cross-module contracts in `src/server/test/contracts/`. +- Put composition-root behavior in `src/server/test/system/`. +- Keep executable repository audits and tools under `scripts/`. Tests that directly verify one + such script may remain colocated under `scripts/`; application, transport, and cross-process + integration tests belong under the appropriate `src/test/` owner instead. + +Production source must never import test or test-support code. Prefer event- or dependency-driven +test timing over arbitrary sleeps. Test-only overrides must preserve the production default and +exercise the same runtime path. + +Every package test command runs through `scripts/runTestSuite.ts`. It preserves Bun's failure code +and additionally fails an otherwise green suite when output contains a React missing-`act(...)` +warning, an unconfigured React act environment warning, or a Bun panic/crash banner. Do not bypass +that runner in repository test scripts. + +## Lint and boundaries + +Oxlint applies its baseline strict rules to tests as well as production source. Some +production-only restrictions deliberately exclude tests—for example, tests may import +`bun:test`, fixtures, or test support—but tests are not globally ignored. + +The source-boundary checker scans `src/` and `scripts/`, including test files. It rejects +repository escapes, undeclared packages, environment-authority violations, and imports that break +the reviewed process architecture. + +## Pull-request evidence + +Document the focused regression tests and all gates run. For visible browser behavior, include a +short manual smoke result or screenshot when a layout engine or browser API cannot be represented +faithfully by the Bun test environment. Explain any gate that could not be run. + +Never commit secrets, tokens, private keys, production data, database files, or runtime state. diff --git a/docs/generated/README.md b/greenfield/docs/generated/README.md similarity index 100% rename from docs/generated/README.md rename to greenfield/docs/generated/README.md diff --git a/docs/generated/configuration.md b/greenfield/docs/generated/configuration.md similarity index 100% rename from docs/generated/configuration.md rename to greenfield/docs/generated/configuration.md diff --git a/docs/generated/packages-and-runtime.md b/greenfield/docs/generated/packages-and-runtime.md similarity index 86% rename from docs/generated/packages-and-runtime.md rename to greenfield/docs/generated/packages-and-runtime.md index 6cabec584..6ed466413 100644 --- a/docs/generated/packages-and-runtime.md +++ b/greenfield/docs/generated/packages-and-runtime.md @@ -19,7 +19,7 @@ | `@dnd-kit/react` | `^0.5.0` | `0.5.0` | runtime | | `@dnd-kit/sortable` | `^10.0.0` | `10.0.0` | runtime | | `@headlessui/react` | `^2.2.10` | `2.2.10` | runtime | -| `@microlink/react-json-view` | `^1.31.26` | `1.31.26` | runtime | +| `@microlink/react-json-view` | `^1.31.28` | `1.31.28` | runtime | | `@simplewebauthn/browser` | `13.3.0` | `13.3.0` | runtime | | `@simplewebauthn/server` | `13.3.2` | `13.3.2` | runtime | | `@tailwindcss/typography` | `^0.5.20` | `0.5.20` | runtime | @@ -29,9 +29,9 @@ | `@tanstack/react-db` | `0.1.95` | `0.1.95` | runtime | | `@tanstack/react-form` | `^1.33.3` | `1.33.3` | runtime | | `@tanstack/react-query` | `^5.101.4` | `5.101.4` | runtime | -| `@tanstack/react-router` | `^1.170.18` | `1.170.18` | runtime | -| `@tanstack/react-store` | `^0.11.0` | `0.11.0` | runtime | -| `@tanstack/react-table` | `^8.21.3` | `8.21.3` | runtime | +| `@tanstack/react-router` | `^1.170.21` | `1.170.21` | runtime | +| `@tanstack/react-store` | `0.11.1` | `0.11.1` | runtime | +| `@tanstack/react-table` | `^9.0.0` | `9.0.0` | runtime | | `@tanstack/react-virtual` | `^3.14.9` | `3.14.9` | runtime | | `@trpc/client` | `11.18.0` | `11.18.0` | runtime | | `@trpc/server` | `11.18.0` | `11.18.0` | runtime | @@ -39,9 +39,9 @@ | `clsx` | `^2.1.1` | `2.1.1` | runtime | | `date-fns` | `^4.4.0` | `4.4.0` | runtime | | `drizzle-orm` | `1.0.0-rc.4` | `1.0.0-rc.4` | runtime | -| `effect` | `4.0.0-beta.103` | `4.0.0-beta.103` | runtime | +| `effect` | `4.0.0-beta.104` | `4.0.0-beta.104` | runtime | | `json5` | `^2.2.3` | `2.2.3` | runtime | -| `lucide-react` | `^1.28.0` | `1.28.0` | runtime | +| `lucide-react` | `^1.29.0` | `1.29.0` | runtime | | `otplib` | `13.4.1` | `13.4.1` | runtime | | `qrcode.react` | `4.2.0` | `4.2.0` | runtime | | `react` | `^19.2.8` | `19.2.8` | runtime | @@ -62,11 +62,11 @@ | `@tanstack/react-devtools` | `^0.10.9` | `0.10.9` | development | | `@tanstack/react-form-devtools` | `^0.2.32` | `0.2.32` | development | | `@tanstack/react-query-devtools` | `^5.101.4` | `5.101.4` | development | -| `@tanstack/react-router-devtools` | `1.167.0` | `1.167.0` | development | +| `@tanstack/react-router-devtools` | `1.167.1` | `1.167.1` | development | | `@testing-library/dom` | `^10.4.1` | `10.4.1` | development | | `@testing-library/jest-dom` | `^7.0.0` | `7.0.0` | development | | `@testing-library/react` | `^16.3.2` | `16.3.2` | development | -| `@testing-library/user-event` | `^14.6.1` | `14.6.1` | development | +| `@testing-library/user-event` | `^14.6.3` | `14.6.3` | development | | `@types/babel__core` | `^7.20.5` | `7.20.5` | development | | `@types/node` | `26.1.2` | `26.1.2` | development | | `@types/react` | `^19.2.18` | `19.2.18` | development | @@ -77,13 +77,13 @@ | `bun-plugin-tailwind` | `^0.1.2` | `0.1.2` | development | | `bun-types` | `1.4.0-canary.20260519T150915` | `1.4.0-canary.20260519T150915` | development | | `drizzle-kit` | `1.0.0-rc.4` | `1.0.0-rc.4` | development | -| `eventsource` | `4.1.0` | `4.1.0` | development | +| `eventsource` | `4.1.1` | `4.1.1` | development | | `happy-dom` | `^20.11.1` | `20.11.1` | development | | `jsonc-parser` | `3.3.1` | `3.3.1` | development | | `oxfmt` | `^0.62.0` | `0.62.0` | development | | `oxlint` | `^1.77.0` | `1.77.0` | development | | `oxlint-config-presets` | `^0.1.18` | `0.1.18` | development | -| `oxlint-tailwindcss` | `^1.6.0` | `1.6.0` | development | +| `oxlint-tailwindcss` | `^1.7.0` | `1.7.0` | development | | `oxlint-tsgolint` | `^7.0.2001` | `7.0.2001` | development | | `tailwindcss` | `^4.3.3` | `4.3.3` | development | | `typescript` | `^7.0.2` | `7.0.2` | development | diff --git a/docs/generated/procedures.md b/greenfield/docs/generated/procedures.md similarity index 100% rename from docs/generated/procedures.md rename to greenfield/docs/generated/procedures.md diff --git a/docs/generated/raw-http.md b/greenfield/docs/generated/raw-http.md similarity index 100% rename from docs/generated/raw-http.md rename to greenfield/docs/generated/raw-http.md diff --git a/docs/generated/realtime-events.md b/greenfield/docs/generated/realtime-events.md similarity index 100% rename from docs/generated/realtime-events.md rename to greenfield/docs/generated/realtime-events.md diff --git a/docs/generated/schemas/accountSecurity.beginTotpEnrollment.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.beginTotpEnrollment.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.beginTotpEnrollment.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.beginTotpEnrollment.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.beginTotpEnrollment.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.beginTotpEnrollment.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.beginTotpEnrollment.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.beginTotpEnrollment.output.schema.json diff --git a/docs/generated/schemas/accountSecurity.beginWebAuthnEnrollment.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.beginWebAuthnEnrollment.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.beginWebAuthnEnrollment.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.beginWebAuthnEnrollment.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.beginWebAuthnEnrollment.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.beginWebAuthnEnrollment.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.beginWebAuthnEnrollment.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.beginWebAuthnEnrollment.output.schema.json diff --git a/docs/generated/schemas/accountSecurity.beginWebAuthnStepUp.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.beginWebAuthnStepUp.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.beginWebAuthnStepUp.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.beginWebAuthnStepUp.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.beginWebAuthnStepUp.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.beginWebAuthnStepUp.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.beginWebAuthnStepUp.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.beginWebAuthnStepUp.output.schema.json diff --git a/docs/generated/schemas/accountSecurity.confirmTotpEnrollment.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.confirmTotpEnrollment.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.confirmTotpEnrollment.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.confirmTotpEnrollment.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.confirmTotpEnrollment.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.confirmTotpEnrollment.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.confirmTotpEnrollment.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.confirmTotpEnrollment.output.schema.json diff --git a/docs/generated/schemas/accountSecurity.confirmWebAuthnEnrollment.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.confirmWebAuthnEnrollment.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.confirmWebAuthnEnrollment.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.confirmWebAuthnEnrollment.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.confirmWebAuthnEnrollment.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.confirmWebAuthnEnrollment.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.confirmWebAuthnEnrollment.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.confirmWebAuthnEnrollment.output.schema.json diff --git a/docs/generated/schemas/accountSecurity.disableMfa.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.disableMfa.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.disableMfa.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.disableMfa.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.disableMfa.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.disableMfa.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.disableMfa.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.disableMfa.output.schema.json diff --git a/docs/generated/schemas/accountSecurity.reauthenticatePassword.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.reauthenticatePassword.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.reauthenticatePassword.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.reauthenticatePassword.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.reauthenticatePassword.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.reauthenticatePassword.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.reauthenticatePassword.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.reauthenticatePassword.output.schema.json diff --git a/docs/generated/schemas/accountSecurity.removeTotpFactor.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.removeTotpFactor.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.removeTotpFactor.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.removeTotpFactor.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.removeTotpFactor.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.removeTotpFactor.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.removeTotpFactor.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.removeTotpFactor.output.schema.json diff --git a/docs/generated/schemas/accountSecurity.removeWebAuthnCredential.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.removeWebAuthnCredential.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.removeWebAuthnCredential.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.removeWebAuthnCredential.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.removeWebAuthnCredential.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.removeWebAuthnCredential.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.removeWebAuthnCredential.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.removeWebAuthnCredential.output.schema.json diff --git a/docs/generated/schemas/accountSecurity.rotateRecoveryCodes.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.rotateRecoveryCodes.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.rotateRecoveryCodes.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.rotateRecoveryCodes.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.rotateRecoveryCodes.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.rotateRecoveryCodes.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.rotateRecoveryCodes.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.rotateRecoveryCodes.output.schema.json diff --git a/docs/generated/schemas/accountSecurity.stepUpRecovery.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.stepUpRecovery.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.stepUpRecovery.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.stepUpRecovery.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.stepUpRecovery.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.stepUpRecovery.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.stepUpRecovery.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.stepUpRecovery.output.schema.json diff --git a/docs/generated/schemas/accountSecurity.stepUpTotp.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.stepUpTotp.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.stepUpTotp.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.stepUpTotp.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.stepUpTotp.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.stepUpTotp.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.stepUpTotp.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.stepUpTotp.output.schema.json diff --git a/docs/generated/schemas/accountSecurity.stepUpWebAuthn.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.stepUpWebAuthn.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.stepUpWebAuthn.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.stepUpWebAuthn.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.stepUpWebAuthn.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.stepUpWebAuthn.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.stepUpWebAuthn.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.stepUpWebAuthn.output.schema.json diff --git a/docs/generated/schemas/accountSecurity.summary.input.schema.json b/greenfield/docs/generated/schemas/accountSecurity.summary.input.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.summary.input.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.summary.input.schema.json diff --git a/docs/generated/schemas/accountSecurity.summary.output.schema.json b/greenfield/docs/generated/schemas/accountSecurity.summary.output.schema.json similarity index 100% rename from docs/generated/schemas/accountSecurity.summary.output.schema.json rename to greenfield/docs/generated/schemas/accountSecurity.summary.output.schema.json diff --git a/docs/generated/schemas/auth.beginWebAuthnLogin.input.schema.json b/greenfield/docs/generated/schemas/auth.beginWebAuthnLogin.input.schema.json similarity index 100% rename from docs/generated/schemas/auth.beginWebAuthnLogin.input.schema.json rename to greenfield/docs/generated/schemas/auth.beginWebAuthnLogin.input.schema.json diff --git a/docs/generated/schemas/auth.beginWebAuthnLogin.output.schema.json b/greenfield/docs/generated/schemas/auth.beginWebAuthnLogin.output.schema.json similarity index 100% rename from docs/generated/schemas/auth.beginWebAuthnLogin.output.schema.json rename to greenfield/docs/generated/schemas/auth.beginWebAuthnLogin.output.schema.json diff --git a/docs/generated/schemas/auth.bootstrap.input.schema.json b/greenfield/docs/generated/schemas/auth.bootstrap.input.schema.json similarity index 100% rename from docs/generated/schemas/auth.bootstrap.input.schema.json rename to greenfield/docs/generated/schemas/auth.bootstrap.input.schema.json diff --git a/docs/generated/schemas/auth.bootstrap.output.schema.json b/greenfield/docs/generated/schemas/auth.bootstrap.output.schema.json similarity index 100% rename from docs/generated/schemas/auth.bootstrap.output.schema.json rename to greenfield/docs/generated/schemas/auth.bootstrap.output.schema.json diff --git a/docs/generated/schemas/auth.changePassword.input.schema.json b/greenfield/docs/generated/schemas/auth.changePassword.input.schema.json similarity index 100% rename from docs/generated/schemas/auth.changePassword.input.schema.json rename to greenfield/docs/generated/schemas/auth.changePassword.input.schema.json diff --git a/docs/generated/schemas/auth.changePassword.output.schema.json b/greenfield/docs/generated/schemas/auth.changePassword.output.schema.json similarity index 100% rename from docs/generated/schemas/auth.changePassword.output.schema.json rename to greenfield/docs/generated/schemas/auth.changePassword.output.schema.json diff --git a/docs/generated/schemas/auth.login.input.schema.json b/greenfield/docs/generated/schemas/auth.login.input.schema.json similarity index 100% rename from docs/generated/schemas/auth.login.input.schema.json rename to greenfield/docs/generated/schemas/auth.login.input.schema.json diff --git a/docs/generated/schemas/auth.login.output.schema.json b/greenfield/docs/generated/schemas/auth.login.output.schema.json similarity index 100% rename from docs/generated/schemas/auth.login.output.schema.json rename to greenfield/docs/generated/schemas/auth.login.output.schema.json diff --git a/docs/generated/schemas/auth.loginRecovery.input.schema.json b/greenfield/docs/generated/schemas/auth.loginRecovery.input.schema.json similarity index 100% rename from docs/generated/schemas/auth.loginRecovery.input.schema.json rename to greenfield/docs/generated/schemas/auth.loginRecovery.input.schema.json diff --git a/docs/generated/schemas/auth.loginRecovery.output.schema.json b/greenfield/docs/generated/schemas/auth.loginRecovery.output.schema.json similarity index 100% rename from docs/generated/schemas/auth.loginRecovery.output.schema.json rename to greenfield/docs/generated/schemas/auth.loginRecovery.output.schema.json diff --git a/docs/generated/schemas/auth.loginTotp.input.schema.json b/greenfield/docs/generated/schemas/auth.loginTotp.input.schema.json similarity index 100% rename from docs/generated/schemas/auth.loginTotp.input.schema.json rename to greenfield/docs/generated/schemas/auth.loginTotp.input.schema.json diff --git a/docs/generated/schemas/auth.loginTotp.output.schema.json b/greenfield/docs/generated/schemas/auth.loginTotp.output.schema.json similarity index 100% rename from docs/generated/schemas/auth.loginTotp.output.schema.json rename to greenfield/docs/generated/schemas/auth.loginTotp.output.schema.json diff --git a/docs/generated/schemas/auth.loginWebAuthn.input.schema.json b/greenfield/docs/generated/schemas/auth.loginWebAuthn.input.schema.json similarity index 100% rename from docs/generated/schemas/auth.loginWebAuthn.input.schema.json rename to greenfield/docs/generated/schemas/auth.loginWebAuthn.input.schema.json diff --git a/docs/generated/schemas/auth.loginWebAuthn.output.schema.json b/greenfield/docs/generated/schemas/auth.loginWebAuthn.output.schema.json similarity index 100% rename from docs/generated/schemas/auth.loginWebAuthn.output.schema.json rename to greenfield/docs/generated/schemas/auth.loginWebAuthn.output.schema.json diff --git a/docs/generated/schemas/auth.logout.input.schema.json b/greenfield/docs/generated/schemas/auth.logout.input.schema.json similarity index 100% rename from docs/generated/schemas/auth.logout.input.schema.json rename to greenfield/docs/generated/schemas/auth.logout.input.schema.json diff --git a/docs/generated/schemas/auth.logout.output.schema.json b/greenfield/docs/generated/schemas/auth.logout.output.schema.json similarity index 100% rename from docs/generated/schemas/auth.logout.output.schema.json rename to greenfield/docs/generated/schemas/auth.logout.output.schema.json diff --git a/docs/generated/schemas/auth.revokeSession.input.schema.json b/greenfield/docs/generated/schemas/auth.revokeSession.input.schema.json similarity index 100% rename from docs/generated/schemas/auth.revokeSession.input.schema.json rename to greenfield/docs/generated/schemas/auth.revokeSession.input.schema.json diff --git a/docs/generated/schemas/auth.revokeSession.output.schema.json b/greenfield/docs/generated/schemas/auth.revokeSession.output.schema.json similarity index 100% rename from docs/generated/schemas/auth.revokeSession.output.schema.json rename to greenfield/docs/generated/schemas/auth.revokeSession.output.schema.json diff --git a/docs/generated/schemas/auth.sessions.input.schema.json b/greenfield/docs/generated/schemas/auth.sessions.input.schema.json similarity index 100% rename from docs/generated/schemas/auth.sessions.input.schema.json rename to greenfield/docs/generated/schemas/auth.sessions.input.schema.json diff --git a/docs/generated/schemas/auth.sessions.output.schema.json b/greenfield/docs/generated/schemas/auth.sessions.output.schema.json similarity index 100% rename from docs/generated/schemas/auth.sessions.output.schema.json rename to greenfield/docs/generated/schemas/auth.sessions.output.schema.json diff --git a/docs/generated/schemas/auth.status.input.schema.json b/greenfield/docs/generated/schemas/auth.status.input.schema.json similarity index 100% rename from docs/generated/schemas/auth.status.input.schema.json rename to greenfield/docs/generated/schemas/auth.status.input.schema.json diff --git a/docs/generated/schemas/auth.status.output.schema.json b/greenfield/docs/generated/schemas/auth.status.output.schema.json similarity index 100% rename from docs/generated/schemas/auth.status.output.schema.json rename to greenfield/docs/generated/schemas/auth.status.output.schema.json diff --git a/docs/generated/schemas/auth.touch.input.schema.json b/greenfield/docs/generated/schemas/auth.touch.input.schema.json similarity index 100% rename from docs/generated/schemas/auth.touch.input.schema.json rename to greenfield/docs/generated/schemas/auth.touch.input.schema.json diff --git a/docs/generated/schemas/auth.touch.output.schema.json b/greenfield/docs/generated/schemas/auth.touch.output.schema.json similarity index 100% rename from docs/generated/schemas/auth.touch.output.schema.json rename to greenfield/docs/generated/schemas/auth.touch.output.schema.json diff --git a/docs/generated/schemas/automationSecurity.createCredential.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.createCredential.input.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.createCredential.input.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.createCredential.input.schema.json diff --git a/docs/generated/schemas/automationSecurity.createCredential.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.createCredential.output.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.createCredential.output.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.createCredential.output.schema.json diff --git a/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json diff --git a/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json diff --git a/docs/generated/schemas/automationSecurity.disablePrincipal.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.input.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.disablePrincipal.input.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.input.schema.json diff --git a/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json diff --git a/docs/generated/schemas/automationSecurity.listCredentials.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.listCredentials.input.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.listCredentials.input.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.listCredentials.input.schema.json diff --git a/docs/generated/schemas/automationSecurity.listCredentials.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.listCredentials.output.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.listCredentials.output.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.listCredentials.output.schema.json diff --git a/docs/generated/schemas/automationSecurity.listPrincipals.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.input.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.listPrincipals.input.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.listPrincipals.input.schema.json diff --git a/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json diff --git a/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json diff --git a/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json diff --git a/docs/generated/schemas/automationSecurity.revokeCredential.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.revokeCredential.input.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.revokeCredential.input.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.revokeCredential.input.schema.json diff --git a/docs/generated/schemas/automationSecurity.revokeCredential.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.revokeCredential.output.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.revokeCredential.output.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.revokeCredential.output.schema.json diff --git a/docs/generated/schemas/automationSecurity.rotateCredential.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.rotateCredential.input.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.rotateCredential.input.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.rotateCredential.input.schema.json diff --git a/docs/generated/schemas/automationSecurity.rotateCredential.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.rotateCredential.output.schema.json similarity index 100% rename from docs/generated/schemas/automationSecurity.rotateCredential.output.schema.json rename to greenfield/docs/generated/schemas/automationSecurity.rotateCredential.output.schema.json diff --git a/docs/generated/schemas/events.stream.input.schema.json b/greenfield/docs/generated/schemas/events.stream.input.schema.json similarity index 100% rename from docs/generated/schemas/events.stream.input.schema.json rename to greenfield/docs/generated/schemas/events.stream.input.schema.json diff --git a/docs/generated/schemas/events.stream.output.schema.json b/greenfield/docs/generated/schemas/events.stream.output.schema.json similarity index 100% rename from docs/generated/schemas/events.stream.output.schema.json rename to greenfield/docs/generated/schemas/events.stream.output.schema.json diff --git a/docs/generated/schemas/health.liveness.response.schema.json b/greenfield/docs/generated/schemas/health.liveness.response.schema.json similarity index 100% rename from docs/generated/schemas/health.liveness.response.schema.json rename to greenfield/docs/generated/schemas/health.liveness.response.schema.json diff --git a/docs/generated/schemas/health.readiness.response.schema.json b/greenfield/docs/generated/schemas/health.readiness.response.schema.json similarity index 100% rename from docs/generated/schemas/health.readiness.response.schema.json rename to greenfield/docs/generated/schemas/health.readiness.response.schema.json diff --git a/docs/generated/schemas/system.runtimeIdentity.input.schema.json b/greenfield/docs/generated/schemas/system.runtimeIdentity.input.schema.json similarity index 100% rename from docs/generated/schemas/system.runtimeIdentity.input.schema.json rename to greenfield/docs/generated/schemas/system.runtimeIdentity.input.schema.json diff --git a/docs/generated/schemas/system.runtimeIdentity.output.schema.json b/greenfield/docs/generated/schemas/system.runtimeIdentity.output.schema.json similarity index 100% rename from docs/generated/schemas/system.runtimeIdentity.output.schema.json rename to greenfield/docs/generated/schemas/system.runtimeIdentity.output.schema.json diff --git a/greenfield/docs/index.md b/greenfield/docs/index.md new file mode 100644 index 000000000..96f250eee --- /dev/null +++ b/greenfield/docs/index.md @@ -0,0 +1,11 @@ +# Mira Dashboard Documentation + +- [Greenfield rewrite blueprint](architecture/greenfield-rewrite.md) +- [Implementation progress](architecture/greenfield-rewrite/progress.md) +- [Application architecture](architecture/greenfield-rewrite/application-architecture.md) +- [Data and security](architecture/greenfield-rewrite/data-and-security.md) +- [Runtime and delivery](architecture/greenfield-rewrite/runtime-and-delivery.md) +- [Implementation plan](architecture/greenfield-rewrite/implementation-plan.md) +- [Phase 2 threat model](security/greenfield-phase-two-threat-model.md) +- [Generated reference](generated/README.md) +- [Testing and pull requests](development/testing-and-prs.md) diff --git a/docs/security/greenfield-phase-two-threat-model.md b/greenfield/docs/security/greenfield-phase-two-threat-model.md similarity index 98% rename from docs/security/greenfield-phase-two-threat-model.md rename to greenfield/docs/security/greenfield-phase-two-threat-model.md index dbeca5efe..f2e9e8e74 100644 --- a/docs/security/greenfield-phase-two-threat-model.md +++ b/greenfield/docs/security/greenfield-phase-two-threat-model.md @@ -101,7 +101,7 @@ security-administration boundary. | A WebAuthn response replays, crosses purpose/session/RP boundaries, leaks raw ceremony data, or races a counter update. | Fixed RP ID/origins and ES256 policy, bounded preflight, one short-lived replaced challenge consumed on first admitted verification, public-key-only storage, and credential counter compare-and-swap. | `src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.test.ts`; `src/server/domains/security/mfa/webauthn/adapter.test.ts`; `src/server/domains/security/mfa/webauthn/credentialState.test.ts`; `src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts`; `src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts`; `src/server/test/system/serverWebAuthnAuthentication.test.ts` | | An automation token escalates capabilities, self-administers, survives revocation, or is lost during rotation. | Exact capability membership, browser-session-only administration with transactional recent-MFA revalidation, authorization-version CAS, staged replacement, explicit revoke, terminal disable, and renewable lease validation. | `src/server/domains/security/automation/lifecyclePrincipal.test.ts`; `src/server/domains/security/automation/lifecycleCredential.test.ts`; `src/server/domains/security/automation/lifecycleRepository.test.ts`; `src/server/domains/security/automation/procedures.test.ts`; `src/server/domains/security/requestAuthenticationAutomation.test.ts`; `src/server/test/system/serverAutomationSecurity.test.ts`; `src/server/test/system/serverAutomationSecurityLostResponse.test.ts`; `src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts` | | Secret material appears in list output, errors, logs, or audit. | Output schemas omit validators/hashes, secret-return procedures expose a token once, errors are redacted, and audit persists only allowlisted metadata. | `src/server/domains/security/automation/procedures.test.ts`; `src/server/test/system/serverAutomationSecurity.test.ts`; `src/server/test/system/serverGatewayCredentialVerification.test.ts` | -| An SSE subscriber requests unauthorized topics, retains revoked authority, creates a replay gap, or consumes unbounded memory. | Authorize before pump access, renew the authentication lease, use durable tracked cursors, reject invalid gaps, bound each subscriber, and disconnect slow consumers. | `src/server/domains/realtime/procedures.test.ts`; `src/server/domains/realtime/authenticationLeaseStream.test.ts`; `src/server/platform/realtime/eventPumpSubscriptionReplay.test.ts`; `src/server/platform/realtime/eventPumpSubscriptionBackpressure.test.ts`; `src/server/test/system/serverRealtime.test.ts`; `qualification/realtime/eventFeed.test.ts`; `qualification/topology/rollingReleaseSse.test.ts`; `qualification/resources/pausedTlsSseClient.test.ts` | +| An SSE subscriber requests unauthorized topics, retains revoked authority, creates a replay gap, or consumes unbounded memory. | Authorize before pump access, renew the authentication lease, use durable tracked cursors, reject invalid gaps, bound each subscriber, and disconnect slow consumers. | `src/server/domains/realtime/procedures.test.ts`; `src/server/domains/realtime/authenticationLeaseStream.test.ts`; `src/server/platform/realtime/eventPumpSubscriptionReplay.test.ts`; `src/server/platform/realtime/eventPumpSubscriptionBackpressure.test.ts`; `src/server/test/system/serverRealtime.test.ts`; `src/test/integration/transport/realtime/eventFeed.test.ts`; `src/test/integration/transport/topology/rollingReleaseSse.test.ts`; `src/test/integration/resources/pausedTlsSseClient.test.ts` | | Configuration redirects the device-less verifier off the trusted local backend or puts the credential in upgrade metadata. | Accept only literal IPv4/IPv6 loopback `ws://` with an explicit port and root path; reject DNS, remote, `wss://`, userinfo, path, query, and fragment forms. Send no Origin, authorization, proxy, or subprotocol header and no token-bearing URL. | `src/server/platform/gateway/gatewayCredentialVerifier.test.ts` | | A fake or auth-disabled Gateway sends binary, unknown, malformed, oversized, duplicate, out-of-order, wrong-ID, contradictory, or permissive frames. | Accept text JSON only; cap the challenge at 4 KiB and current installed hello at 25 MiB; allow exactly challenge plus matching response; require operator role, `operator.admin` requested only for this handshake, and token auth mode; fail every unknown or contradictory frame immediately; classify only structured token mismatch as invalid. | `src/server/platform/gateway/gatewayCredentialProtocol.test.ts`; `src/server/platform/gateway/gatewayCredentialVerifier.test.ts`; `src/server/test/system/serverGatewayCredentialVerification.test.ts` | | Gateway startup or transport unavailability creates an internal retry storm or bypasses durable throttling. | Never reconnect or retry inside the verifier, including `startup-sidecars`; redact the failure and require the operator/client to retry the whole HTTP bootstrap request under durable cooldown. | `src/server/platform/gateway/gatewayCredentialVerifier.test.ts`; `src/server/test/system/serverGatewayCredentialVerification.test.ts`; `src/server/domains/security/authenticationLifecycle.rateLimit.test.ts` | @@ -154,9 +154,8 @@ Phase 2 is closed only for the server-side scope stated above. The evidence cons 1. focused unit, repository, lifecycle, adapter, and system tests at the exact paths in the misuse table; -2. `bun run typecheck:server`, `bun run test:server`, and the security-relevant qualification - suites; -3. `bun run test:server:docs`, `bun run docs:check`, and `bun run db:check`; and +2. `bun run typecheck`, `bun run test:server`, and the security-relevant integration suites; +3. `bun run test:tooling`, `bun run docs:check`, and `bun run db:check`; and 4. the explicit remaining-phase status in `docs/architecture/greenfield-rewrite/progress.md`. diff --git a/drizzle.config.ts b/greenfield/drizzle.config.ts similarity index 100% rename from drizzle.config.ts rename to greenfield/drizzle.config.ts diff --git a/migrations/20260804022252_dashboard-foundation/migration.sql b/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql similarity index 100% rename from migrations/20260804022252_dashboard-foundation/migration.sql rename to greenfield/migrations/20260804022252_dashboard-foundation/migration.sql diff --git a/migrations/20260804022252_dashboard-foundation/snapshot.json b/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json similarity index 100% rename from migrations/20260804022252_dashboard-foundation/snapshot.json rename to greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json diff --git a/greenfield/package.json b/greenfield/package.json new file mode 100644 index 000000000..d0fa7e6f0 --- /dev/null +++ b/greenfield/package.json @@ -0,0 +1,113 @@ +{ + "name": "mira-dashboard", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "check:boundaries": "bun scripts/checkSourceBoundaries.ts", + "db:check": "bun scripts/checkDatabaseSchema.ts", + "db:generate": "drizzle-kit generate --config drizzle.config.ts --output json", + "docs:check": "bun scripts/generateDocs.ts --check", + "docs:generate": "bun scripts/generateDocs.ts", + "format": "oxfmt --write .", + "format:check": "oxfmt --check .", + "lint": "bun run lint:bun && bun run lint:browser", + "lint:browser": "oxlint src/browser --tsconfig tsconfig.browser.json --no-error-on-unmatched-pattern", + "lint:bun": "oxlint . --tsconfig tsconfig.bun.json --ignore-pattern 'src/browser/**'", + "lint:fix": "oxlint . --fix --tsconfig tsconfig.bun.json --ignore-pattern 'src/browser/**' && oxlint src/browser --fix --tsconfig tsconfig.browser.json --no-error-on-unmatched-pattern", + "evidence:resources:sse": "bun src/test/integration/resources/runSseMemoryEvidence.ts", + "test": "bun run test:boundaries && bun run test:browser && bun run test:integration && bun run test:parity && bun run test:server && bun run test:tooling", + "test:boundaries": "bun scripts/runTestSuite.ts scripts/sourceBoundaries", + "test:browser": "bun scripts/runTestSuite.ts --pass-with-no-tests src/browser", + "test:coverage": "bun scripts/runCoverage.ts", + "test:integration": "bun scripts/runTestSuite.ts src/test/integration src/test/support", + "test:parity": "bun scripts/runTestSuite.ts src/test/parity", + "test:server": "bun scripts/runTestSuite.ts src/app src/server src/shared src/contracts", + "test:tooling": "bun scripts/runTestSuite.ts scripts/documentation scripts/buildSourceIdentity.test.ts scripts/checkDatabaseSchema.test.ts scripts/checkCoverage.test.ts scripts/runTestSuite.test.ts scripts/testOutputPolicy.test.ts", + "typecheck": "bun run typecheck:browser && bun run typecheck:bun", + "typecheck:browser": "bun node_modules/typescript/bin/tsc -p tsconfig.browser.json --noEmit", + "typecheck:bun": "bun node_modules/typescript/bin/tsc -p tsconfig.bun.json --noEmit" + }, + "dependencies": { + "@daypicker/react": "10.0.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/react": "^0.5.0", + "@dnd-kit/sortable": "^10.0.0", + "@headlessui/react": "^2.2.10", + "@microlink/react-json-view": "^1.31.28", + "@simplewebauthn/browser": "13.3.0", + "@simplewebauthn/server": "13.3.2", + "@tailwindcss/typography": "^0.5.20", + "@tanstack/db": "0.6.17", + "@tanstack/query-core": "5.101.4", + "@tanstack/query-db-collection": "1.2.1", + "@tanstack/react-db": "0.1.95", + "@tanstack/react-form": "^1.33.3", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-router": "^1.170.21", + "@tanstack/react-store": "0.11.1", + "@tanstack/react-table": "^9.0.0", + "@tanstack/react-virtual": "^3.14.9", + "@trpc/client": "11.18.0", + "@trpc/server": "11.18.0", + "@trpc/tanstack-react-query": "11.18.0", + "clsx": "^2.1.1", + "date-fns": "^4.4.0", + "drizzle-orm": "1.0.0-rc.4", + "effect": "4.0.0-beta.104", + "json5": "^2.2.3", + "lucide-react": "^1.29.0", + "otplib": "13.4.1", + "qrcode.react": "4.2.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-error-boundary": "^6.1.2", + "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^16.1.1", + "refractor": "^5.0.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", + "superjson": "2.2.6", + "tailwind-merge": "^3.6.0", + "valibot": "^1.4.2" + }, + "devDependencies": { + "@babel/core": "^8.0.1", + "@happy-dom/global-registrator": "^20.11.1", + "@tanstack/react-devtools": "^0.10.9", + "@tanstack/react-form-devtools": "^0.2.32", + "@tanstack/react-query-devtools": "^5.101.4", + "@tanstack/react-router-devtools": "1.167.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", + "@types/babel__core": "^7.20.5", + "@types/node": "26.1.2", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@types/react-syntax-highlighter": "^15.5.13", + "@valibot/to-json-schema": "1.7.1", + "babel-plugin-react-compiler": "^1.0.0", + "bun-plugin-tailwind": "^0.1.2", + "bun-types": "1.4.0-canary.20260519T150915", + "drizzle-kit": "1.0.0-rc.4", + "eventsource": "4.1.1", + "happy-dom": "^20.11.1", + "jsonc-parser": "3.3.1", + "oxfmt": "^0.62.0", + "oxlint": "^1.77.0", + "oxlint-config-presets": "^0.1.18", + "oxlint-tailwindcss": "^1.7.0", + "oxlint-tsgolint": "^7.0.2001", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2" + }, + "browserslist": [ + "Chrome >= 140", + "Firefox >= 133", + "Safari >= 18.4" + ] +} diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/agents.json b/greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/agents.json similarity index 100% rename from qualification/openclaw/fixtures/2026.7.2-beta.7/agents.json rename to greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/agents.json diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/chat.json b/greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/chat.json similarity index 100% rename from qualification/openclaw/fixtures/2026.7.2-beta.7/chat.json rename to greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/chat.json diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/cron.json b/greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/cron.json similarity index 100% rename from qualification/openclaw/fixtures/2026.7.2-beta.7/cron.json rename to greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/cron.json diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/gateway.json b/greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/gateway.json similarity index 100% rename from qualification/openclaw/fixtures/2026.7.2-beta.7/gateway.json rename to greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/gateway.json diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/manifest.json b/greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/manifest.json similarity index 100% rename from qualification/openclaw/fixtures/2026.7.2-beta.7/manifest.json rename to greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/manifest.json diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/sessions.json b/greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/sessions.json similarity index 100% rename from qualification/openclaw/fixtures/2026.7.2-beta.7/sessions.json rename to greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/sessions.json diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/tasks.json b/greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/tasks.json similarity index 100% rename from qualification/openclaw/fixtures/2026.7.2-beta.7/tasks.json rename to greenfield/scripts/audits/openclaw/fixtures/2026.7.2-beta.7/tasks.json diff --git a/qualification/openclaw/reviewedFixtures.ts b/greenfield/scripts/audits/openclaw/reviewedFixtures.ts similarity index 99% rename from qualification/openclaw/reviewedFixtures.ts rename to greenfield/scripts/audits/openclaw/reviewedFixtures.ts index b6c40f226..80a3fa52b 100644 --- a/qualification/openclaw/reviewedFixtures.ts +++ b/greenfield/scripts/audits/openclaw/reviewedFixtures.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/pr import path from "node:path"; import { fileURLToPath } from "node:url"; -import { readBoundedUtf8RegularFile } from "../files/boundedFile.ts"; +import { readBoundedUtf8RegularFile } from "../../files/boundedFile.ts"; import { agentsFixtureSchema, chatFixtureSchema, diff --git a/qualification/openclaw/runSourceAudit.ts b/greenfield/scripts/audits/openclaw/runSourceAudit.ts similarity index 100% rename from qualification/openclaw/runSourceAudit.ts rename to greenfield/scripts/audits/openclaw/runSourceAudit.ts diff --git a/qualification/openclaw/sourceAudit.ts b/greenfield/scripts/audits/openclaw/sourceAudit.ts similarity index 99% rename from qualification/openclaw/sourceAudit.ts rename to greenfield/scripts/audits/openclaw/sourceAudit.ts index 0570981d8..10ea1fa19 100644 --- a/qualification/openclaw/sourceAudit.ts +++ b/greenfield/scripts/audits/openclaw/sourceAudit.ts @@ -4,7 +4,7 @@ import path from "node:path"; import * as v from "valibot"; -import { readBoundedUtf8RegularFile } from "../files/boundedFile.ts"; +import { readBoundedUtf8RegularFile } from "../../files/boundedFile.ts"; import { parseSourceAuditResult, type SourceArtifact, diff --git a/qualification/openclaw/sourceAuditSchemas.ts b/greenfield/scripts/audits/openclaw/sourceAuditSchemas.ts similarity index 100% rename from qualification/openclaw/sourceAuditSchemas.ts rename to greenfield/scripts/audits/openclaw/sourceAuditSchemas.ts diff --git a/greenfield/scripts/buildSourceIdentity.test.ts b/greenfield/scripts/buildSourceIdentity.test.ts new file mode 100644 index 000000000..8b380b21e --- /dev/null +++ b/greenfield/scripts/buildSourceIdentity.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { resolveBuildSourceIdentity } from "./buildSourceIdentity.ts"; + +const temporaryRepositories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryRepositories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +function runGit(repositoryRoot: string, ...arguments_: string[]): string { + const result = Bun.spawnSync( + ["git", "--no-optional-locks", "-C", repositoryRoot, ...arguments_], + { + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + } + ); + if (result.exitCode !== 0) { + throw new Error(result.stderr.toString() || "Git fixture command failed"); + } + return result.stdout.toString().trim(); +} + +async function createRepository(): Promise<{ commitSha: string; root: string }> { + const root = await mkdtemp(path.join(tmpdir(), "mira-source-identity-")); + temporaryRepositories.push(root); + runGit(root, "init", "--quiet"); + runGit(root, "config", "user.name", "Mira Test"); + runGit(root, "config", "user.email", "mira-test@example.invalid"); + await writeFile(path.join(root, "tracked.txt"), "initial\n", { + encoding: "utf8", + mode: 0o600, + }); + runGit(root, "add", "tracked.txt"); + runGit(root, "commit", "--quiet", "--message", "initial"); + return { commitSha: runGit(root, "rev-parse", "HEAD"), root }; +} + +describe("build source identity", () => { + test("returns the full commit for a clean repository", async () => { + const { commitSha, root } = await createRepository(); + + const identity = resolveBuildSourceIdentity(root); + + expect(identity).toEqual({ commitSha, state: "clean" }); + expect(Object.isFrozen(identity)).toBe(true); + }); + + test("detects tracked, staged, and untracked changes", async () => { + const { commitSha, root } = await createRepository(); + const trackedPath = path.join(root, "tracked.txt"); + const untrackedPath = path.join(root, "untracked.txt"); + + await writeFile(trackedPath, "changed\n", { encoding: "utf8", mode: 0o600 }); + expect(resolveBuildSourceIdentity(root)).toEqual({ + commitSha, + state: "dirty", + }); + + runGit(root, "add", "tracked.txt"); + expect(resolveBuildSourceIdentity(root)).toEqual({ + commitSha, + state: "dirty", + }); + + await writeFile(untrackedPath, "new\n", { encoding: "utf8", mode: 0o600 }); + expect(resolveBuildSourceIdentity(root)).toEqual({ + commitSha, + state: "dirty", + }); + await unlink(untrackedPath); + }); + + test("fails closed outside a committed Git repository", async () => { + const root = await mkdtemp(path.join(tmpdir(), "mira-source-identity-")); + temporaryRepositories.push(root); + + expect(resolveBuildSourceIdentity(root)).toEqual({ state: "unknown" }); + expect(resolveBuildSourceIdentity("relative/path")).toEqual({ + state: "unknown", + }); + }); +}); diff --git a/greenfield/scripts/buildSourceIdentity.ts b/greenfield/scripts/buildSourceIdentity.ts new file mode 100644 index 000000000..9cf0594db --- /dev/null +++ b/greenfield/scripts/buildSourceIdentity.ts @@ -0,0 +1,62 @@ +import path from "node:path"; + +import * as v from "valibot"; + +import { fullCommitShaSchema } from "../src/shared/validation.ts"; + +/** Stable source identity resolved from one local Git checkout. */ +export type BuildSourceIdentity = + | Readonly<{ commitSha: string; state: "clean" | "dirty" }> + | Readonly<{ state: "unknown" }>; + +const maximumGitOutputBytes = 1024 * 1024; +const commitShaSchema = fullCommitShaSchema(); + +function gitOutput( + repositoryRoot: string, + arguments_: readonly string[] +): string | undefined { + try { + const result = Bun.spawnSync( + ["git", "--no-optional-locks", "-C", repositoryRoot, ...arguments_], + { + maxBuffer: maximumGitOutputBytes, + stderr: "ignore", + stdin: "ignore", + stdout: "pipe", + } + ); + if (result.exitCode !== 0) return undefined; + return result.stdout.toString().trim(); + } catch { + return undefined; + } +} + +/** + * Resolves the full commit and clean-tree state for an explicit repository root. + * Git failures and malformed identities fail closed without inventing release metadata. + * @param repositoryRoot Absolute repository root to inspect. + * @returns Clean, dirty, or unknown source identity. + */ +export function resolveBuildSourceIdentity(repositoryRoot: string): BuildSourceIdentity { + if (!path.isAbsolute(repositoryRoot) || repositoryRoot.includes("\0")) { + return Object.freeze({ state: "unknown" }); + } + + const commitOutput = gitOutput(repositoryRoot, ["rev-parse", "--verify", "HEAD"]); + const commit = v.safeParse(commitShaSchema, commitOutput, { abortEarly: true }); + if (!commit.success) return Object.freeze({ state: "unknown" }); + + const status = gitOutput(repositoryRoot, [ + "status", + "--porcelain=v1", + "--untracked-files=all", + "--ignore-submodules=none", + ]); + if (status === undefined) return Object.freeze({ state: "unknown" }); + return Object.freeze({ + commitSha: commit.output, + state: status.length === 0 ? "clean" : "dirty", + }); +} diff --git a/greenfield/scripts/checkCoverage.test.ts b/greenfield/scripts/checkCoverage.test.ts new file mode 100644 index 000000000..8f045b70b --- /dev/null +++ b/greenfield/scripts/checkCoverage.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; + +import { + assertCoverageIncludesSources, + assertLineCoverage, + summarizeLineCoverage, +} from "./checkCoverage.ts"; + +function record(source: string, foundLines: number, hitLines: number): string { + return [ + "TN:", + `SF:${source}`, + `LF:${String(foundLines)}`, + `LH:${String(hitLines)}`, + "end_of_record", + ].join("\n"); +} + +describe("coverage threshold", () => { + test("aggregates only exact selected source roots", () => { + const summary = summarizeLineCoverage( + [ + record("src/server/service.ts", 80, 70), + record("src/shared/value.ts", 20, 15), + record("src-old/ignored.ts", 100, 0), + record("scripts/ignored.ts", 100, 0), + ].join("\n"), + ["src"] + ); + + expect(summary).toEqual({ foundLines: 100, hitLines: 85, percent: 85 }); + }); + + test("accepts the exact threshold and rejects a lower result", () => { + const exact = record("src/service.ts", 100, 85); + expect(assertLineCoverage(exact, 85, ["src"]).percent).toBe(85); + + expect(() => + assertLineCoverage(record("src/service.ts", 100, 84), 85, ["src"]) + ).toThrow("Coverage 84.00% is below required 85.00% (84/100 lines)"); + }); + + test("rejects missing and internally inconsistent line totals", () => { + expect(() => summarizeLineCoverage("TN:\n", ["src"])).toThrow( + "LCOV contains no line coverage" + ); + expect(() => + summarizeLineCoverage("SF:src/service.ts\nLF:2\nLH:3\nend_of_record\n", [ + "src", + ]) + ).toThrow("LCOV hit-line total exceeds"); + expect(() => + summarizeLineCoverage( + "SF:src/service.ts\nLF:not-a-number\nLH:0\nend_of_record\n", + ["src"] + ) + ).toThrow("LCOV contains an invalid LF line count"); + }); + + test("requires a valid threshold and repository-relative source root", () => { + const lcov = record("src/service.ts", 1, 1); + expect(() => assertLineCoverage(lcov, Number.NaN, ["src"])).toThrow( + "Coverage threshold must be between zero and 100" + ); + expect(() => summarizeLineCoverage(lcov, [])).toThrow( + "Coverage requires at least one repository source root" + ); + expect(() => summarizeLineCoverage(lcov, ["../src"])).toThrow( + "Coverage requires at least one repository source root" + ); + expect(() => summarizeLineCoverage(lcov, ["/src"])).toThrow( + "Coverage requires at least one repository source root" + ); + }); + + test("rejects an executable source file missing entirely from LCOV", () => { + const lcov = record("src/service.ts", 10, 10); + expect(() => + assertCoverageIncludesSources(lcov, ["src/missing.ts", "src/service.ts"]) + ).toThrow("LCOV is missing executable source files:\nsrc/missing.ts"); + + expect(() => + assertCoverageIncludesSources(lcov, ["src/service.ts"]) + ).not.toThrow(); + }); +}); diff --git a/greenfield/scripts/checkCoverage.ts b/greenfield/scripts/checkCoverage.ts new file mode 100644 index 000000000..9a8137654 --- /dev/null +++ b/greenfield/scripts/checkCoverage.ts @@ -0,0 +1,266 @@ +import path from "node:path"; + +import { readBoundedUtf8RegularFile } from "./files/boundedFile.ts"; +import { discoverSourceFiles } from "./sourceBoundaries/sourceDiscovery.ts"; +import { sourceRole } from "./sourceBoundaries/sourceTopologyPolicy.ts"; + +/** Required repository line coverage percentage. */ +export const requiredLineCoveragePercent = 85; + +/** Aggregate line coverage for the selected production-source roots. */ +export interface LineCoverageSummary { + readonly foundLines: number; + readonly hitLines: number; + readonly percent: number; +} + +const maximumLcovBytes = 64 * 1024 * 1024; +const typeScriptModuleExtensions: ReadonlySet = new Set([".cts", ".mts", ".ts"]); + +function normalizeCoveragePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//u, "").replace(/\/$/u, ""); +} + +function belongsToRoot(sourcePath: string, sourceRoot: string): boolean { + return sourcePath === sourceRoot || sourcePath.startsWith(`${sourceRoot}/`); +} + +function parseLineCount(kind: "LF" | "LH", value: string): number { + const count = Number(value); + if (!Number.isSafeInteger(count) || count < 0) { + throw new Error(`LCOV contains an invalid ${kind} line count`); + } + return count; +} + +function transpilerLoader(filePath: string): "js" | "jsx" | "ts" | "tsx" { + const extension = path.extname(filePath); + if (extension === ".tsx") return "tsx"; + if (typeScriptModuleExtensions.has(extension)) return "ts"; + if (extension === ".jsx") return "jsx"; + return "js"; +} + +/** + * Requires every executable production source file to be present in the LCOV inventory. + * @param lcov Complete LCOV document. + * @param expectedSourcePaths Exact repository-relative executable source paths. + */ +export function assertCoverageIncludesSources( + lcov: string, + expectedSourcePaths: readonly string[] +): void { + const reportedSources = new Set( + lcov + .split(/\r?\n/u) + .filter((line) => line.startsWith("SF:")) + .map((line) => normalizeCoveragePath(line.slice(3))) + ); + const missingSources = expectedSourcePaths.filter( + (sourcePath) => !reportedSources.has(normalizeCoveragePath(sourcePath)) + ); + if (missingSources.length > 0) { + throw new Error( + `LCOV is missing executable source files:\n${missingSources.join("\n")}` + ); + } +} + +/** + * Discovers production modules that emit runtime JavaScript and therefore require LCOV. + * @param projectRoot Absolute repository root. + * @param sourceRoots Repository-relative roots included in the threshold. + * @returns Sorted executable source paths, excluding tests and type-only modules. + */ +export async function discoverExecutableCoverageSources( + projectRoot: string, + sourceRoots: readonly string[] +): Promise { + const normalizedRoots = sourceRoots.map((root) => normalizeCoveragePath(root)); + const discovery = await discoverSourceFiles(projectRoot); + if (discovery.violations.length > 0) { + throw new Error("Coverage source inventory requires valid source boundaries"); + } + + const transpilers = new Map(); + const executableSources: string[] = []; + for (const filePath of discovery.files) { + if ( + sourceRole(filePath) === "test" || + !normalizedRoots.some((root) => belongsToRoot(filePath, root)) + ) { + continue; + } + + const loader = transpilerLoader(filePath); + let transpiler = transpilers.get(loader); + if (transpiler === undefined) { + transpiler = new Bun.Transpiler({ loader, target: "bun" }); + transpilers.set(loader, transpiler); + } + const source = await Bun.file(path.join(projectRoot, filePath)).text(); + if (transpiler.transformSync(source).trim().length > 0) { + executableSources.push(filePath); + } + } + return Object.freeze(executableSources.toSorted()); +} + +/** + * Aggregates LCOV line totals for exact repository-relative production roots. + * @param lcov Complete LCOV document. + * @param sourceRoots Repository-relative roots included in the threshold. + * @returns Hit, found, and unrounded percentage totals. + */ +export function summarizeLineCoverage( + lcov: string, + sourceRoots: readonly string[] +): LineCoverageSummary { + const normalizedRoots = sourceRoots.map((root) => normalizeCoveragePath(root)); + if ( + normalizedRoots.length === 0 || + normalizedRoots.some( + (root) => + root.length === 0 || + root === "." || + root === ".." || + root.includes("\0") || + root.startsWith("../") || + path.posix.isAbsolute(root) || + /^[A-Za-z]:\//u.test(root) + ) + ) { + throw new TypeError("Coverage requires at least one repository source root"); + } + + let countCurrentRecord = false; + let currentFoundLines: number | undefined; + let currentHitLines: number | undefined; + let foundLines = 0; + let hitLines = 0; + + function finishRecord(): void { + if (!countCurrentRecord) return; + if (currentFoundLines === undefined || currentHitLines === undefined) { + throw new Error("LCOV source record is missing LF or LH line totals"); + } + if (currentHitLines > currentFoundLines) { + throw new Error("LCOV hit-line total exceeds its found-line total"); + } + foundLines += currentFoundLines; + hitLines += currentHitLines; + } + + for (const line of lcov.split(/\r?\n/u)) { + if (line.startsWith("SF:")) { + finishRecord(); + const sourcePath = normalizeCoveragePath(line.slice(3)); + countCurrentRecord = normalizedRoots.some((root) => + belongsToRoot(sourcePath, root) + ); + currentFoundLines = undefined; + currentHitLines = undefined; + } else if (line.startsWith("LF:") && countCurrentRecord) { + if (currentFoundLines !== undefined) { + throw new Error("LCOV source record contains duplicate LF totals"); + } + currentFoundLines = parseLineCount("LF", line.slice(3)); + } else if (line.startsWith("LH:") && countCurrentRecord) { + if (currentHitLines !== undefined) { + throw new Error("LCOV source record contains duplicate LH totals"); + } + currentHitLines = parseLineCount("LH", line.slice(3)); + } else if (line === "end_of_record") { + finishRecord(); + countCurrentRecord = false; + currentFoundLines = undefined; + currentHitLines = undefined; + } + } + finishRecord(); + + if (foundLines === 0) { + throw new Error("LCOV contains no line coverage for the selected source roots"); + } + return { + foundLines, + hitLines, + percent: (hitLines / foundLines) * 100, + }; +} + +/** + * Requires one aggregate LCOV line-coverage threshold. + * @param lcov Complete LCOV document. + * @param thresholdPercent Inclusive percentage threshold. + * @param sourceRoots Repository-relative production roots included in the threshold. + * @returns The accepted coverage summary. + */ +export function assertLineCoverage( + lcov: string, + thresholdPercent: number, + sourceRoots: readonly string[] +): LineCoverageSummary { + if ( + !Number.isFinite(thresholdPercent) || + thresholdPercent < 0 || + thresholdPercent > 100 + ) { + throw new TypeError("Coverage threshold must be between zero and 100"); + } + + const summary = summarizeLineCoverage(lcov, sourceRoots); + if (summary.percent < thresholdPercent) { + throw new Error( + `Coverage ${summary.percent.toFixed(2)}% is below required ${thresholdPercent.toFixed(2)}% (${summary.hitLines}/${summary.foundLines} lines)` + ); + } + return summary; +} + +/** + * Reads and validates a stable, bounded LCOV artifact from this repository. + * @param lcovPath Absolute or repository-relative LCOV path. + * @param thresholdPercent Inclusive line-coverage threshold. + * @param sourceRoots Repository-relative production roots included in the threshold. + * @param projectRoot Repository root containing the artifact. + * @returns The accepted coverage summary. + */ +export async function checkCoverageFile( + lcovPath: string, + thresholdPercent: number, + sourceRoots: readonly string[], + projectRoot = path.resolve(import.meta.dir, "..") +): Promise { + const resolvedPath = path.resolve(projectRoot, lcovPath); + const { text } = await readBoundedUtf8RegularFile( + resolvedPath, + projectRoot, + maximumLcovBytes, + "Coverage artifact is unavailable or invalid", + "Coverage artifact is not valid UTF-8" + ); + const summary = assertLineCoverage(text, thresholdPercent, sourceRoots); + assertCoverageIncludesSources( + text, + await discoverExecutableCoverageSources(projectRoot, sourceRoots) + ); + return summary; +} + +async function main(): Promise { + const [lcovPath, thresholdInput, ...sourceRoots] = process.argv.slice(2); + const thresholdPercent = Number(thresholdInput); + if (lcovPath === undefined || thresholdInput === undefined) { + throw new TypeError( + "Usage: bun scripts/checkCoverage.ts " + ); + } + + const summary = await checkCoverageFile(lcovPath, thresholdPercent, sourceRoots); + console.log( + `Coverage ${summary.percent.toFixed(2)}% meets required ${thresholdPercent.toFixed(2)}% (${summary.hitLines}/${summary.foundLines} lines)` + ); +} + +if (import.meta.main) await main(); diff --git a/scripts/checkDatabaseSchema.test.ts b/greenfield/scripts/checkDatabaseSchema.test.ts similarity index 100% rename from scripts/checkDatabaseSchema.test.ts rename to greenfield/scripts/checkDatabaseSchema.test.ts diff --git a/scripts/checkDatabaseSchema.ts b/greenfield/scripts/checkDatabaseSchema.ts similarity index 100% rename from scripts/checkDatabaseSchema.ts rename to greenfield/scripts/checkDatabaseSchema.ts diff --git a/scripts/checkSourceBoundaries.ts b/greenfield/scripts/checkSourceBoundaries.ts similarity index 78% rename from scripts/checkSourceBoundaries.ts rename to greenfield/scripts/checkSourceBoundaries.ts index 9032547d0..791aaf34b 100644 --- a/scripts/checkSourceBoundaries.ts +++ b/greenfield/scripts/checkSourceBoundaries.ts @@ -3,13 +3,8 @@ import path from "node:path"; import { readBoundaryConfiguration } from "./sourceBoundaries/boundaryConfiguration.ts"; import { parseSourceAnalysis } from "./sourceBoundaries/importGraph.ts"; +import { validateExactRelativeImportTarget } from "./sourceBoundaries/importTargetValidation.ts"; import { - validateExactRelativeImportTarget, - validateLegacyAllowlistTarget, -} from "./sourceBoundaries/importTargetValidation.ts"; -import { - legacyScriptImportAllowlist, - legacyScriptImportKey, type SourceBoundaryViolation, validateDeclaredPackageImport, validateSourceAmbientRuntimeDeclaration, @@ -25,7 +20,6 @@ import { discoverSourceFiles } from "./sourceBoundaries/sourceDiscovery.ts"; const sourceAnalysisConcurrency = 4; interface SourceAnalysisResult { - readonly observedLegacyScriptImports: readonly string[]; readonly violations: readonly SourceBoundaryViolation[]; } @@ -58,7 +52,6 @@ async function analyzeSourceFile( declaredPackageNames: ReadonlySet ): Promise { const violations: SourceBoundaryViolation[] = []; - const observedLegacyScriptImports: string[] = []; const fileViolation = validateSourceFile(importer); if (fileViolation !== undefined) violations.push(fileViolation); @@ -100,21 +93,6 @@ async function analyzeSourceFile( if (environmentViolation !== undefined) violations.push(environmentViolation); } for (const sourceImport of analysis.imports) { - const legacyImportKey = legacyScriptImportKey(importer, sourceImport); - if ( - legacyImportKey !== undefined && - legacyScriptImportAllowlist.has(legacyImportKey) - ) { - observedLegacyScriptImports.push(legacyImportKey); - const legacyTargetViolation = await validateLegacyAllowlistTarget( - projectRoot, - realProjectRoot, - legacyImportKey - ); - if (legacyTargetViolation !== undefined) { - violations.push(legacyTargetViolation); - } - } const importViolation = validateSourceImport(importer, sourceImport); if (importViolation === undefined) { const exactTargetViolation = await validateExactRelativeImportTarget( @@ -136,7 +114,7 @@ async function analyzeSourceFile( ); if (packageViolation !== undefined) violations.push(packageViolation); } - return { observedLegacyScriptImports, violations }; + return { violations }; } /** @@ -153,7 +131,6 @@ export async function checkSourceBoundaries( ...discovery.violations, ...configuration.violations, ]; - const observedLegacyScriptImports = new Set(); const realProjectRoot = await realpath(path.resolve(projectRoot)); const sourceResults = await mapWithBoundedConcurrency( discovery.files, @@ -168,19 +145,6 @@ export async function checkSourceBoundaries( ); for (const result of sourceResults) { violations.push(...result.violations); - for (const observedImport of result.observedLegacyScriptImports) { - observedLegacyScriptImports.add(observedImport); - } - } - for (const allowlistedImport of legacyScriptImportAllowlist) { - if (observedLegacyScriptImports.has(allowlistedImport)) continue; - const separatorIndex = allowlistedImport.indexOf("\0"); - violations.push({ - importer: allowlistedImport.slice(0, separatorIndex), - line: 1, - message: "Legacy script allowlist entry is stale or no longer imported", - specifier: allowlistedImport.slice(separatorIndex + 1), - }); } return violations.toSorted( (left, right) => diff --git a/scripts/documentation/artifacts.test.ts b/greenfield/scripts/documentation/artifacts.test.ts similarity index 99% rename from scripts/documentation/artifacts.test.ts rename to greenfield/scripts/documentation/artifacts.test.ts index 3e47dbb4c..438ad3bda 100644 --- a/scripts/documentation/artifacts.test.ts +++ b/greenfield/scripts/documentation/artifacts.test.ts @@ -11,12 +11,12 @@ const packageManifest = { }, devDependencies: { "@valibot/to-json-schema": "1.7.1", - eventsource: "4.1.0", + eventsource: "4.1.1", }, resolvedVersions: { "@trpc/server": "11.18.0", "@valibot/to-json-schema": "1.7.1", - eventsource: "4.1.0", + eventsource: "4.1.1", valibot: "1.4.2", }, }; diff --git a/scripts/documentation/artifacts.ts b/greenfield/scripts/documentation/artifacts.ts similarity index 100% rename from scripts/documentation/artifacts.ts rename to greenfield/scripts/documentation/artifacts.ts diff --git a/scripts/documentation/bunLock.test.ts b/greenfield/scripts/documentation/bunLock.test.ts similarity index 100% rename from scripts/documentation/bunLock.test.ts rename to greenfield/scripts/documentation/bunLock.test.ts diff --git a/scripts/documentation/bunLock.ts b/greenfield/scripts/documentation/bunLock.ts similarity index 100% rename from scripts/documentation/bunLock.ts rename to greenfield/scripts/documentation/bunLock.ts diff --git a/scripts/documentation/configurationMarkdown.test.ts b/greenfield/scripts/documentation/configurationMarkdown.test.ts similarity index 100% rename from scripts/documentation/configurationMarkdown.test.ts rename to greenfield/scripts/documentation/configurationMarkdown.test.ts diff --git a/scripts/documentation/files.ts b/greenfield/scripts/documentation/files.ts similarity index 100% rename from scripts/documentation/files.ts rename to greenfield/scripts/documentation/files.ts diff --git a/scripts/documentation/jsonSchema.test.ts b/greenfield/scripts/documentation/jsonSchema.test.ts similarity index 100% rename from scripts/documentation/jsonSchema.test.ts rename to greenfield/scripts/documentation/jsonSchema.test.ts diff --git a/scripts/documentation/jsonSchema.ts b/greenfield/scripts/documentation/jsonSchema.ts similarity index 100% rename from scripts/documentation/jsonSchema.ts rename to greenfield/scripts/documentation/jsonSchema.ts diff --git a/scripts/documentation/markdown.ts b/greenfield/scripts/documentation/markdown.ts similarity index 100% rename from scripts/documentation/markdown.ts rename to greenfield/scripts/documentation/markdown.ts diff --git a/qualification/files/boundedFile.ts b/greenfield/scripts/files/boundedFile.ts similarity index 92% rename from qualification/files/boundedFile.ts rename to greenfield/scripts/files/boundedFile.ts index 56fce7ba7..cab76a247 100644 --- a/qualification/files/boundedFile.ts +++ b/greenfield/scripts/files/boundedFile.ts @@ -2,7 +2,7 @@ import { constants, type BigIntStats } from "node:fs"; import { lstat, open, realpath } from "node:fs/promises"; import path from "node:path"; -export interface BoundedFileReadQualificationHooks { +export interface BoundedFileReadTestHooks { /** Holds the read after its initial descriptor stat for deterministic mutation tests. */ readonly afterInitialStat?: () => Promise | void; } @@ -35,11 +35,11 @@ function matchesSnapshot(before: BigIntStats, after: BigIntStats): boolean { * Reads one stable regular file through a held nonblocking, no-follow descriptor. * A post-read no-follow path snapshot revalidates that the requested path still names * the same held descriptor snapshot before any bytes are returned. - * @param absolutePath Absolute file path selected by the qualification caller. + * @param absolutePath Absolute file path selected by the caller. * @param allowedRoot Explicit root that is permitted to contain the descriptor target. * @param maximumBytes Maximum accepted file size. * @param invalidMessage Redacted failure message for every invalid file operation. - * @param qualificationHooks Deterministic qualification-only read boundaries. + * @param testHooks Deterministic test-only read boundaries. * @returns Exact file bytes from the opened descriptor. */ export async function readBoundedRegularFile( @@ -47,7 +47,7 @@ export async function readBoundedRegularFile( allowedRoot: string, maximumBytes: number, invalidMessage: string, - qualificationHooks: BoundedFileReadQualificationHooks = {} + testHooks: BoundedFileReadTestHooks = {} ): Promise { if ( !path.isAbsolute(absolutePath) || @@ -88,7 +88,7 @@ export async function readBoundedRegularFile( if (!before.isFile() || before.size <= 0n || before.size > BigInt(maximumBytes)) { throw invalidFileState(invalidMessage); } - await qualificationHooks.afterInitialStat?.(); + await testHooks.afterInitialStat?.(); const expectedBytes = Number(before.size); const buffer = Buffer.alloc(expectedBytes + 1); @@ -132,7 +132,7 @@ export async function readBoundedRegularFile( /** * Reads a stable bounded file and rejects malformed UTF-8 with a redacted error. - * @param absolutePath Absolute file path selected by the qualification caller. + * @param absolutePath Absolute file path selected by the caller. * @param allowedRoot Explicit root permitted to contain the descriptor target. * @param maximumBytes Maximum accepted file size. * @param invalidStateMessage Redacted file-operation failure message. diff --git a/greenfield/scripts/frontendBuildArtifacts.ts b/greenfield/scripts/frontendBuildArtifacts.ts new file mode 100644 index 000000000..2e385bd9d --- /dev/null +++ b/greenfield/scripts/frontendBuildArtifacts.ts @@ -0,0 +1,349 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { brotliCompressSync, constants, gzipSync } from "node:zlib"; + +const COMPRESSIBLE_EXTENSIONS = new Set([ + ".css", + ".html", + ".js", + ".json", + ".svg", + ".txt", + ".webmanifest", + ".xml", +]); +const MINIMUM_COMPRESSION_BYTES = 512; +const SCRIPT_TAG_PATTERN = /]*>[\s\S]*?<\/script(?:\s[^>]*)?>/giu; +const SCRIPT_SOURCE_ATTRIBUTE_PATTERN = /\bsrc=(["'])([^"']+)\1/iu; +const MODULE_SCRIPT_TYPE_PATTERN = /\btype=(["'])module\1/iu; + +export interface FrontendBundleMeasurements { + initialJavaScriptGzipBytes: number; + initialJavaScriptRawBytes: number; + initialStylesheetGzipBytes: number; + initialStylesheetRawBytes: number; + largestJavaScriptGzipBytes: number; + totalJavaScriptGzipBytes: number; + totalJavaScriptRawBytes: number; +} + +type FrontendBundleBudget = keyof Pick< + FrontendBundleMeasurements, + | "initialJavaScriptGzipBytes" + | "initialStylesheetGzipBytes" + | "largestJavaScriptGzipBytes" + | "totalJavaScriptGzipBytes" +>; + +export const FRONTEND_BUNDLE_BUDGETS: Readonly> = { + initialJavaScriptGzipBytes: 350 * 1024, + initialStylesheetGzipBytes: 25 * 1024, + largestJavaScriptGzipBytes: 75 * 1024, + totalJavaScriptGzipBytes: 850 * 1024, +}; + +interface MeasuredOutput { + gzipBytes: number; + outputPath: string; + rawBytes: number; +} + +export interface FrontendBundleMetrics { + budgets: Readonly>; + formatVersion: 1; + initialFiles: MeasuredOutput[]; + measurements: FrontendBundleMeasurements; +} + +function normalizedOutputKey(outputKey: string): string { + return outputKey.replaceAll("\\", "/").replace(/^\.\//u, ""); +} + +function isPathWithin(directory: string, candidate: string): boolean { + return candidate === directory || candidate.startsWith(`${directory}${path.sep}`); +} + +function resolvedOutput(outdir: string, outputKey: string) { + const resolvedOutdir = path.resolve(outdir); + const normalizedKey = normalizedOutputKey(outputKey); + const cwdRelativePath = path.resolve(normalizedKey); + const resolvedPath = isPathWithin(resolvedOutdir, cwdRelativePath) + ? cwdRelativePath + : path.resolve(resolvedOutdir, normalizedKey); + if (resolvedPath === resolvedOutdir || !isPathWithin(resolvedOutdir, resolvedPath)) { + throw new Error(`Frontend build output escaped its directory: ${outputKey}`); + } + return { + filePath: resolvedPath, + relativePath: path.relative(resolvedOutdir, resolvedPath).replaceAll("\\", "/"), + }; +} + +function isFrontendAppInput(inputKey: string, expectedAppInput: string): boolean { + const normalized = normalizedOutputKey(inputKey); + const normalizedExpectedInput = normalizedOutputKey(expectedAppInput); + return ( + normalized === normalizedExpectedInput || + normalized.endsWith(`/${normalizedExpectedInput}`) + ); +} + +/** + * Resolves the single JavaScript output that owns the application bootstrap. + * @param metafile Bun build metadata for the completed browser build. + * @param expectedAppInput Repository-relative application entrypoint. + * @returns Resolved the single JavaScript output that owns the application bootstrap. + */ +export function frontendAppOutputKey( + metafile: Bun.BuildMetafile, + expectedAppInput: string +): string { + const candidates = Object.entries(metafile.outputs) + .filter( + ([outputKey, output]) => + path.extname(outputKey) === ".js" && + Object.keys(output.inputs).some((inputKey) => + isFrontendAppInput(inputKey, expectedAppInput) + ) + ) + .map(([outputKey]) => outputKey); + if (candidates.length !== 1) { + throw new Error( + `Frontend build metadata must contain exactly one ${expectedAppInput} output; found ${candidates.length}` + ); + } + return candidates[0]!; +} + +/** + * Works around Bun selecting an unrelated split chunk for the generated HTML + * module script when metafile output is enabled. + * @param metafile Bun build metadata for the completed browser build. + * @param outdir Build output directory containing the generated HTML. + * @param expectedAppInput Repository-relative application entrypoint. + * @returns Promise resolving to the write frontend html app entrypoint result. + */ +export async function writeFrontendHtmlAppEntrypoint( + metafile: Bun.BuildMetafile, + outdir: string, + expectedAppInput: string +): Promise { + const appOutput = resolvedOutput( + outdir, + frontendAppOutputKey(metafile, expectedAppInput) + ); + const publicPath = `/${appOutput.relativePath}`; + const indexPath = path.join(path.resolve(outdir), "index.html"); + const html = await readFile(indexPath, "utf8"); + const moduleScripts = html + .matchAll(SCRIPT_TAG_PATTERN) + .filter( + ([script]) => + MODULE_SCRIPT_TYPE_PATTERN.test(script) && + SCRIPT_SOURCE_ATTRIBUTE_PATTERN.test(script) + ) + .toArray(); + if (moduleScripts.length !== 1) { + throw new Error( + `Frontend index must contain exactly one module script with a source; found ${moduleScripts.length}` + ); + } + const [script] = moduleScripts[0]!; + const source = script.match(SCRIPT_SOURCE_ATTRIBUTE_PATTERN)?.[2]; + if (!source) { + throw new Error("Frontend index module script has no source"); + } + if (source !== publicPath) { + const correctedScript = script.replace( + SCRIPT_SOURCE_ATTRIBUTE_PATTERN, + () => `src="${publicPath}"` + ); + const scriptIndex = moduleScripts[0]!.index; + const correctedHtml = + html.slice(0, scriptIndex) + + correctedScript + + html.slice(scriptIndex + script.length); + await writeFile(indexPath, correctedHtml); + } + return publicPath; +} + +/** + * Resolves the static startup graph while excluding route and feature + * `dynamic-import` edges. + * @param metafile Bun build metadata for the completed browser build. + * @param expectedAppInput Repository-relative application entrypoint. + * @returns Resolved the static startup graph while excluding route and feature `dynamic-import` edges. + */ +export function initialFrontendOutputKeys( + metafile: Bun.BuildMetafile, + expectedAppInput: string +): Set { + const outputs = metafile.outputs; + const keyByNormalizedPath = new Map( + Object.keys(outputs).map((outputKey) => [ + normalizedOutputKey(outputKey), + outputKey, + ]) + ); + const resolveOutputKey = (candidate: string): string | undefined => + Object.hasOwn(outputs, candidate) + ? candidate + : keyByNormalizedPath.get(normalizedOutputKey(candidate)); + const pending = [frontendAppOutputKey(metafile, expectedAppInput)]; + const initialOutputKeys = new Set(); + + while (pending.length > 0) { + const outputKey = pending.pop(); + if (!outputKey || initialOutputKeys.has(outputKey)) continue; + const output = outputs[outputKey]; + if (!output) continue; + initialOutputKeys.add(outputKey); + + if (output.cssBundle) { + const cssOutputKey = resolveOutputKey(output.cssBundle); + if (cssOutputKey) pending.push(cssOutputKey); + } + const staticImports = output.imports.filter( + ({ kind }) => kind !== "dynamic-import" + ); + for (const imported of staticImports) { + const importedOutputKey = resolveOutputKey(imported.path); + if (importedOutputKey) pending.push(importedOutputKey); + } + } + + return initialOutputKeys; +} + +function sumOutputs( + outputs: Iterable, + field: "gzipBytes" | "rawBytes" +): number { + let total = 0; + for (const output of outputs) total += output[field]; + return total; +} + +/** + * Measures the complete and initial production JavaScript/CSS graphs. + * @param metafile Bun build metadata for the completed browser build. + * @param outdir Build output directory containing emitted assets. + * @param expectedAppInput Repository-relative application entrypoint. + * @returns Promise resolving to the measure frontend bundle result. + */ +export async function measureFrontendBundle( + metafile: Bun.BuildMetafile, + outdir: string, + expectedAppInput: string +): Promise { + const measuredOutputs = new Map(); + for (const outputKey of Object.keys(metafile.outputs)) { + const extension = path.extname(outputKey); + if (extension !== ".css" && extension !== ".js") continue; + const output = resolvedOutput(outdir, outputKey); + const contents = await readFile(output.filePath); + measuredOutputs.set(outputKey, { + gzipBytes: gzipSync(contents, { level: 9 }).byteLength, + outputPath: output.relativePath, + rawBytes: contents.byteLength, + }); + } + + const initialOutputKeys = initialFrontendOutputKeys(metafile, expectedAppInput); + const initialFiles = [...initialOutputKeys] + .map((outputKey) => measuredOutputs.get(outputKey)) + .filter((output): output is MeasuredOutput => output !== undefined) + .toSorted((left, right) => left.outputPath.localeCompare(right.outputPath)); + const initialJavaScript = initialFiles.filter(({ outputPath }) => + outputPath.endsWith(".js") + ); + const initialStylesheets = initialFiles.filter(({ outputPath }) => + outputPath.endsWith(".css") + ); + const allJavaScript: MeasuredOutput[] = []; + for (const output of measuredOutputs.values()) { + if (output.outputPath.endsWith(".js")) allJavaScript.push(output); + } + if (initialJavaScript.length === 0) { + throw new Error( + "Frontend bundle metadata did not contain an initial JavaScript graph" + ); + } + + return { + budgets: FRONTEND_BUNDLE_BUDGETS, + formatVersion: 1, + initialFiles, + measurements: { + initialJavaScriptGzipBytes: sumOutputs(initialJavaScript, "gzipBytes"), + initialJavaScriptRawBytes: sumOutputs(initialJavaScript, "rawBytes"), + initialStylesheetGzipBytes: sumOutputs(initialStylesheets, "gzipBytes"), + initialStylesheetRawBytes: sumOutputs(initialStylesheets, "rawBytes"), + largestJavaScriptGzipBytes: Math.max( + 0, + ...allJavaScript.map(({ gzipBytes }) => gzipBytes) + ), + totalJavaScriptGzipBytes: sumOutputs(allJavaScript, "gzipBytes"), + totalJavaScriptRawBytes: sumOutputs(allJavaScript, "rawBytes"), + }, + }; +} + +/** + * Fails production builds that exceed the checked-in network-size budgets. + * @param measurements Measured browser bundle sizes. + */ +export function assertFrontendBundleBudgets( + measurements: FrontendBundleMeasurements +): void { + const exceeded = Object.entries(FRONTEND_BUNDLE_BUDGETS).filter( + ([budget, limit]) => measurements[budget as FrontendBundleBudget] > limit + ); + if (exceeded.length === 0) return; + + throw new Error( + [ + "Frontend bundle budget exceeded:", + ...exceeded.map(([budget, limit]) => { + const actual = measurements[budget as FrontendBundleBudget]; + return `- ${budget}: ${actual} bytes (limit ${limit})`; + }), + ].join("\n") + ); +} + +/** + * Writes deterministic Brotli and gzip sidecars for compressible build outputs. + * @param outputPaths Emitted build assets to inspect and compress. + * @returns Promise resolving to the write precompressed frontend assets result. + */ +export async function writePrecompressedFrontendAssets( + outputPaths: Iterable +): Promise { + let compressedFileCount = 0; + + for (const outputPath of outputPaths) { + if (!COMPRESSIBLE_EXTENSIONS.has(path.extname(outputPath))) continue; + const contents = await readFile(outputPath); + if (contents.byteLength < MINIMUM_COMPRESSION_BYTES) continue; + + const brotliContents = brotliCompressSync(contents, { + params: { + [constants.BROTLI_PARAM_QUALITY]: 11, + }, + }); + if (brotliContents.byteLength < contents.byteLength) { + await writeFile(`${outputPath}.br`, brotliContents); + compressedFileCount += 1; + } + + const gzipContents = gzipSync(contents, { level: 9 }); + if (gzipContents.byteLength < contents.byteLength) { + await writeFile(`${outputPath}.gz`, gzipContents); + compressedFileCount += 1; + } + } + + return compressedFileCount; +} diff --git a/scripts/generateDocs.ts b/greenfield/scripts/generateDocs.ts similarity index 100% rename from scripts/generateDocs.ts rename to greenfield/scripts/generateDocs.ts diff --git a/greenfield/scripts/reactCompilerPlugin.ts b/greenfield/scripts/reactCompilerPlugin.ts new file mode 100644 index 000000000..e8440f97f --- /dev/null +++ b/greenfield/scripts/reactCompilerPlugin.ts @@ -0,0 +1,31 @@ +import * as babel from "@babel/core"; +import ReactCompiler from "babel-plugin-react-compiler"; + +const reactCompilerPlugin: Bun.BunPlugin = { + name: "react-compiler", + setup(build: Bun.PluginBuilder) { + build.onLoad({ filter: /\.[jt]sx$/ }, async (arguments_) => { + const input = await Bun.file(arguments_.path).text(); + const result = await babel.transformAsync(input, { + ast: false, + babelrc: false, + configFile: false, + filename: arguments_.path, + parserOpts: { plugins: ["jsx", "typescript"] }, + plugins: [[ReactCompiler, {}]], + sourceMaps: false, + }); + + if (!result?.code) { + throw new Error(`Failed to compile ${arguments_.path}`); + } + + return { + contents: result.code, + loader: arguments_.path.endsWith(".jsx") ? "jsx" : "tsx", + }; + }); + }, +}; + +export default reactCompilerPlugin; diff --git a/greenfield/scripts/runCoverage.ts b/greenfield/scripts/runCoverage.ts new file mode 100644 index 000000000..8cd86dd60 --- /dev/null +++ b/greenfield/scripts/runCoverage.ts @@ -0,0 +1,59 @@ +import { mkdir, unlink } from "node:fs/promises"; +import path from "node:path"; + +import { checkCoverageFile, requiredLineCoveragePercent } from "./checkCoverage.ts"; +import { runTestSuite } from "./runTestSuite.ts"; + +const projectRoot = path.resolve(import.meta.dir, ".."); +const coverageDirectory = path.join(projectRoot, "coverage"); +const lcovPath = path.join(coverageDirectory, "lcov.info"); +const coveredSourceRoots = Object.freeze(["src"]); +const coverageTestTargets = Object.freeze(["scripts", "src"]); + +/** @returns Completion after the exact stale LCOV artifact is absent. */ +async function removeStaleLcov(): Promise { + try { + await unlink(lcovPath); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") { + throw error; + } + } +} + +/** + * Runs the complete test set with LCOV and enforces the repository threshold. + * @returns Zero when tests, output policy, and line coverage all pass. + */ +export async function runCoverage(): Promise { + await mkdir(coverageDirectory, { recursive: true }); + await removeStaleLcov(); + + const testExitCode = await runTestSuite( + [ + "--coverage", + "--coverage-reporter", + "text", + "--coverage-reporter", + "lcov", + "--coverage-dir", + coverageDirectory, + ...coverageTestTargets, + ], + projectRoot + ); + if (testExitCode !== 0) return testExitCode; + + const summary = await checkCoverageFile( + lcovPath, + requiredLineCoveragePercent, + coveredSourceRoots, + projectRoot + ); + console.log( + `Coverage ${summary.percent.toFixed(2)}% meets required ${requiredLineCoveragePercent.toFixed(2)}% (${summary.hitLines}/${summary.foundLines} lines)` + ); + return 0; +} + +if (import.meta.main) process.exitCode = await runCoverage(); diff --git a/greenfield/scripts/runTestSuite.test.ts b/greenfield/scripts/runTestSuite.test.ts new file mode 100644 index 000000000..48fcdbf55 --- /dev/null +++ b/greenfield/scripts/runTestSuite.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const projectRoot = path.resolve(import.meta.dir, ".."); +const runnerPath = path.join(import.meta.dir, "runTestSuite.ts"); +const temporaryDirectories: string[] = []; + +interface RunnerResult { + readonly exitCode: number; + readonly stderr: string; + readonly stdout: string; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +async function runFixture(source: string): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "mira-test-runner-")); + temporaryDirectories.push(directory); + const testPath = path.join(directory, "fixture.test.ts"); + await writeFile(testPath, source, { encoding: "utf8", mode: 0o600 }); + + const result = Bun.spawnSync([process.execPath, runnerPath, testPath], { + cwd: projectRoot, + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + }); + return { + exitCode: result.exitCode, + stderr: result.stderr.toString(), + stdout: result.stdout.toString(), + }; +} + +describe("test suite runner", () => { + test("preserves a passing test result", async () => { + const result = await runFixture(` + import { expect, test } from "bun:test"; + test("passes", () => expect(2 + 2).toBe(4)); + `); + + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain("Test output policy failed"); + }); + + test("fails a passing test that emits a React act warning", async () => { + const result = await runFixture(` + import { expect, test } from "bun:test"; + test("warns", () => { + console.error("An update inside a test was not wrapped in act(...)"); + expect(true).toBe(true); + }); + `); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "Test output policy failed: React update was not wrapped in act(...)." + ); + }); + + test("preserves a failing test result", async () => { + const result = await runFixture(` + import { expect, test } from "bun:test"; + test("fails", () => expect("actual").toBe("expected")); + `); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain("actual"); + }); +}); diff --git a/greenfield/scripts/runTestSuite.ts b/greenfield/scripts/runTestSuite.ts new file mode 100644 index 000000000..3dc73997e --- /dev/null +++ b/greenfield/scripts/runTestSuite.ts @@ -0,0 +1,70 @@ +import { once } from "node:events"; +import path from "node:path"; +import type { Writable } from "node:stream"; + +import { TestOutputInspector, type TestOutputViolation } from "./testOutputPolicy.ts"; + +async function relayOutput( + stream: ReadableStream, + destination: Writable, + inspector: TestOutputInspector +): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const result = await reader.read(); + if (result.done) break; + + inspector.inspect(decoder.decode(result.value, { stream: true })); + if (!destination.write(result.value)) await once(destination, "drain"); + } + inspector.inspect(decoder.decode()); + } finally { + reader.releaseLock(); + } +} + +function firstViolation( + stdout: TestOutputInspector, + stderr: TestOutputInspector +): TestOutputViolation | undefined { + return stdout.violation ?? stderr.violation; +} + +/** + * Runs one Bun test process and enforces the repository test-output policy. + * @param arguments_ Arguments passed after `bun test`. + * @param projectRoot Repository root used as the child working directory. + * @returns The child failure code, or one when a passing child emitted forbidden output. + */ +export async function runTestSuite( + arguments_: readonly string[], + projectRoot = path.resolve(import.meta.dir, "..") +): Promise { + const child = Bun.spawn([process.execPath, "test", ...arguments_], { + cwd: projectRoot, + stderr: "pipe", + stdin: "inherit", + stdout: "pipe", + }); + const stdoutInspector = new TestOutputInspector(); + const stderrInspector = new TestOutputInspector(); + + const [exitCode] = await Promise.all([ + child.exited, + relayOutput(child.stdout, process.stdout, stdoutInspector), + relayOutput(child.stderr, process.stderr, stderrInspector), + ]); + const violation = firstViolation(stdoutInspector, stderrInspector); + if (violation !== undefined) { + process.stderr.write(`Test output policy failed: ${violation.description}.\n`); + } + + if (exitCode !== 0) return exitCode; + return violation === undefined ? 0 : 1; +} + +if (import.meta.main) { + process.exitCode = await runTestSuite(process.argv.slice(2)); +} diff --git a/scripts/sourceBoundaries/boundaryConfiguration.test.ts b/greenfield/scripts/sourceBoundaries/boundaryConfiguration.test.ts similarity index 69% rename from scripts/sourceBoundaries/boundaryConfiguration.test.ts rename to greenfield/scripts/sourceBoundaries/boundaryConfiguration.test.ts index e498369cb..b3f3c6361 100644 --- a/scripts/sourceBoundaries/boundaryConfiguration.test.ts +++ b/greenfield/scripts/sourceBoundaries/boundaryConfiguration.test.ts @@ -95,7 +95,7 @@ describe("source-boundary root configuration", () => { JSON.stringify({ compilerOptions: { paths: { "hidden/*": ["src/*"] } } }) ); await writeFile( - path.join(projectRoot, "tsconfig.server.json"), + path.join(projectRoot, "tsconfig.unreviewed.json"), JSON.stringify({ extends: "./config/base.json" }) ); await writeFile( @@ -129,8 +129,8 @@ describe("source-boundary root configuration", () => { expect( violations.some( (violation) => - violation.importer === "tsconfig.server.json" && - violation.message.includes("exact reviewed ./tsconfig.json") + violation.importer === "tsconfig.unreviewed.json" && + violation.message.includes("exact reviewed configuration") ) ).toBe(true); expect( @@ -153,11 +153,7 @@ describe("source-boundary root configuration", () => { for (const importer of [ "tsconfig.json", "tsconfig.browser.json", - "tsconfig.contracts.json", - "tsconfig.qualification.json", - "tsconfig.scripts.json", - "tsconfig.server.json", - "tsconfig.worker.json", + "tsconfig.bun.json", ] as const) { expect( violations.some( @@ -172,128 +168,56 @@ describe("source-boundary root configuration", () => { } }); - test("rejects authority and membership drift in strict TypeScript partitions", async () => { + test("rejects authority and membership drift in the three TypeScript configurations", async () => { const projectRoot = await temporaryProject(); try { - await writeFile(path.join(projectRoot, "tsconfig.json"), "{}"); - await mkdir(path.join(projectRoot, "src", "shared")); - await writeFile( - path.join(projectRoot, "src", "shared", "ambient.ts"), - "setImmediate(() => undefined);" - ); - const reviewedConfiguration = { - compilerOptions: { lib: ["ESNext"], types: [] }, - exclude: [ - "src/**/*.spec.ts", - "src/**/*.test.ts", - "src/**/__tests__/**/*.ts", - "src/**/testSupport/**/*.ts", - ], - extends: "./tsconfig.json", - include: ["src/contracts/**/*.ts", "src/shared/**/*.ts"], - }; - await writeFile( - path.join(projectRoot, "tsconfig.contracts.json"), - JSON.stringify(reviewedConfiguration) - ); - let violations = await checkSourceBoundaries(projectRoot); - expect( - violations.some( - (violation) => - violation.importer === "tsconfig.contracts.json" && - violation.message.includes("exact reviewed configuration") - ) - ).toBe(false); - expect( - violations.some( - (violation) => violation.importer === "src/shared/ambient.ts" - ) - ).toBe(false); - - const partitionDrift = [ - { - ...reviewedConfiguration, - compilerOptions: { lib: ["ESNext"], types: ["node"] }, - }, - { - ...reviewedConfiguration, - compilerOptions: { lib: ["ESNext", "DOM"], types: [] }, - }, - { - ...reviewedConfiguration, - compilerOptions: { - lib: ["ESNext"], - typeRoots: ["./types"], - types: [], - }, - }, - { - ...reviewedConfiguration, - compilerOptions: { - lib: ["ESNext"], - libReplacement: true, - types: [], - }, - }, - { - ...reviewedConfiguration, - typeAcquisition: { enable: true }, - }, - { - ...reviewedConfiguration, - include: ["src/contracts/**/*.ts"], - }, - { - ...reviewedConfiguration, - compilerOptions: { - lib: ["ESNext"], - rootDirs: ["src/shared", "src/server"], - types: [], - }, - }, - { - ...reviewedConfiguration, - compilerOptions: { - jsxImportSource: "unreviewed-runtime", - lib: ["ESNext"], - types: [], - }, - }, - { - ...reviewedConfiguration, - compilerOptions: { - lib: ["ESNext"], - strict: false, - types: [], - }, - }, - { - compilerOptions: reviewedConfiguration.compilerOptions, - exclude: reviewedConfiguration.exclude, - include: reviewedConfiguration.include, - }, - ] as const; - - for (const configuration of partitionDrift) { + const sourceRoot = path.join(import.meta.dir, "..", ".."); + for (const configName of [ + "tsconfig.json", + "tsconfig.browser.json", + "tsconfig.bun.json", + ] as const) { await writeFile( - path.join(projectRoot, "tsconfig.contracts.json"), - JSON.stringify(configuration) + path.join(projectRoot, configName), + await Bun.file(path.join(sourceRoot, configName)).text() ); - violations = await checkSourceBoundaries(projectRoot); + } + + let violations = await checkSourceBoundaries(projectRoot); + for (const configName of [ + "tsconfig.json", + "tsconfig.browser.json", + "tsconfig.bun.json", + ] as const) { expect( violations.some( (violation) => - violation.importer === "tsconfig.contracts.json" && + violation.importer === configName && violation.message.includes("exact reviewed configuration") ) - ).toBe(true); + ).toBe(false); } + + const bunConfigPath = path.join(projectRoot, "tsconfig.bun.json"); + const bunConfig = await Bun.file(bunConfigPath).text(); + await writeFile( + bunConfigPath, + bunConfig.replace(' "include": ["**/*"],\n', "") + ); + violations = await checkSourceBoundaries(projectRoot); + expect( + violations.some( + (violation) => + violation.importer === "tsconfig.bun.json" && + violation.message.includes("exact reviewed configuration") + ) + ).toBe(true); } finally { await rm(projectRoot, { force: true, recursive: true }); } }); - test("rejects inherited root authority drift", async () => { + test("rejects delegated root configuration drift", async () => { const projectRoot = await temporaryProject(); try { const reviewedRootSource = await Bun.file( @@ -310,8 +234,8 @@ describe("source-boundary root configuration", () => { ).toBe(false); const driftedRootSource = reviewedRootSource.replace( - '"moduleResolution": "bundler",', - '"moduleResolution": "bundler",\n "rootDirs": ["src/shared", "src/server"],' + '"./tsconfig.bun.json"', + '"./tsconfig.server.json"' ); expect(driftedRootSource).not.toBe(reviewedRootSource); await writeFile(path.join(projectRoot, "tsconfig.json"), driftedRootSource); diff --git a/scripts/sourceBoundaries/boundaryConfiguration.ts b/greenfield/scripts/sourceBoundaries/boundaryConfiguration.ts similarity index 76% rename from scripts/sourceBoundaries/boundaryConfiguration.ts rename to greenfield/scripts/sourceBoundaries/boundaryConfiguration.ts index 29e037cf4..669aa092b 100644 --- a/scripts/sourceBoundaries/boundaryConfiguration.ts +++ b/greenfield/scripts/sourceBoundaries/boundaryConfiguration.ts @@ -15,11 +15,12 @@ export interface BoundaryConfiguration { type ReviewedCompilerOption = boolean | string | readonly string[]; interface TypeScriptConfigurationPolicy { - readonly compilerOptions: Readonly>; + readonly compilerOptions?: Readonly>; readonly exclude?: readonly string[]; readonly extends?: "./tsconfig.json"; readonly files?: readonly string[]; - readonly include: readonly string[]; + readonly include?: readonly string[]; + readonly references?: readonly string[]; } const reviewedTypeScriptConfigurations: Readonly< @@ -30,8 +31,6 @@ const reviewedTypeScriptConfigurations: Readonly< allowImportingTsExtensions: true, erasableSyntaxOnly: true, forceConsistentCasingInFileNames: true, - jsx: "react-jsx", - lib: ["ESNext", "DOM", "DOM.Iterable"], module: "Preserve", moduleDetection: "force", moduleResolution: "bundler", @@ -47,21 +46,10 @@ const reviewedTypeScriptConfigurations: Readonly< skipLibCheck: true, strict: true, target: "ESNext", - types: ["bun-types", "node"], verbatimModuleSyntax: true, }, - include: [ - "backend/**/*.ts", - "contracts/**/*.ts", - "drizzle.config.ts", - "frontend/src/**/*", - "qualification/**/*.ts", - "scripts/**/*.ts", - "src/**/*.ts", - "src/**/*.tsx", - "tailwind.config.ts", - "test/**/*.ts", - ], + files: [], + references: ["./tsconfig.browser.json", "./tsconfig.bun.json"], }, "tsconfig.browser.json": { compilerOptions: { @@ -70,84 +58,22 @@ const reviewedTypeScriptConfigurations: Readonly< types: [], useDefineForClassFields: true, }, - exclude: [ - "src/**/*.test.ts", - "src/**/*.test.tsx", - "src/**/*.spec.ts", - "src/**/*.spec.tsx", - "src/**/__tests__/**/*.ts", - "src/**/__tests__/**/*.tsx", - "src/**/testSupport/**/*.ts", - "src/**/testSupport/**/*.tsx", - ], - include: [ - "src/app/browser.tsx", - "src/browser/**/*.ts", - "src/browser/**/*.tsx", - "src/contracts/**/*.ts", - "src/shared/**/*.ts", - ], + exclude: ["node_modules"], extends: "./tsconfig.json", - }, - "tsconfig.contracts.json": { - compilerOptions: { lib: ["ESNext"], types: [] }, - exclude: [ - "src/**/*.spec.ts", - "src/**/*.test.ts", - "src/**/__tests__/**/*.ts", - "src/**/testSupport/**/*.ts", + files: [ + "node_modules/bun-types/test.d.ts", + "src/test/types/bunCanaryMatchers.d.ts", ], - include: ["src/contracts/**/*.ts", "src/shared/**/*.ts"], - extends: "./tsconfig.json", - }, - "tsconfig.qualification.json": { - compilerOptions: { - lib: ["ESNext", "DOM", "DOM.Iterable"], - tsBuildInfoFile: "./node_modules/.tmp/tsconfig.qualification.tsbuildinfo", - types: ["bun-types", "node"], - }, - extends: "./tsconfig.json", - include: ["qualification/**/*.ts"], - }, - "tsconfig.scripts.json": { - compilerOptions: { lib: ["ESNext"], types: ["bun-types", "node"] }, - extends: "./tsconfig.json", - include: ["drizzle.config.ts", "scripts/**/*.ts", "tailwind.config.ts"], + include: ["src/browser/**/*.ts", "src/browser/**/*.tsx"], }, - "tsconfig.server.json": { + "tsconfig.bun.json": { compilerOptions: { lib: ["ESNext"], - tsBuildInfoFile: "./node_modules/.tmp/tsconfig.server.tsbuildinfo", types: ["bun-types", "node"], }, + exclude: ["node_modules", "src/browser/**/*"], extends: "./tsconfig.json", - files: [ - "src/app/dashboardServer.test.ts", - "src/app/dashboardServer.ts", - "src/app/environmentSource.ts", - "src/app/server.ts", - "src/app/trpcHttpHandler.test.ts", - "src/app/trpcHttpHandler.ts", - "src/app/trpcRequestPolicy.test.ts", - "src/app/trpcRequestPolicy.ts", - ], - include: ["src/contracts/**/*.ts", "src/server/**/*.ts", "src/shared/**/*.ts"], - }, - "tsconfig.worker.json": { - compilerOptions: { lib: ["ESNext"], types: ["bun-types", "node"] }, - exclude: [ - "src/**/*.spec.ts", - "src/**/*.test.ts", - "src/**/__tests__/**/*.ts", - "src/**/testSupport/**/*.ts", - ], - include: [ - "src/app/worker*.ts", - "src/contracts/**/*.ts", - "src/shared/**/*.ts", - "src/worker/**/*.ts", - ], - extends: "./tsconfig.json", + include: ["**/*"], }, }); @@ -181,28 +107,55 @@ function equalsCompilerOptions( ); } +function equalsReferences(value: unknown, expected: readonly string[]): boolean { + return ( + Array.isArray(value) && + value.length === expected.length && + value.every((entry, index) => { + const expectedPath = expected[index]; + return ( + isRecord(entry) && + equalsStringArray(Object.keys(entry), ["path"]) && + entry.path === expectedPath + ); + }) + ); +} + function hasReviewedTypeScriptConfiguration( tsconfig: Readonly>, policy: TypeScriptConfigurationPolicy ): boolean { - const expectedTopLevelNames = ["compilerOptions", "include"]; + const expectedTopLevelNames: string[] = []; + if (policy.compilerOptions !== undefined) { + expectedTopLevelNames.push("compilerOptions"); + } if (policy.exclude !== undefined) expectedTopLevelNames.push("exclude"); if (policy.extends !== undefined) expectedTopLevelNames.push("extends"); if (policy.files !== undefined) expectedTopLevelNames.push("files"); + if (policy.include !== undefined) expectedTopLevelNames.push("include"); + if (policy.references !== undefined) expectedTopLevelNames.push("references"); return ( equalsStringArray( Object.keys(tsconfig).toSorted(), expectedTopLevelNames.toSorted() ) && - equalsCompilerOptions(tsconfig.compilerOptions, policy.compilerOptions) && + (policy.compilerOptions === undefined + ? tsconfig.compilerOptions === undefined + : equalsCompilerOptions(tsconfig.compilerOptions, policy.compilerOptions)) && tsconfig.extends === policy.extends && - equalsStringArray(tsconfig.include, policy.include) && + (policy.include === undefined + ? tsconfig.include === undefined + : equalsStringArray(tsconfig.include, policy.include)) && (policy.files === undefined ? tsconfig.files === undefined : equalsStringArray(tsconfig.files, policy.files)) && (policy.exclude === undefined ? tsconfig.exclude === undefined - : equalsStringArray(tsconfig.exclude, policy.exclude)) + : equalsStringArray(tsconfig.exclude, policy.exclude)) && + (policy.references === undefined + ? tsconfig.references === undefined + : equalsReferences(tsconfig.references, policy.references)) ); } @@ -394,18 +347,19 @@ export async function readBoundaryConfiguration( ) ); } + const configurationPolicy = reviewedTypeScriptConfigurations[tsconfigName]; if ( tsconfig.extends !== undefined && - (tsconfigName === "tsconfig.json" || tsconfig.extends !== "./tsconfig.json") + (configurationPolicy === undefined || + tsconfig.extends !== configurationPolicy.extends) ) { violations.push( boundaryPathViolation( tsconfigName, - "Root TypeScript partitions may extend only the exact reviewed ./tsconfig.json configuration" + "TypeScript partitions may extend only their exact reviewed configuration" ) ); } - const configurationPolicy = reviewedTypeScriptConfigurations[tsconfigName]; if ( configurationPolicy !== undefined && !hasReviewedTypeScriptConfiguration(tsconfig, configurationPolicy) diff --git a/scripts/sourceBoundaries/checkerIntegration.test.ts b/greenfield/scripts/sourceBoundaries/checkerIntegration.test.ts similarity index 100% rename from scripts/sourceBoundaries/checkerIntegration.test.ts rename to greenfield/scripts/sourceBoundaries/checkerIntegration.test.ts diff --git a/scripts/sourceBoundaries/externalAuthorityPolicy.ts b/greenfield/scripts/sourceBoundaries/externalAuthorityPolicy.ts similarity index 90% rename from scripts/sourceBoundaries/externalAuthorityPolicy.ts rename to greenfield/scripts/sourceBoundaries/externalAuthorityPolicy.ts index 0b32bcd72..20aedd576 100644 --- a/scripts/sourceBoundaries/externalAuthorityPolicy.ts +++ b/greenfield/scripts/sourceBoundaries/externalAuthorityPolicy.ts @@ -7,7 +7,6 @@ import { } from "./sourceTopologyPolicy.ts"; const reviewedBareBunImportSignatures: ReadonlyMap = new Map([ - ["scripts/developmentFrontend.ts", "type:Server"], ["src/server/rawHttp/authenticationCredentials.ts", "value:CookieMap"], ]); const policyHandledNodeBuiltinNames: ReadonlySet = new Set([ @@ -39,7 +38,7 @@ function violation( } function isInternalBareSpecifier(specifier: string): boolean { - return /^(?:backend|frontend|qualification|scripts|src)\//u.test(specifier); + return /^(?:scripts|src)\//u.test(specifier); } function isInternalAliasSpecifier(specifier: string): boolean { @@ -95,6 +94,7 @@ function canonicalNodeBuiltinName(specifier: string): string | undefined { function isProcessExecutionRole(importerRole: SourceRole): boolean { return ( importerRole === "scripts" || + importerRole === "test" || importerRole === "worker" || importerRole === "worker-app" ); @@ -113,19 +113,24 @@ export function validateExternalImport( sourceImport: SourceImport ): SourceBoundaryViolation | undefined { const specifier = sourceImport.specifier; + const isEvidenceRole = importerRole === "test"; if (sourceImport.kind === "dynamic-code") { - return violation( - importer, - sourceImport, - "Production source may not use eval, Function, constructor access, module compilation, WebAssembly compilation, or string-form timer dynamic-code primitives" - ); + return isEvidenceRole + ? undefined + : violation( + importer, + sourceImport, + "Production source may not use eval, Function, constructor access, module compilation, WebAssembly compilation, or string-form timer dynamic-code primitives" + ); } if (sourceImport.kind === "shell-execution") { - return violation( - importer, - sourceImport, - "Production source may not invoke Bun.$ shell-execution authority" - ); + return isEvidenceRole + ? undefined + : violation( + importer, + sourceImport, + "Production source may not invoke Bun.$ shell-execution authority" + ); } if (sourceImport.kind === "process-execution") { return isProcessExecutionRole(importerRole) @@ -136,7 +141,11 @@ export function validateExternalImport( "Only scripts and worker source may invoke reviewed process-execution authority" ); } - if (sourceImport.kind === "module-loader" && specifier === undefined) { + if ( + sourceImport.kind === "module-loader" && + specifier === undefined && + !isEvidenceRole + ) { return violation( importer, sourceImport, @@ -144,6 +153,7 @@ export function validateExternalImport( ); } if (specifier === undefined) { + if (isEvidenceRole) return undefined; return violation( importer, sourceImport, @@ -171,6 +181,7 @@ export function validateExternalImport( "Repository source imports must use an explicit relative specifier" ); } + if (isEvidenceRole) return undefined; if (/^bun:test(?:\/|$)/u.test(specifier)) { return violation( importer, @@ -251,10 +262,7 @@ export function validateExternalImport( "Only scripts and worker source may import child-process APIs" ); } - if ( - (importerRole === "browser" || importerRole === "browser-app") && - isForbiddenBrowserPackage(specifier) - ) { + if (importerRole === "browser" && isForbiddenBrowserPackage(specifier)) { return violation( importer, sourceImport, diff --git a/scripts/sourceBoundaries/importGraph.test.ts b/greenfield/scripts/sourceBoundaries/importGraph.test.ts similarity index 100% rename from scripts/sourceBoundaries/importGraph.test.ts rename to greenfield/scripts/sourceBoundaries/importGraph.test.ts diff --git a/scripts/sourceBoundaries/importGraph.ts b/greenfield/scripts/sourceBoundaries/importGraph.ts similarity index 100% rename from scripts/sourceBoundaries/importGraph.ts rename to greenfield/scripts/sourceBoundaries/importGraph.ts diff --git a/scripts/sourceBoundaries/importTargetValidation.test.ts b/greenfield/scripts/sourceBoundaries/importTargetValidation.test.ts similarity index 83% rename from scripts/sourceBoundaries/importTargetValidation.test.ts rename to greenfield/scripts/sourceBoundaries/importTargetValidation.test.ts index 1d229e040..71c7d2df1 100644 --- a/scripts/sourceBoundaries/importTargetValidation.test.ts +++ b/greenfield/scripts/sourceBoundaries/importTargetValidation.test.ts @@ -1,10 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, rm, symlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { checkSourceBoundaries } from "../checkSourceBoundaries.ts"; -import { legacyScriptImportAllowlist, legacyScriptImportKey } from "./policy.ts"; import { temporaryProject } from "./testSupport.ts"; describe("source-boundary import target validation", () => { @@ -224,53 +222,27 @@ describe("source-boundary import target validation", () => { } }); - test("validates exact legacy allowlist targets without following symlinks", async () => { + test("rejects repository escapes from browser and integration tests", async () => { const projectRoot = await temporaryProject(); - const externalRoot = await mkdtemp(path.join(tmpdir(), "mira-legacy-external-")); try { - const importer = "scripts/buildBackend.ts"; - const specifier = "../backend/src/services/releases/runtime.ts"; - const allowlistKey = legacyScriptImportKey(importer, { - kind: "import", - line: 1, - specifier, - }); - expect(allowlistKey).toBeDefined(); - expect(legacyScriptImportAllowlist.has(allowlistKey ?? "")).toBe(true); await writeFile( - path.join(projectRoot, "scripts", "buildBackend.ts"), - `import "${specifier}";` + path.join(projectRoot, "src", "browser", "escape.test.ts"), + 'import "../../../outside.ts";' ); - await mkdir( - path.join(projectRoot, "backend", "src", "services", "releases"), - { recursive: true } - ); - const externalTarget = path.join(externalRoot, "runtime.ts"); - await writeFile(externalTarget, "export const runtime = true;"); - await symlink( - externalTarget, - path.join( - projectRoot, - "backend", - "src", - "services", - "releases", - "runtime.ts" - ) + await writeFile( + path.join(projectRoot, "src", "test", "integration", "escape.ts"), + 'import "../../../../outside.ts";' ); const violations = await checkSourceBoundaries(projectRoot); expect( - violations.some( - (violation) => - violation.importer === "scripts/buildBackend.ts" && - violation.message.includes("may not contain symbolic links") - ) - ).toBe(true); + violations + .filter((violation) => violation.message.includes("may not escape")) + .map(({ importer }) => importer) + ).toEqual(["src/browser/escape.test.ts", "src/test/integration/escape.ts"]); } finally { await rm(projectRoot, { force: true, recursive: true }); - await rm(externalRoot, { force: true, recursive: true }); } }); }); diff --git a/scripts/sourceBoundaries/importTargetValidation.ts b/greenfield/scripts/sourceBoundaries/importTargetValidation.ts similarity index 71% rename from scripts/sourceBoundaries/importTargetValidation.ts rename to greenfield/scripts/sourceBoundaries/importTargetValidation.ts index e79555649..ab4ba2ed9 100644 --- a/scripts/sourceBoundaries/importTargetValidation.ts +++ b/greenfield/scripts/sourceBoundaries/importTargetValidation.ts @@ -4,7 +4,7 @@ import path from "node:path"; import type { SourceImport } from "./importGraph.ts"; import type { SourceBoundaryViolation } from "./policyTypes.ts"; import { isContainedPath, repositoryPath } from "./sourceBoundaryPaths.ts"; -import { isTestPath } from "./sourceTopologyPolicy.ts"; +import { sourceRole } from "./sourceTopologyPolicy.ts"; interface TargetValidationMessages { readonly escaped: string; @@ -43,43 +43,6 @@ async function validateContainedTarget( return undefined; } -/** - * Validates that an exact legacy allowlist target remains a contained regular file. - * @param projectRoot Absolute repository root. - * @param realProjectRoot Canonical repository root used for containment. - * @param allowlistKey Stable importer/target allowlist key. - * @returns Target violation when the reviewed target has drifted. - */ -export async function validateLegacyAllowlistTarget( - projectRoot: string, - realProjectRoot: string, - allowlistKey: string -): Promise { - const separatorIndex = allowlistKey.indexOf("\0"); - const importer = allowlistKey.slice(0, separatorIndex); - const target = allowlistKey.slice(separatorIndex + 1); - const lexicalProjectRoot = path.resolve(projectRoot); - const violation = (message: string): SourceBoundaryViolation => ({ - importer, - line: 1, - message, - specifier: target, - }); - return validateContainedTarget( - lexicalProjectRoot, - realProjectRoot, - target, - { - escaped: "Legacy allowlisted target real path escapes the repository", - invalidType: "Legacy allowlisted target must be a regular repository file", - missing: "Legacy allowlisted target is missing or unreadable", - symbolicLink: - "Legacy allowlisted target paths may not contain symbolic links", - }, - violation - ); -} - function importTargetViolation( importer: string, sourceImport: SourceImport, @@ -111,7 +74,6 @@ export async function validateExactRelativeImportTarget( ): Promise { const specifier = sourceImport.specifier; if ( - isTestPath(importer) || specifier === undefined || !specifier.startsWith(".") || /[%?#\\]/u.test(specifier) @@ -122,6 +84,9 @@ export async function validateExactRelativeImportTarget( const joinedTarget = path.posix.join(importerDirectory, specifier); const target = repositoryPath(path.posix.normalize(joinedTarget)); if (target === ".." || target.startsWith("../")) return undefined; + if (path.posix.extname(target) === "" && sourceRole(importer) === "test") { + return undefined; + } const lexicalProjectRoot = path.resolve(projectRoot); return validateContainedTarget( diff --git a/scripts/sourceBoundaries/lintConfiguration.test.ts b/greenfield/scripts/sourceBoundaries/lintConfiguration.test.ts similarity index 68% rename from scripts/sourceBoundaries/lintConfiguration.test.ts rename to greenfield/scripts/sourceBoundaries/lintConfiguration.test.ts index 8e276b9c6..0ae272b2f 100644 --- a/scripts/sourceBoundaries/lintConfiguration.test.ts +++ b/greenfield/scripts/sourceBoundaries/lintConfiguration.test.ts @@ -11,10 +11,20 @@ interface LintResult { async function runOxlint( executable: string, projectRoot: string, + tsconfig: string, files: readonly string[] ): Promise { const process = Bun.spawn( - [executable, "--config", ".oxlintrc.json", "--format", "unix", ...files], + [ + executable, + "--config", + ".oxlintrc.json", + "--format", + "unix", + "--tsconfig", + tsconfig, + ...files, + ], { cwd: projectRoot, env: { ...globalThis.process.env, NO_COLOR: "1" }, @@ -39,6 +49,16 @@ describe("effective source-boundary lint configuration", () => { path.join(repositoryRoot, ".oxlintrc.json"), path.join(fixtureRoot, ".oxlintrc.json") ); + for (const configurationName of [ + "tsconfig.json", + "tsconfig.browser.json", + "tsconfig.bun.json", + ] as const) { + await copyFile( + path.join(repositoryRoot, configurationName), + path.join(fixtureRoot, configurationName) + ); + } await symlink( path.join(repositoryRoot, "node_modules"), path.join(fixtureRoot, "node_modules"), @@ -47,12 +67,30 @@ describe("effective source-boundary lint configuration", () => { await mkdir(path.join(fixtureRoot, "src", "browser"), { recursive: true, }); - await mkdir(path.join(fixtureRoot, "frontend", "src"), { + await mkdir(path.join(fixtureRoot, "src", "test", "types"), { recursive: true, }); await copyFile( - path.join(repositoryRoot, "frontend", "src", "index.css"), - path.join(fixtureRoot, "frontend", "src", "index.css") + path.join( + repositoryRoot, + "src", + "test", + "types", + "bunCanaryMatchers.d.ts" + ), + path.join(fixtureRoot, "src", "test", "types", "bunCanaryMatchers.d.ts") + ); + await copyFile( + path.join( + repositoryRoot, + "src", + "browser", + "testSupport", + "frontendBuildFixture", + "src", + "index.css" + ), + path.join(fixtureRoot, "src", "browser", "index.css") ); await mkdir(path.join(fixtureRoot, "src", "server"), { recursive: true, @@ -73,11 +111,24 @@ describe("effective source-boundary lint configuration", () => { 'console.log("forbidden");\n' ); - const result = await runOxlint( - path.join(repositoryRoot, "node_modules", ".bin", "oxlint"), - fixtureRoot, - ["src/browser/browserBoundary.ts", "src/worker/workerConsole.ts"] + const executable = path.join( + repositoryRoot, + "node_modules", + ".bin", + "oxlint" ); + const [browserResult, workerResult] = await Promise.all([ + runOxlint(executable, fixtureRoot, "tsconfig.browser.json", [ + "src/browser/browserBoundary.ts", + ]), + runOxlint(executable, fixtureRoot, "tsconfig.bun.json", [ + "src/worker/workerConsole.ts", + ]), + ]); + const result = { + exitCode: browserResult.exitCode + workerResult.exitCode, + output: `${browserResult.output}\n${workerResult.output}`, + }; expect(result.exitCode).not.toBe(0); expect(result.output).toContain("'memo' import from 'react' is restricted"); @@ -108,8 +159,9 @@ describe("effective source-boundary lint configuration", () => { ) ); const testResult = await runOxlint( - path.join(repositoryRoot, "node_modules", ".bin", "oxlint"), + executable, fixtureRoot, + "tsconfig.browser.json", [ "src/browser/browserBoundary.spec.ts", "src/browser/__tests__/browserBoundary.ts", @@ -126,5 +178,5 @@ describe("effective source-boundary lint configuration", () => { } finally { await rm(fixtureRoot, { force: true, recursive: true }); } - }); + }, 30_000); }); diff --git a/scripts/sourceBoundaries/policy.test.ts b/greenfield/scripts/sourceBoundaries/policy.test.ts similarity index 88% rename from scripts/sourceBoundaries/policy.test.ts rename to greenfield/scripts/sourceBoundaries/policy.test.ts index 5162a3187..9cb4e8c16 100644 --- a/scripts/sourceBoundaries/policy.test.ts +++ b/greenfield/scripts/sourceBoundaries/policy.test.ts @@ -3,7 +3,6 @@ import { fileURLToPath } from "node:url"; import { checkSourceBoundaries } from "../checkSourceBoundaries.ts"; import { - legacyScriptImportAllowlist, validateDeclaredPackageImport, validateSourceAmbientRuntimeDeclaration, validateSourceEnvironmentAccess, @@ -131,11 +130,13 @@ describe("source-boundary policy", () => { expect(validateSourceFile("src/contracts/escape.d.ts")?.message).toContain( "declaration files are forbidden" ); + expect( + validateSourceFile("src/test/types/bunCanaryMatchers.d.ts") + ).toBeUndefined(); }); test("permits TSX only in the strict browser graph", () => { expect(validateSourceFile("src/browser/view.tsx")).toBeUndefined(); - expect(validateSourceFile("src/app/browser.tsx")).toBeUndefined(); for (const file of [ "drizzle.config.tsx", "scripts/generate.tsx", @@ -318,14 +319,6 @@ describe("source-boundary policy", () => { }); test("allows only exact reviewed bare Bun import bindings", () => { - expect( - validateSourceImport("scripts/developmentFrontend.ts", { - kind: "import", - importedBindings: [{ imported: "Server", typeOnly: true }], - line: 1, - specifier: "bun", - }) - ).toBeUndefined(); expect( validateSourceImport("src/server/rawHttp/authenticationCredentials.ts", { kind: "import", @@ -568,47 +561,6 @@ describe("source-boundary policy", () => { ); }); - test("freezes the exact legacy script coexistence allowlist", () => { - expect( - [...legacyScriptImportAllowlist] - .map((entry) => entry.replace("\0", " -> ")) - .toSorted() - ).toMatchInlineSnapshot(` - [ - "scripts/buildBackend.ts -> backend/src/services/releases/runtime.ts", - "scripts/developmentFrontend.ts -> frontend/index.html", - "scripts/developmentFrontend.ts -> frontend/src/lib/developmentProxyHeaders.ts", - "scripts/developmentStack.ts -> backend/src/development/developmentEnvironment.ts", - "scripts/developmentStack.ts -> backend/src/development/developmentRuntime.ts", - "scripts/developmentStack.ts -> backend/src/development/developmentStackConfig.ts", - "scripts/developmentStack.ts -> backend/src/development/developmentState.ts", - "scripts/frontendBuild.ts -> backend/src/services/releases/runtime.ts", - "scripts/productionBootstrap.ts -> backend/src/database/connection.ts", - "scripts/productionBootstrap.ts -> backend/src/lib/dashboardPaths.ts", - "scripts/productionBootstrap.ts -> backend/src/lib/processes.ts", - "scripts/productionBootstrap.ts -> backend/src/lib/systemdProperties.ts", - "scripts/productionBootstrap.ts -> backend/src/releaseLifecycle.ts", - "scripts/productionBootstrap.ts -> backend/src/services/releases/deployment.ts", - "scripts/productionBootstrap.ts -> backend/src/services/releases/releaseActivation.ts", - "scripts/productionBootstrap.ts -> backend/src/services/releases/systemdPolicy.ts", - "scripts/qualification/legacyBackendRouteProbe.ts -> backend/src/routes/registry.ts", - "scripts/writeReleaseManifest.ts -> backend/src/services/releases/manifestArtifacts.ts", - ] - `); - expect( - validateSourceImport( - "scripts/buildBackend.ts", - staticImport("../backend/src/services/releases/runtime.ts") - ) - ).toBeUndefined(); - expect( - validateSourceImport( - "scripts/newTool.ts", - staticImport("../backend/src/services/releases/runtime.ts") - )?.message - ).toContain("New script imports"); - }); - test("accepts the complete current repository graph", async () => { const projectRootUrl = new URL("../..", import.meta.url); const violations = await checkSourceBoundaries(fileURLToPath(projectRootUrl)); diff --git a/scripts/sourceBoundaries/policy.ts b/greenfield/scripts/sourceBoundaries/policy.ts similarity index 84% rename from scripts/sourceBoundaries/policy.ts rename to greenfield/scripts/sourceBoundaries/policy.ts index a9b07596f..b7487ddba 100644 --- a/scripts/sourceBoundaries/policy.ts +++ b/greenfield/scripts/sourceBoundaries/policy.ts @@ -8,8 +8,6 @@ import { environmentSourceConsumers, environmentSourceFile, isTestPath, - legacyEdge, - legacyScriptImportAllowlist, normalizeRepositoryPath, relativeImportTarget, sourceRole, @@ -17,7 +15,7 @@ import { export { validateDeclaredPackageImport } from "./externalAuthorityPolicy.ts"; export type { SourceBoundaryViolation } from "./policyTypes.ts"; -export { isTestPath, legacyScriptImportAllowlist } from "./sourceTopologyPolicy.ts"; +export { isTestPath } from "./sourceTopologyPolicy.ts"; const reviewedRelativeExtensions: ReadonlySet = new Set([ ".css", @@ -27,6 +25,10 @@ const reviewedRelativeExtensions: ReadonlySet = new Set([ ".tsx", ]); +const reviewedDeclarationFiles: ReadonlySet = new Set([ + "src/test/types/bunCanaryMatchers.d.ts", +]); + function violation( importer: string, sourceImport: SourceImport, @@ -60,7 +62,10 @@ export function validateSourceFile( "Every repository-root executable source file must belong to an explicit reviewed process role", }; } - if (normalizedImporter.endsWith(".d.ts")) { + if ( + normalizedImporter.endsWith(".d.ts") && + !reviewedDeclarationFiles.has(normalizedImporter) + ) { return { importer: normalizedImporter, line: 1, @@ -79,7 +84,7 @@ export function validateSourceFile( if ( normalizedImporter.endsWith(".tsx") && importerRole !== "browser" && - importerRole !== "browser-app" + importerRole !== "test" ) { return { importer: normalizedImporter, @@ -162,7 +167,9 @@ export function validateSourceRuntimeAuthorityEscape( line: number ): SourceBoundaryViolation | undefined { const normalizedImporter = normalizeRepositoryPath(importer); - if (sourceRole(normalizedImporter) === "test") return undefined; + if (sourceRole(normalizedImporter) === "test") { + return undefined; + } return { importer: normalizedImporter, line, @@ -211,33 +218,6 @@ export function validateSourceAmbientRuntimeDeclaration( }; } -/** - * Identifies a script edge into the legacy tree for exact allowlist accounting. - * @param importer Repository-relative importing file. - * @param sourceImport Parsed import or re-export. - * @returns Stable allowlist key, or `undefined` for a non-legacy edge. - */ -export function legacyScriptImportKey( - importer: string, - sourceImport: SourceImport -): string | undefined { - const normalizedImporter = normalizeRepositoryPath(importer); - const specifier = sourceImport.specifier; - if ( - sourceRole(normalizedImporter) !== "scripts" || - specifier === undefined || - !specifier.startsWith(".") - ) { - return undefined; - } - - const target = relativeImportTarget(normalizedImporter, specifier); - const targetRole = sourceRole(target); - return targetRole === "legacy-backend" || targetRole === "legacy-frontend" - ? legacyEdge(normalizedImporter, target) - : undefined; -} - /** * Applies the path and runtime policy to one parsed module edge. * @param importer Repository-relative importing file. @@ -250,7 +230,7 @@ export function validateSourceImport( ): SourceBoundaryViolation | undefined { const normalizedImporter = normalizeRepositoryPath(importer); const importerRole = sourceRole(normalizedImporter); - if (importerRole === "test") return undefined; + const isEvidenceRole = importerRole === "test"; const specifier = sourceImport.specifier; if (specifier?.includes("%")) { @@ -294,21 +274,25 @@ export function validateSourceImport( ); } const targetExtension = path.posix.extname(target); - if (targetExtension === "" || targetExtension === ".") { + if ((targetExtension === "" || targetExtension === ".") && !isEvidenceRole) { return violation( normalizedImporter, sourceImport, "Production relative imports must include an explicit file extension to prevent runtime resolver fallback" ); } - if (!reviewedRelativeExtensions.has(targetExtension)) { + if ( + targetExtension !== "" && + targetExtension !== "." && + !reviewedRelativeExtensions.has(targetExtension) + ) { return violation( normalizedImporter, sourceImport, "Production relative imports must use a reviewed explicit .ts, .tsx, .css, .html, or .json extension" ); } - if (isTestPath(target)) { + if (isTestPath(target) && !isEvidenceRole) { return violation( normalizedImporter, sourceImport, @@ -318,7 +302,7 @@ export function validateSourceImport( const targetRole = sourceRole(target); if (targetRole === "environment-source") { - return environmentSourceConsumers.has(normalizedImporter) + return isEvidenceRole || environmentSourceConsumers.has(normalizedImporter) ? undefined : violation( normalizedImporter, @@ -326,18 +310,6 @@ export function validateSourceImport( "Only the web and worker composition roots may import the runtime environment source" ); } - if ( - importerRole === "scripts" && - (targetRole === "legacy-backend" || targetRole === "legacy-frontend") - ) { - return legacyScriptImportAllowlist.has(legacyEdge(normalizedImporter, target)) - ? undefined - : violation( - normalizedImporter, - sourceImport, - "New script imports into the legacy backend or frontend are forbidden" - ); - } if (targetRole === "unclassified-app") { return violation( normalizedImporter, diff --git a/scripts/sourceBoundaries/policyTypes.ts b/greenfield/scripts/sourceBoundaries/policyTypes.ts similarity index 100% rename from scripts/sourceBoundaries/policyTypes.ts rename to greenfield/scripts/sourceBoundaries/policyTypes.ts diff --git a/scripts/sourceBoundaries/runtimeAuthorityAnalysis.ts b/greenfield/scripts/sourceBoundaries/runtimeAuthorityAnalysis.ts similarity index 100% rename from scripts/sourceBoundaries/runtimeAuthorityAnalysis.ts rename to greenfield/scripts/sourceBoundaries/runtimeAuthorityAnalysis.ts diff --git a/scripts/sourceBoundaries/runtimeCodeAuthorityAnalysis.ts b/greenfield/scripts/sourceBoundaries/runtimeCodeAuthorityAnalysis.ts similarity index 100% rename from scripts/sourceBoundaries/runtimeCodeAuthorityAnalysis.ts rename to greenfield/scripts/sourceBoundaries/runtimeCodeAuthorityAnalysis.ts diff --git a/scripts/sourceBoundaries/runtimeOwnerAnalysis.ts b/greenfield/scripts/sourceBoundaries/runtimeOwnerAnalysis.ts similarity index 100% rename from scripts/sourceBoundaries/runtimeOwnerAnalysis.ts rename to greenfield/scripts/sourceBoundaries/runtimeOwnerAnalysis.ts diff --git a/scripts/sourceBoundaries/sourceAst.ts b/greenfield/scripts/sourceBoundaries/sourceAst.ts similarity index 100% rename from scripts/sourceBoundaries/sourceAst.ts rename to greenfield/scripts/sourceBoundaries/sourceAst.ts diff --git a/scripts/sourceBoundaries/sourceBoundaryPaths.ts b/greenfield/scripts/sourceBoundaries/sourceBoundaryPaths.ts similarity index 100% rename from scripts/sourceBoundaries/sourceBoundaryPaths.ts rename to greenfield/scripts/sourceBoundaries/sourceBoundaryPaths.ts diff --git a/scripts/sourceBoundaries/sourceDirectives.ts b/greenfield/scripts/sourceBoundaries/sourceDirectives.ts similarity index 100% rename from scripts/sourceBoundaries/sourceDirectives.ts rename to greenfield/scripts/sourceBoundaries/sourceDirectives.ts diff --git a/scripts/sourceBoundaries/sourceDiscovery.test.ts b/greenfield/scripts/sourceBoundaries/sourceDiscovery.test.ts similarity index 100% rename from scripts/sourceBoundaries/sourceDiscovery.test.ts rename to greenfield/scripts/sourceBoundaries/sourceDiscovery.test.ts diff --git a/scripts/sourceBoundaries/sourceDiscovery.ts b/greenfield/scripts/sourceBoundaries/sourceDiscovery.ts similarity index 98% rename from scripts/sourceBoundaries/sourceDiscovery.ts rename to greenfield/scripts/sourceBoundaries/sourceDiscovery.ts index 74c9b9d28..9a102c0ae 100644 --- a/scripts/sourceBoundaries/sourceDiscovery.ts +++ b/greenfield/scripts/sourceBoundaries/sourceDiscovery.ts @@ -14,19 +14,14 @@ const nestedResolverMetadataPattern = const reviewedRootDirectories: ReadonlySet = new Set([ ".git", ".github", - "backend", - "contracts", "coverage", + "data", "dist", "docs", - "frontend", "migrations", "node_modules", - "qualification", "scripts", "src", - "systemd", - "test", ]); /** Discovered executable sources plus fail-closed repository-layout findings. */ diff --git a/scripts/sourceBoundaries/sourceTopologyPolicy.ts b/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts similarity index 52% rename from scripts/sourceBoundaries/sourceTopologyPolicy.ts rename to greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts index 812ff80c4..3e697217d 100644 --- a/scripts/sourceBoundaries/sourceTopologyPolicy.ts +++ b/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts @@ -3,11 +3,8 @@ import path from "node:path"; /** Explicit process or architectural role assigned to a scanned source path. */ export type SourceRole = | "browser" - | "browser-app" | "contracts" | "environment-source" - | "legacy-backend" - | "legacy-frontend" | "scripts" | "server" | "shared" @@ -40,83 +37,18 @@ export const environmentSourceConsumers: ReadonlySet = new Set([ "src/app/worker.ts", ]); -/** - * Creates a stable key for one script edge into a legacy implementation. - * @param importer Normalized script importer. - * @param target Normalized legacy target. - * @returns Stable allowlist key. - */ -export function legacyEdge(importer: string, target: string): string { - return `${importer}\0${target}`; -} - -/** Exact coexistence edges into the legacy implementation. New edges are rejected. */ -export const legacyScriptImportAllowlist: ReadonlySet = new Set([ - legacyEdge("scripts/buildBackend.ts", "backend/src/services/releases/runtime.ts"), - legacyEdge("scripts/developmentFrontend.ts", "frontend/index.html"), - legacyEdge( - "scripts/developmentFrontend.ts", - "frontend/src/lib/developmentProxyHeaders.ts" - ), - legacyEdge( - "scripts/developmentStack.ts", - "backend/src/development/developmentEnvironment.ts" - ), - legacyEdge( - "scripts/developmentStack.ts", - "backend/src/development/developmentRuntime.ts" - ), - legacyEdge( - "scripts/developmentStack.ts", - "backend/src/development/developmentStackConfig.ts" - ), - legacyEdge( - "scripts/developmentStack.ts", - "backend/src/development/developmentState.ts" - ), - legacyEdge("scripts/frontendBuild.ts", "backend/src/services/releases/runtime.ts"), - legacyEdge("scripts/productionBootstrap.ts", "backend/src/database/connection.ts"), - legacyEdge("scripts/productionBootstrap.ts", "backend/src/lib/dashboardPaths.ts"), - legacyEdge("scripts/productionBootstrap.ts", "backend/src/lib/processes.ts"), - legacyEdge("scripts/productionBootstrap.ts", "backend/src/lib/systemdProperties.ts"), - legacyEdge("scripts/productionBootstrap.ts", "backend/src/releaseLifecycle.ts"), - legacyEdge( - "scripts/productionBootstrap.ts", - "backend/src/services/releases/deployment.ts" - ), - legacyEdge( - "scripts/productionBootstrap.ts", - "backend/src/services/releases/releaseActivation.ts" - ), - legacyEdge( - "scripts/productionBootstrap.ts", - "backend/src/services/releases/systemdPolicy.ts" - ), - legacyEdge( - "scripts/qualification/legacyBackendRouteProbe.ts", - "backend/src/routes/registry.ts" - ), - legacyEdge( - "scripts/writeReleaseManifest.ts", - "backend/src/services/releases/manifestArtifacts.ts" - ), -]); - /** Reviewed dependency-direction matrix for every source role. */ export const allowedTargets: Readonly>> = { browser: new Set(["browser", "contracts", "shared"]), - "browser-app": new Set(["browser", "browser-app", "contracts", "shared"]), contracts: new Set(["contracts", "shared"]), "environment-source": new Set(["shared"]), - "legacy-backend": new Set(), - "legacy-frontend": new Set(), scripts: new Set(["contracts", "scripts", "shared"]), server: new Set(["contracts", "server", "shared"]), shared: new Set(["shared"]), test: new Set([ "browser", - "browser-app", "contracts", + "environment-source", "scripts", "server", "shared", @@ -133,8 +65,8 @@ export const allowedTargets: Readonly }; /** - * Normalizes a repository-relative source path for policy evaluation. - * @param filePath Candidate repository-relative path. + * Normalizes a project-relative source path for policy evaluation. + * @param filePath Candidate project-relative path. * @returns Canonical forward-slash path without a leading dot segment. */ export function normalizeRepositoryPath(filePath: string): string { @@ -142,10 +74,10 @@ export function normalizeRepositoryPath(filePath: string): string { } /** - * Resolves a relative import lexically within repository path semantics. - * @param importer Normalized repository-relative importer. + * Resolves a relative import lexically within project path semantics. + * @param importer Normalized project-relative importer. * @param specifier Relative module specifier. - * @returns Normalized repository-relative lexical target. + * @returns Normalized project-relative lexical target. */ export function relativeImportTarget(importer: string, specifier: string): string { const importerDirectory = path.posix.dirname(importer); @@ -154,9 +86,9 @@ export function relativeImportTarget(importer: string, specifier: string): strin } /** - * Identifies source and target paths reserved for tests or test support. - * @param filePath Normalized repository-relative path. - * @returns Whether the path belongs to test-only source. + * Identifies paths reserved for tests or test support. + * @param filePath Normalized project-relative source path. + * @returns Whether the path belongs to test code or support. */ export function isTestPath(filePath: string): boolean { return ( @@ -166,9 +98,9 @@ export function isTestPath(filePath: string): boolean { } /** - * Classifies one normalized repository path into its explicit source role. - * @param filePath Normalized repository-relative path. - * @returns Explicit process or architectural source role. + * Classifies one normalized project path into its explicit source role. + * @param filePath Normalized project-relative source path. + * @returns The reviewed source role for the path. */ export function sourceRole(filePath: string): SourceRole { if (applicationCompositionTestFiles.has(filePath)) return "test"; @@ -178,7 +110,6 @@ export function sourceRole(filePath: string): SourceRole { if (isTestPath(filePath)) return "test"; if (filePath === environmentSourceFile) return "environment-source"; if (webApplicationFiles.has(filePath)) return "web-app"; - if (filePath === "src/app/browser.tsx") return "browser-app"; if (filePath === "src/app/worker.ts") return "worker-app"; if (filePath.startsWith("src/app/")) return "unclassified-app"; if (filePath.startsWith("src/browser/")) return "browser"; @@ -193,7 +124,5 @@ export function sourceRole(filePath: string): SourceRole { ) { return "scripts"; } - if (filePath.startsWith("backend/")) return "legacy-backend"; - if (filePath.startsWith("frontend/")) return "legacy-frontend"; return "unknown"; } diff --git a/scripts/sourceBoundaries/testSupport.ts b/greenfield/scripts/sourceBoundaries/testSupport.ts similarity index 85% rename from scripts/sourceBoundaries/testSupport.ts rename to greenfield/scripts/sourceBoundaries/testSupport.ts index 1b89b3eff..8f17187da 100644 --- a/scripts/sourceBoundaries/testSupport.ts +++ b/greenfield/scripts/sourceBoundaries/testSupport.ts @@ -9,6 +9,9 @@ import path from "node:path"; export async function temporaryProject(): Promise { const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-source-boundary-")); await mkdir(path.join(projectRoot, "scripts")); + await mkdir(path.join(projectRoot, "src", "test", "integration"), { + recursive: true, + }); await mkdir(path.join(projectRoot, "src", "browser"), { recursive: true }); await writeFile(path.join(projectRoot, "package.json"), "{}"); return projectRoot; diff --git a/greenfield/scripts/testOutputPolicy.test.ts b/greenfield/scripts/testOutputPolicy.test.ts new file mode 100644 index 000000000..313f09ea3 --- /dev/null +++ b/greenfield/scripts/testOutputPolicy.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; + +import { findTestOutputViolation, TestOutputInspector } from "./testOutputPolicy.ts"; + +describe("test output policy", () => { + test.each([ + [ + "React missing act wrapper", + "Warning: An update to Dashboard inside a test was not wrapped in act(...).", + "React update was not wrapped in act(...)", + ], + [ + "React act environment", + "The current testing environment is not configured to support act(...)", + "React act environment is not configured", + ], + [ + "Bun main-thread panic", + "panic(main thread): assertion failed", + "Bun main thread panicked", + ], + [ + "Bun crash banner", + "Oh no: Bun has crashed. This indicates a bug in Bun.", + "Bun crashed", + ], + ])("rejects %s", (_name, output, description) => { + expect(findTestOutputViolation(output)).toEqual({ description }); + }); + + test("allows unrelated warnings", () => { + expect(findTestOutputViolation("Warning: fixture intentionally retried")).toBe( + undefined + ); + }); + + test("finds a warning split across stream chunks", () => { + const inspector = new TestOutputInspector(); + + inspector.inspect("Warning: update was not wrap"); + expect(inspector.violation).toBe(undefined); + + inspector.inspect("ped in act(...)\n"); + expect(inspector.violation).toEqual({ + description: "React update was not wrapped in act(...)", + }); + }); +}); diff --git a/greenfield/scripts/testOutputPolicy.ts b/greenfield/scripts/testOutputPolicy.ts new file mode 100644 index 000000000..96b705c44 --- /dev/null +++ b/greenfield/scripts/testOutputPolicy.ts @@ -0,0 +1,65 @@ +/** One disallowed message found in test-process output. */ +export interface TestOutputViolation { + readonly description: string; +} + +interface TestOutputRule extends TestOutputViolation { + readonly pattern: RegExp; +} + +const testOutputRules: readonly TestOutputRule[] = Object.freeze([ + { + description: "React update was not wrapped in act(...)", + pattern: /not wrapped in act/i, + }, + { + description: "React act environment is not configured", + pattern: /current testing environment is not configured to support act/i, + }, + { + description: "Bun main thread panicked", + pattern: /panic\(main thread\):/i, + }, + { + description: "Bun crashed", + pattern: /oh no: Bun has crashed/i, + }, +]); + +const retainedOutputTailCharacters = 256; + +/** + * Finds a warning or runtime failure that is forbidden in otherwise passing test output. + * @param output Decoded stdout or stderr text. + * @returns The first matched policy violation, if any. + */ +export function findTestOutputViolation(output: string): TestOutputViolation | undefined { + const rule = testOutputRules.find((candidate) => candidate.pattern.test(output)); + return rule === undefined ? undefined : { description: rule.description }; +} + +/** + * Incrementally checks one continuous output stream without retaining the full test log. + * A small tail preserves matches split across adjacent stream chunks. + */ +export class TestOutputInspector { + #tail = ""; + #violation: TestOutputViolation | undefined; + + /** @returns First violation found in this stream. */ + get violation(): TestOutputViolation | undefined { + return this.#violation; + } + + /** + * Adds the next decoded output chunk to the policy check. + * @param chunk Text from one continuous process-output stream. + */ + inspect(chunk: string): void { + if (this.#violation !== undefined || chunk.length === 0) return; + + const candidate = this.#tail + chunk; + this.#violation = findTestOutputViolation(candidate); + this.#tail = candidate.slice(-retainedOutputTailCharacters); + } +} diff --git a/src/app/dashboardServer.test.ts b/greenfield/src/app/dashboardServer.test.ts similarity index 100% rename from src/app/dashboardServer.test.ts rename to greenfield/src/app/dashboardServer.test.ts diff --git a/src/app/dashboardServer.ts b/greenfield/src/app/dashboardServer.ts similarity index 100% rename from src/app/dashboardServer.ts rename to greenfield/src/app/dashboardServer.ts diff --git a/src/app/environmentSource.ts b/greenfield/src/app/environmentSource.ts similarity index 100% rename from src/app/environmentSource.ts rename to greenfield/src/app/environmentSource.ts diff --git a/src/app/server.ts b/greenfield/src/app/server.ts similarity index 100% rename from src/app/server.ts rename to greenfield/src/app/server.ts diff --git a/src/app/trpcHttpHandler.test.ts b/greenfield/src/app/trpcHttpHandler.test.ts similarity index 100% rename from src/app/trpcHttpHandler.test.ts rename to greenfield/src/app/trpcHttpHandler.test.ts diff --git a/src/app/trpcHttpHandler.ts b/greenfield/src/app/trpcHttpHandler.ts similarity index 100% rename from src/app/trpcHttpHandler.ts rename to greenfield/src/app/trpcHttpHandler.ts diff --git a/src/app/trpcRequestPolicy.test.ts b/greenfield/src/app/trpcRequestPolicy.test.ts similarity index 100% rename from src/app/trpcRequestPolicy.test.ts rename to greenfield/src/app/trpcRequestPolicy.test.ts diff --git a/src/app/trpcRequestPolicy.ts b/greenfield/src/app/trpcRequestPolicy.ts similarity index 100% rename from src/app/trpcRequestPolicy.ts rename to greenfield/src/app/trpcRequestPolicy.ts diff --git a/qualification/build/fixtures/frontend/index.html b/greenfield/src/browser/testSupport/frontendBuildFixture/index.html similarity index 87% rename from qualification/build/fixtures/frontend/index.html rename to greenfield/src/browser/testSupport/frontendBuildFixture/index.html index 98d699414..db84db336 100644 --- a/qualification/build/fixtures/frontend/index.html +++ b/greenfield/src/browser/testSupport/frontendBuildFixture/index.html @@ -3,7 +3,7 @@ - Frontend build qualification + Frontend build fixture diff --git a/qualification/build/fixtures/frontend/src/QualificationApp.tsx b/greenfield/src/browser/testSupport/frontendBuildFixture/src/FixtureApp.tsx similarity index 93% rename from qualification/build/fixtures/frontend/src/QualificationApp.tsx rename to greenfield/src/browser/testSupport/frontendBuildFixture/src/FixtureApp.tsx index 2e603ad77..56aac6436 100644 --- a/qualification/build/fixtures/frontend/src/QualificationApp.tsx +++ b/greenfield/src/browser/testSupport/frontendBuildFixture/src/FixtureApp.tsx @@ -2,7 +2,7 @@ import { lazy, Suspense, useState } from "react"; const LazyPanel = lazy(() => import("./LazyPanel")); -export default function QualificationApp() { +export default function FixtureApp() { const [count, setCount] = useState(0); return ( diff --git a/qualification/build/fixtures/frontend/src/LazyPanel.tsx b/greenfield/src/browser/testSupport/frontendBuildFixture/src/LazyPanel.tsx similarity index 100% rename from qualification/build/fixtures/frontend/src/LazyPanel.tsx rename to greenfield/src/browser/testSupport/frontendBuildFixture/src/LazyPanel.tsx diff --git a/qualification/build/fixtures/frontend/src/index.css b/greenfield/src/browser/testSupport/frontendBuildFixture/src/index.css similarity index 100% rename from qualification/build/fixtures/frontend/src/index.css rename to greenfield/src/browser/testSupport/frontendBuildFixture/src/index.css diff --git a/qualification/build/fixtures/frontend/src/main.tsx b/greenfield/src/browser/testSupport/frontendBuildFixture/src/main.tsx similarity index 68% rename from qualification/build/fixtures/frontend/src/main.tsx rename to greenfield/src/browser/testSupport/frontendBuildFixture/src/main.tsx index 02d16e25f..4acb01957 100644 --- a/qualification/build/fixtures/frontend/src/main.tsx +++ b/greenfield/src/browser/testSupport/frontendBuildFixture/src/main.tsx @@ -1,10 +1,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import QualificationApp from "./QualificationApp"; +import FixtureApp from "./FixtureApp"; createRoot(document.querySelector("#root")!).render( - + ); diff --git a/src/contracts/accountSecurity.test.ts b/greenfield/src/contracts/accountSecurity.test.ts similarity index 100% rename from src/contracts/accountSecurity.test.ts rename to greenfield/src/contracts/accountSecurity.test.ts diff --git a/src/contracts/accountSecurity.ts b/greenfield/src/contracts/accountSecurity.ts similarity index 100% rename from src/contracts/accountSecurity.ts rename to greenfield/src/contracts/accountSecurity.ts diff --git a/src/contracts/auth.test.ts b/greenfield/src/contracts/auth.test.ts similarity index 100% rename from src/contracts/auth.test.ts rename to greenfield/src/contracts/auth.test.ts diff --git a/src/contracts/auth.ts b/greenfield/src/contracts/auth.ts similarity index 100% rename from src/contracts/auth.ts rename to greenfield/src/contracts/auth.ts diff --git a/src/contracts/automationSecurity.test.ts b/greenfield/src/contracts/automationSecurity.test.ts similarity index 100% rename from src/contracts/automationSecurity.test.ts rename to greenfield/src/contracts/automationSecurity.test.ts diff --git a/src/contracts/automationSecurity.ts b/greenfield/src/contracts/automationSecurity.ts similarity index 100% rename from src/contracts/automationSecurity.ts rename to greenfield/src/contracts/automationSecurity.ts diff --git a/src/contracts/contractRegistry.test.ts b/greenfield/src/contracts/contractRegistry.test.ts similarity index 100% rename from src/contracts/contractRegistry.test.ts rename to greenfield/src/contracts/contractRegistry.test.ts diff --git a/src/contracts/contractRegistry.ts b/greenfield/src/contracts/contractRegistry.ts similarity index 100% rename from src/contracts/contractRegistry.ts rename to greenfield/src/contracts/contractRegistry.ts diff --git a/src/contracts/events.test.ts b/greenfield/src/contracts/events.test.ts similarity index 100% rename from src/contracts/events.test.ts rename to greenfield/src/contracts/events.test.ts diff --git a/src/contracts/events.ts b/greenfield/src/contracts/events.ts similarity index 100% rename from src/contracts/events.ts rename to greenfield/src/contracts/events.ts diff --git a/src/contracts/monitoring.ts b/greenfield/src/contracts/monitoring.ts similarity index 100% rename from src/contracts/monitoring.ts rename to greenfield/src/contracts/monitoring.ts diff --git a/src/contracts/monitoringRealtime.ts b/greenfield/src/contracts/monitoringRealtime.ts similarity index 100% rename from src/contracts/monitoringRealtime.ts rename to greenfield/src/contracts/monitoringRealtime.ts diff --git a/src/contracts/realtime.ts b/greenfield/src/contracts/realtime.ts similarity index 100% rename from src/contracts/realtime.ts rename to greenfield/src/contracts/realtime.ts diff --git a/src/contracts/registry.ts b/greenfield/src/contracts/registry.ts similarity index 100% rename from src/contracts/registry.ts rename to greenfield/src/contracts/registry.ts diff --git a/src/contracts/security.test.ts b/greenfield/src/contracts/security.test.ts similarity index 100% rename from src/contracts/security.test.ts rename to greenfield/src/contracts/security.test.ts diff --git a/src/contracts/security.ts b/greenfield/src/contracts/security.ts similarity index 100% rename from src/contracts/security.ts rename to greenfield/src/contracts/security.ts diff --git a/src/contracts/system.ts b/greenfield/src/contracts/system.ts similarity index 100% rename from src/contracts/system.ts rename to greenfield/src/contracts/system.ts diff --git a/src/contracts/webauthn.test.ts b/greenfield/src/contracts/webauthn.test.ts similarity index 100% rename from src/contracts/webauthn.test.ts rename to greenfield/src/contracts/webauthn.test.ts diff --git a/src/contracts/webauthn.ts b/greenfield/src/contracts/webauthn.ts similarity index 100% rename from src/contracts/webauthn.ts rename to greenfield/src/contracts/webauthn.ts diff --git a/src/server/database/migrations/applyVerifiedMigrations.ts b/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts similarity index 100% rename from src/server/database/migrations/applyVerifiedMigrations.ts rename to greenfield/src/server/database/migrations/applyVerifiedMigrations.ts diff --git a/src/server/database/migrations/auditEventsSchema.test.ts b/greenfield/src/server/database/migrations/auditEventsSchema.test.ts similarity index 100% rename from src/server/database/migrations/auditEventsSchema.test.ts rename to greenfield/src/server/database/migrations/auditEventsSchema.test.ts diff --git a/src/server/database/migrations/authenticationRateLimitSchema.test.ts b/greenfield/src/server/database/migrations/authenticationRateLimitSchema.test.ts similarity index 100% rename from src/server/database/migrations/authenticationRateLimitSchema.test.ts rename to greenfield/src/server/database/migrations/authenticationRateLimitSchema.test.ts diff --git a/src/server/database/migrations/jsonObjectConstraints.test.ts b/greenfield/src/server/database/migrations/jsonObjectConstraints.test.ts similarity index 100% rename from src/server/database/migrations/jsonObjectConstraints.test.ts rename to greenfield/src/server/database/migrations/jsonObjectConstraints.test.ts diff --git a/src/server/database/migrations/loadVerifiedMigrations.test.ts b/greenfield/src/server/database/migrations/loadVerifiedMigrations.test.ts similarity index 100% rename from src/server/database/migrations/loadVerifiedMigrations.test.ts rename to greenfield/src/server/database/migrations/loadVerifiedMigrations.test.ts diff --git a/src/server/database/migrations/loadVerifiedMigrations.ts b/greenfield/src/server/database/migrations/loadVerifiedMigrations.ts similarity index 100% rename from src/server/database/migrations/loadVerifiedMigrations.ts rename to greenfield/src/server/database/migrations/loadVerifiedMigrations.ts diff --git a/src/server/database/migrations/manifest.ts b/greenfield/src/server/database/migrations/manifest.ts similarity index 100% rename from src/server/database/migrations/manifest.ts rename to greenfield/src/server/database/migrations/manifest.ts diff --git a/src/server/database/migrations/mfaLifecycleSchema.test.ts b/greenfield/src/server/database/migrations/mfaLifecycleSchema.test.ts similarity index 100% rename from src/server/database/migrations/mfaLifecycleSchema.test.ts rename to greenfield/src/server/database/migrations/mfaLifecycleSchema.test.ts diff --git a/src/server/database/migrations/migrationApplicationTime.test.ts b/greenfield/src/server/database/migrations/migrationApplicationTime.test.ts similarity index 100% rename from src/server/database/migrations/migrationApplicationTime.test.ts rename to greenfield/src/server/database/migrations/migrationApplicationTime.test.ts diff --git a/src/server/database/migrations/migrationGraph.test.ts b/greenfield/src/server/database/migrations/migrationGraph.test.ts similarity index 100% rename from src/server/database/migrations/migrationGraph.test.ts rename to greenfield/src/server/database/migrations/migrationGraph.test.ts diff --git a/src/server/database/migrations/migrationLedgerValidation.test.ts b/greenfield/src/server/database/migrations/migrationLedgerValidation.test.ts similarity index 100% rename from src/server/database/migrations/migrationLedgerValidation.test.ts rename to greenfield/src/server/database/migrations/migrationLedgerValidation.test.ts diff --git a/src/server/database/migrations/monitoringSchema.test.ts b/greenfield/src/server/database/migrations/monitoringSchema.test.ts similarity index 100% rename from src/server/database/migrations/monitoringSchema.test.ts rename to greenfield/src/server/database/migrations/monitoringSchema.test.ts diff --git a/src/server/database/migrations/realtimeSchema.test.ts b/greenfield/src/server/database/migrations/realtimeSchema.test.ts similarity index 100% rename from src/server/database/migrations/realtimeSchema.test.ts rename to greenfield/src/server/database/migrations/realtimeSchema.test.ts diff --git a/src/server/database/migrations/securityIdentitySchema.automation.test.ts b/greenfield/src/server/database/migrations/securityIdentitySchema.automation.test.ts similarity index 100% rename from src/server/database/migrations/securityIdentitySchema.automation.test.ts rename to greenfield/src/server/database/migrations/securityIdentitySchema.automation.test.ts diff --git a/src/server/database/migrations/securityIdentitySchema.baseline.test.ts b/greenfield/src/server/database/migrations/securityIdentitySchema.baseline.test.ts similarity index 100% rename from src/server/database/migrations/securityIdentitySchema.baseline.test.ts rename to greenfield/src/server/database/migrations/securityIdentitySchema.baseline.test.ts diff --git a/src/server/database/migrations/securityIdentitySchema.browser.test.ts b/greenfield/src/server/database/migrations/securityIdentitySchema.browser.test.ts similarity index 100% rename from src/server/database/migrations/securityIdentitySchema.browser.test.ts rename to greenfield/src/server/database/migrations/securityIdentitySchema.browser.test.ts diff --git a/src/server/database/migrations/testSupport/securityIdentitySchema.ts b/greenfield/src/server/database/migrations/testSupport/securityIdentitySchema.ts similarity index 100% rename from src/server/database/migrations/testSupport/securityIdentitySchema.ts rename to greenfield/src/server/database/migrations/testSupport/securityIdentitySchema.ts diff --git a/src/server/database/migrations/validation.ts b/greenfield/src/server/database/migrations/validation.ts similarity index 100% rename from src/server/database/migrations/validation.ts rename to greenfield/src/server/database/migrations/validation.ts diff --git a/src/server/database/migrations/verifyDatabaseIntegrity.test.ts b/greenfield/src/server/database/migrations/verifyDatabaseIntegrity.test.ts similarity index 100% rename from src/server/database/migrations/verifyDatabaseIntegrity.test.ts rename to greenfield/src/server/database/migrations/verifyDatabaseIntegrity.test.ts diff --git a/src/server/database/migrations/verifyDatabaseIntegrity.ts b/greenfield/src/server/database/migrations/verifyDatabaseIntegrity.ts similarity index 100% rename from src/server/database/migrations/verifyDatabaseIntegrity.ts rename to greenfield/src/server/database/migrations/verifyDatabaseIntegrity.ts diff --git a/src/server/database/migrations/webauthnLifecycleSchema.test.ts b/greenfield/src/server/database/migrations/webauthnLifecycleSchema.test.ts similarity index 100% rename from src/server/database/migrations/webauthnLifecycleSchema.test.ts rename to greenfield/src/server/database/migrations/webauthnLifecycleSchema.test.ts diff --git a/src/server/database/schema/auditEvents.ts b/greenfield/src/server/database/schema/auditEvents.ts similarity index 100% rename from src/server/database/schema/auditEvents.ts rename to greenfield/src/server/database/schema/auditEvents.ts diff --git a/src/server/database/schema/authChallenges.ts b/greenfield/src/server/database/schema/authChallenges.ts similarity index 100% rename from src/server/database/schema/authChallenges.ts rename to greenfield/src/server/database/schema/authChallenges.ts diff --git a/src/server/database/schema/authPendingLogins.ts b/greenfield/src/server/database/schema/authPendingLogins.ts similarity index 100% rename from src/server/database/schema/authPendingLogins.ts rename to greenfield/src/server/database/schema/authPendingLogins.ts diff --git a/src/server/database/schema/authRateLimitBuckets.ts b/greenfield/src/server/database/schema/authRateLimitBuckets.ts similarity index 100% rename from src/server/database/schema/authRateLimitBuckets.ts rename to greenfield/src/server/database/schema/authRateLimitBuckets.ts diff --git a/src/server/database/schema/authSessions.ts b/greenfield/src/server/database/schema/authSessions.ts similarity index 100% rename from src/server/database/schema/authSessions.ts rename to greenfield/src/server/database/schema/authSessions.ts diff --git a/src/server/database/schema/automationCredentials.ts b/greenfield/src/server/database/schema/automationCredentials.ts similarity index 100% rename from src/server/database/schema/automationCredentials.ts rename to greenfield/src/server/database/schema/automationCredentials.ts diff --git a/src/server/database/schema/automationPersistence.test.ts b/greenfield/src/server/database/schema/automationPersistence.test.ts similarity index 100% rename from src/server/database/schema/automationPersistence.test.ts rename to greenfield/src/server/database/schema/automationPersistence.test.ts diff --git a/src/server/database/schema/automationPrincipalCapabilities.ts b/greenfield/src/server/database/schema/automationPrincipalCapabilities.ts similarity index 100% rename from src/server/database/schema/automationPrincipalCapabilities.ts rename to greenfield/src/server/database/schema/automationPrincipalCapabilities.ts diff --git a/src/server/database/schema/automationPrincipals.ts b/greenfield/src/server/database/schema/automationPrincipals.ts similarity index 100% rename from src/server/database/schema/automationPrincipals.ts rename to greenfield/src/server/database/schema/automationPrincipals.ts diff --git a/src/server/database/schema/checks.ts b/greenfield/src/server/database/schema/checks.ts similarity index 100% rename from src/server/database/schema/checks.ts rename to greenfield/src/server/database/schema/checks.ts diff --git a/src/server/database/schema/drizzleSchema.ts b/greenfield/src/server/database/schema/drizzleSchema.ts similarity index 100% rename from src/server/database/schema/drizzleSchema.ts rename to greenfield/src/server/database/schema/drizzleSchema.ts diff --git a/src/server/database/schema/incidentObservations.ts b/greenfield/src/server/database/schema/incidentObservations.ts similarity index 100% rename from src/server/database/schema/incidentObservations.ts rename to greenfield/src/server/database/schema/incidentObservations.ts diff --git a/src/server/database/schema/incidents.ts b/greenfield/src/server/database/schema/incidents.ts similarity index 100% rename from src/server/database/schema/incidents.ts rename to greenfield/src/server/database/schema/incidents.ts diff --git a/src/server/database/schema/mfaFormats.ts b/greenfield/src/server/database/schema/mfaFormats.ts similarity index 100% rename from src/server/database/schema/mfaFormats.ts rename to greenfield/src/server/database/schema/mfaFormats.ts diff --git a/src/server/database/schema/monitorRuns.ts b/greenfield/src/server/database/schema/monitorRuns.ts similarity index 100% rename from src/server/database/schema/monitorRuns.ts rename to greenfield/src/server/database/schema/monitorRuns.ts diff --git a/src/server/database/schema/notifications.ts b/greenfield/src/server/database/schema/notifications.ts similarity index 100% rename from src/server/database/schema/notifications.ts rename to greenfield/src/server/database/schema/notifications.ts diff --git a/src/server/database/schema/passwordHashCheck.ts b/greenfield/src/server/database/schema/passwordHashCheck.ts similarity index 100% rename from src/server/database/schema/passwordHashCheck.ts rename to greenfield/src/server/database/schema/passwordHashCheck.ts diff --git a/src/server/database/schema/realtime.ts b/greenfield/src/server/database/schema/realtime.ts similarity index 100% rename from src/server/database/schema/realtime.ts rename to greenfield/src/server/database/schema/realtime.ts diff --git a/src/server/database/schema/reports.ts b/greenfield/src/server/database/schema/reports.ts similarity index 100% rename from src/server/database/schema/reports.ts rename to greenfield/src/server/database/schema/reports.ts diff --git a/src/server/database/schema/schemaMigrations.ts b/greenfield/src/server/database/schema/schemaMigrations.ts similarity index 100% rename from src/server/database/schema/schemaMigrations.ts rename to greenfield/src/server/database/schema/schemaMigrations.ts diff --git a/src/server/database/schema/userRecoveryCodes.ts b/greenfield/src/server/database/schema/userRecoveryCodes.ts similarity index 100% rename from src/server/database/schema/userRecoveryCodes.ts rename to greenfield/src/server/database/schema/userRecoveryCodes.ts diff --git a/src/server/database/schema/userTotpFactors.ts b/greenfield/src/server/database/schema/userTotpFactors.ts similarity index 100% rename from src/server/database/schema/userTotpFactors.ts rename to greenfield/src/server/database/schema/userTotpFactors.ts diff --git a/src/server/database/schema/userWebAuthnCredentials.ts b/greenfield/src/server/database/schema/userWebAuthnCredentials.ts similarity index 100% rename from src/server/database/schema/userWebAuthnCredentials.ts rename to greenfield/src/server/database/schema/userWebAuthnCredentials.ts diff --git a/src/server/database/schema/users.ts b/greenfield/src/server/database/schema/users.ts similarity index 100% rename from src/server/database/schema/users.ts rename to greenfield/src/server/database/schema/users.ts diff --git a/src/server/database/schema/webauthnPersistence.test.ts b/greenfield/src/server/database/schema/webauthnPersistence.test.ts similarity index 100% rename from src/server/database/schema/webauthnPersistence.test.ts rename to greenfield/src/server/database/schema/webauthnPersistence.test.ts diff --git a/src/server/database/validation/auditEvents.test.ts b/greenfield/src/server/database/validation/auditEvents.test.ts similarity index 100% rename from src/server/database/validation/auditEvents.test.ts rename to greenfield/src/server/database/validation/auditEvents.test.ts diff --git a/src/server/database/validation/auditEvents.ts b/greenfield/src/server/database/validation/auditEvents.ts similarity index 100% rename from src/server/database/validation/auditEvents.ts rename to greenfield/src/server/database/validation/auditEvents.ts diff --git a/src/server/database/validation/authChallenges.test.ts b/greenfield/src/server/database/validation/authChallenges.test.ts similarity index 100% rename from src/server/database/validation/authChallenges.test.ts rename to greenfield/src/server/database/validation/authChallenges.test.ts diff --git a/src/server/database/validation/authChallenges.ts b/greenfield/src/server/database/validation/authChallenges.ts similarity index 100% rename from src/server/database/validation/authChallenges.ts rename to greenfield/src/server/database/validation/authChallenges.ts diff --git a/src/server/database/validation/authPendingLogins.test.ts b/greenfield/src/server/database/validation/authPendingLogins.test.ts similarity index 100% rename from src/server/database/validation/authPendingLogins.test.ts rename to greenfield/src/server/database/validation/authPendingLogins.test.ts diff --git a/src/server/database/validation/authPendingLogins.ts b/greenfield/src/server/database/validation/authPendingLogins.ts similarity index 100% rename from src/server/database/validation/authPendingLogins.ts rename to greenfield/src/server/database/validation/authPendingLogins.ts diff --git a/src/server/database/validation/authRateLimitBuckets.test.ts b/greenfield/src/server/database/validation/authRateLimitBuckets.test.ts similarity index 100% rename from src/server/database/validation/authRateLimitBuckets.test.ts rename to greenfield/src/server/database/validation/authRateLimitBuckets.test.ts diff --git a/src/server/database/validation/authRateLimitBuckets.ts b/greenfield/src/server/database/validation/authRateLimitBuckets.ts similarity index 100% rename from src/server/database/validation/authRateLimitBuckets.ts rename to greenfield/src/server/database/validation/authRateLimitBuckets.ts diff --git a/src/server/database/validation/authSessions.test.ts b/greenfield/src/server/database/validation/authSessions.test.ts similarity index 100% rename from src/server/database/validation/authSessions.test.ts rename to greenfield/src/server/database/validation/authSessions.test.ts diff --git a/src/server/database/validation/authSessions.ts b/greenfield/src/server/database/validation/authSessions.ts similarity index 100% rename from src/server/database/validation/authSessions.ts rename to greenfield/src/server/database/validation/authSessions.ts diff --git a/src/server/database/validation/automationCredentials.test.ts b/greenfield/src/server/database/validation/automationCredentials.test.ts similarity index 100% rename from src/server/database/validation/automationCredentials.test.ts rename to greenfield/src/server/database/validation/automationCredentials.test.ts diff --git a/src/server/database/validation/automationCredentials.ts b/greenfield/src/server/database/validation/automationCredentials.ts similarity index 100% rename from src/server/database/validation/automationCredentials.ts rename to greenfield/src/server/database/validation/automationCredentials.ts diff --git a/src/server/database/validation/automationPrincipalCapabilities.test.ts b/greenfield/src/server/database/validation/automationPrincipalCapabilities.test.ts similarity index 100% rename from src/server/database/validation/automationPrincipalCapabilities.test.ts rename to greenfield/src/server/database/validation/automationPrincipalCapabilities.test.ts diff --git a/src/server/database/validation/automationPrincipalCapabilities.ts b/greenfield/src/server/database/validation/automationPrincipalCapabilities.ts similarity index 100% rename from src/server/database/validation/automationPrincipalCapabilities.ts rename to greenfield/src/server/database/validation/automationPrincipalCapabilities.ts diff --git a/src/server/database/validation/automationPrincipals.test.ts b/greenfield/src/server/database/validation/automationPrincipals.test.ts similarity index 100% rename from src/server/database/validation/automationPrincipals.test.ts rename to greenfield/src/server/database/validation/automationPrincipals.test.ts diff --git a/src/server/database/validation/automationPrincipals.ts b/greenfield/src/server/database/validation/automationPrincipals.ts similarity index 100% rename from src/server/database/validation/automationPrincipals.ts rename to greenfield/src/server/database/validation/automationPrincipals.ts diff --git a/src/server/database/validation/incidentObservations.test.ts b/greenfield/src/server/database/validation/incidentObservations.test.ts similarity index 100% rename from src/server/database/validation/incidentObservations.test.ts rename to greenfield/src/server/database/validation/incidentObservations.test.ts diff --git a/src/server/database/validation/incidentObservations.ts b/greenfield/src/server/database/validation/incidentObservations.ts similarity index 100% rename from src/server/database/validation/incidentObservations.ts rename to greenfield/src/server/database/validation/incidentObservations.ts diff --git a/src/server/database/validation/incidents.test.ts b/greenfield/src/server/database/validation/incidents.test.ts similarity index 100% rename from src/server/database/validation/incidents.test.ts rename to greenfield/src/server/database/validation/incidents.test.ts diff --git a/src/server/database/validation/incidents.ts b/greenfield/src/server/database/validation/incidents.ts similarity index 100% rename from src/server/database/validation/incidents.ts rename to greenfield/src/server/database/validation/incidents.ts diff --git a/src/server/database/validation/monitorRuns.test.ts b/greenfield/src/server/database/validation/monitorRuns.test.ts similarity index 100% rename from src/server/database/validation/monitorRuns.test.ts rename to greenfield/src/server/database/validation/monitorRuns.test.ts diff --git a/src/server/database/validation/monitorRuns.ts b/greenfield/src/server/database/validation/monitorRuns.ts similarity index 100% rename from src/server/database/validation/monitorRuns.ts rename to greenfield/src/server/database/validation/monitorRuns.ts diff --git a/src/server/database/validation/notifications.test.ts b/greenfield/src/server/database/validation/notifications.test.ts similarity index 100% rename from src/server/database/validation/notifications.test.ts rename to greenfield/src/server/database/validation/notifications.test.ts diff --git a/src/server/database/validation/notifications.ts b/greenfield/src/server/database/validation/notifications.ts similarity index 100% rename from src/server/database/validation/notifications.ts rename to greenfield/src/server/database/validation/notifications.ts diff --git a/src/server/database/validation/realtimeEvents.test.ts b/greenfield/src/server/database/validation/realtimeEvents.test.ts similarity index 98% rename from src/server/database/validation/realtimeEvents.test.ts rename to greenfield/src/server/database/validation/realtimeEvents.test.ts index be81bf23a..8d3485f26 100644 --- a/src/server/database/validation/realtimeEvents.test.ts +++ b/greenfield/src/server/database/validation/realtimeEvents.test.ts @@ -10,7 +10,7 @@ import { const baseEvent = { entityId: "entity-1", - entityType: "qualification", + entityType: "test-entity", expiresAt: addMinutes(1000, 1), occurredAt: toDate(1000), operation: "updated" as const, diff --git a/src/server/database/validation/realtimeEvents.ts b/greenfield/src/server/database/validation/realtimeEvents.ts similarity index 100% rename from src/server/database/validation/realtimeEvents.ts rename to greenfield/src/server/database/validation/realtimeEvents.ts diff --git a/src/server/database/validation/reports.test.ts b/greenfield/src/server/database/validation/reports.test.ts similarity index 100% rename from src/server/database/validation/reports.test.ts rename to greenfield/src/server/database/validation/reports.test.ts diff --git a/src/server/database/validation/reports.ts b/greenfield/src/server/database/validation/reports.ts similarity index 100% rename from src/server/database/validation/reports.ts rename to greenfield/src/server/database/validation/reports.ts diff --git a/src/server/database/validation/rowSchemaIntegration.test.ts b/greenfield/src/server/database/validation/rowSchemaIntegration.test.ts similarity index 100% rename from src/server/database/validation/rowSchemaIntegration.test.ts rename to greenfield/src/server/database/validation/rowSchemaIntegration.test.ts diff --git a/src/server/database/validation/rowSchemas.test.ts b/greenfield/src/server/database/validation/rowSchemas.test.ts similarity index 100% rename from src/server/database/validation/rowSchemas.test.ts rename to greenfield/src/server/database/validation/rowSchemas.test.ts diff --git a/src/server/database/validation/scalars.ts b/greenfield/src/server/database/validation/scalars.ts similarity index 100% rename from src/server/database/validation/scalars.ts rename to greenfield/src/server/database/validation/scalars.ts diff --git a/src/server/database/validation/schemaMigrations.ts b/greenfield/src/server/database/validation/schemaMigrations.ts similarity index 100% rename from src/server/database/validation/schemaMigrations.ts rename to greenfield/src/server/database/validation/schemaMigrations.ts diff --git a/src/server/database/validation/securityScalars.ts b/greenfield/src/server/database/validation/securityScalars.ts similarity index 100% rename from src/server/database/validation/securityScalars.ts rename to greenfield/src/server/database/validation/securityScalars.ts diff --git a/src/server/database/validation/testSupport/rows.ts b/greenfield/src/server/database/validation/testSupport/rows.ts similarity index 100% rename from src/server/database/validation/testSupport/rows.ts rename to greenfield/src/server/database/validation/testSupport/rows.ts diff --git a/src/server/database/validation/testSupport/securityRows.ts b/greenfield/src/server/database/validation/testSupport/securityRows.ts similarity index 100% rename from src/server/database/validation/testSupport/securityRows.ts rename to greenfield/src/server/database/validation/testSupport/securityRows.ts diff --git a/src/server/database/validation/userRecoveryCodes.test.ts b/greenfield/src/server/database/validation/userRecoveryCodes.test.ts similarity index 100% rename from src/server/database/validation/userRecoveryCodes.test.ts rename to greenfield/src/server/database/validation/userRecoveryCodes.test.ts diff --git a/src/server/database/validation/userRecoveryCodes.ts b/greenfield/src/server/database/validation/userRecoveryCodes.ts similarity index 100% rename from src/server/database/validation/userRecoveryCodes.ts rename to greenfield/src/server/database/validation/userRecoveryCodes.ts diff --git a/src/server/database/validation/userTotpFactors.test.ts b/greenfield/src/server/database/validation/userTotpFactors.test.ts similarity index 100% rename from src/server/database/validation/userTotpFactors.test.ts rename to greenfield/src/server/database/validation/userTotpFactors.test.ts diff --git a/src/server/database/validation/userTotpFactors.ts b/greenfield/src/server/database/validation/userTotpFactors.ts similarity index 100% rename from src/server/database/validation/userTotpFactors.ts rename to greenfield/src/server/database/validation/userTotpFactors.ts diff --git a/src/server/database/validation/userWebAuthnCredentials.test.ts b/greenfield/src/server/database/validation/userWebAuthnCredentials.test.ts similarity index 100% rename from src/server/database/validation/userWebAuthnCredentials.test.ts rename to greenfield/src/server/database/validation/userWebAuthnCredentials.test.ts diff --git a/src/server/database/validation/userWebAuthnCredentials.ts b/greenfield/src/server/database/validation/userWebAuthnCredentials.ts similarity index 100% rename from src/server/database/validation/userWebAuthnCredentials.ts rename to greenfield/src/server/database/validation/userWebAuthnCredentials.ts diff --git a/src/server/database/validation/users.test.ts b/greenfield/src/server/database/validation/users.test.ts similarity index 100% rename from src/server/database/validation/users.test.ts rename to greenfield/src/server/database/validation/users.test.ts diff --git a/src/server/database/validation/users.ts b/greenfield/src/server/database/validation/users.ts similarity index 100% rename from src/server/database/validation/users.ts rename to greenfield/src/server/database/validation/users.ts diff --git a/src/server/database/validation/webauthnScalars.ts b/greenfield/src/server/database/validation/webauthnScalars.ts similarity index 100% rename from src/server/database/validation/webauthnScalars.ts rename to greenfield/src/server/database/validation/webauthnScalars.ts diff --git a/src/server/domains/monitoring/normalization.test.ts b/greenfield/src/server/domains/monitoring/normalization.test.ts similarity index 100% rename from src/server/domains/monitoring/normalization.test.ts rename to greenfield/src/server/domains/monitoring/normalization.test.ts diff --git a/src/server/domains/monitoring/normalization.ts b/greenfield/src/server/domains/monitoring/normalization.ts similarity index 97% rename from src/server/domains/monitoring/normalization.ts rename to greenfield/src/server/domains/monitoring/normalization.ts index c7884fee7..5e173a223 100644 --- a/src/server/domains/monitoring/normalization.ts +++ b/greenfield/src/server/domains/monitoring/normalization.ts @@ -7,6 +7,7 @@ import { } from "../../../contracts/monitoring.ts"; import { sha256Hex } from "../../shared/crypto.ts"; +const TaggedErrorClass = Schema.TaggedError; const fingerprintVersion = "monitoring-incident-fingerprint:v1"; const identifierPolicy = Object.freeze({ pattern: /^[a-z0-9][a-z0-9._:-]*$/u, @@ -77,7 +78,7 @@ export interface NormalizedMonitoringSubmission { } /** Expected validation failure before the monitoring repository is entered. */ -export class MonitoringSnapshotValidationError extends Schema.TaggedErrorClass( +export class MonitoringSnapshotValidationError extends TaggedErrorClass( "mira-dashboard/server/domains/monitoring/MonitoringSnapshotValidationError" )("MonitoringSnapshotValidationError", { message: Schema.String, diff --git a/src/server/domains/monitoring/realtimeEvents.ts b/greenfield/src/server/domains/monitoring/realtimeEvents.ts similarity index 100% rename from src/server/domains/monitoring/realtimeEvents.ts rename to greenfield/src/server/domains/monitoring/realtimeEvents.ts diff --git a/src/server/domains/monitoring/repository.ts b/greenfield/src/server/domains/monitoring/repository.ts similarity index 100% rename from src/server/domains/monitoring/repository.ts rename to greenfield/src/server/domains/monitoring/repository.ts diff --git a/src/server/domains/monitoring/serialization.ts b/greenfield/src/server/domains/monitoring/serialization.ts similarity index 100% rename from src/server/domains/monitoring/serialization.ts rename to greenfield/src/server/domains/monitoring/serialization.ts diff --git a/src/server/domains/monitoring/service.ts b/greenfield/src/server/domains/monitoring/service.ts similarity index 98% rename from src/server/domains/monitoring/service.ts rename to greenfield/src/server/domains/monitoring/service.ts index 844193e3e..910810680 100644 --- a/src/server/domains/monitoring/service.ts +++ b/greenfield/src/server/domains/monitoring/service.ts @@ -30,6 +30,7 @@ import { applyMonitoringSnapshotLifecycle } from "./snapshotLifecycle.ts"; export { monitoringRealtimeTopics } from "./realtimeEvents.ts"; export { MonitoringSnapshotValidationError } from "./normalization.ts"; +const TaggedErrorClass = Schema.TaggedError; const defaultRealtimeRetentionMilliseconds = hoursToMilliseconds(168); const maximumSnapshotFutureSkewMilliseconds = minutesToMilliseconds(5); const realtimeRetentionSchema = positiveSafeIntegerSchema( @@ -80,7 +81,7 @@ export class MonitoringService extends Context.Service< >()("mira-dashboard/server/domains/monitoring/MonitoringService") {} /** A run id was retried with content that differs from its immutable first submission. */ -export class MonitoringRunConflictError extends Schema.TaggedErrorClass( +export class MonitoringRunConflictError extends TaggedErrorClass( "mira-dashboard/server/domains/monitoring/MonitoringRunConflictError" )("MonitoringRunConflictError", { message: Schema.String, diff --git a/src/server/domains/monitoring/serviceBoundary.test.ts b/greenfield/src/server/domains/monitoring/serviceBoundary.test.ts similarity index 100% rename from src/server/domains/monitoring/serviceBoundary.test.ts rename to greenfield/src/server/domains/monitoring/serviceBoundary.test.ts diff --git a/src/server/domains/monitoring/serviceLifecycle.test.ts b/greenfield/src/server/domains/monitoring/serviceLifecycle.test.ts similarity index 100% rename from src/server/domains/monitoring/serviceLifecycle.test.ts rename to greenfield/src/server/domains/monitoring/serviceLifecycle.test.ts diff --git a/src/server/domains/monitoring/serviceOrdering.test.ts b/greenfield/src/server/domains/monitoring/serviceOrdering.test.ts similarity index 100% rename from src/server/domains/monitoring/serviceOrdering.test.ts rename to greenfield/src/server/domains/monitoring/serviceOrdering.test.ts diff --git a/src/server/domains/monitoring/snapshotLifecycle.ts b/greenfield/src/server/domains/monitoring/snapshotLifecycle.ts similarity index 100% rename from src/server/domains/monitoring/snapshotLifecycle.ts rename to greenfield/src/server/domains/monitoring/snapshotLifecycle.ts diff --git a/src/server/domains/monitoring/testSupport/monitoringService.ts b/greenfield/src/server/domains/monitoring/testSupport/monitoringService.ts similarity index 100% rename from src/server/domains/monitoring/testSupport/monitoringService.ts rename to greenfield/src/server/domains/monitoring/testSupport/monitoringService.ts diff --git a/src/server/domains/realtime/authenticationLeaseStream.test.ts b/greenfield/src/server/domains/realtime/authenticationLeaseStream.test.ts similarity index 100% rename from src/server/domains/realtime/authenticationLeaseStream.test.ts rename to greenfield/src/server/domains/realtime/authenticationLeaseStream.test.ts diff --git a/src/server/domains/realtime/authenticationLeaseStream.ts b/greenfield/src/server/domains/realtime/authenticationLeaseStream.ts similarity index 100% rename from src/server/domains/realtime/authenticationLeaseStream.ts rename to greenfield/src/server/domains/realtime/authenticationLeaseStream.ts diff --git a/src/server/domains/realtime/errors.test.ts b/greenfield/src/server/domains/realtime/errors.test.ts similarity index 100% rename from src/server/domains/realtime/errors.test.ts rename to greenfield/src/server/domains/realtime/errors.test.ts diff --git a/src/server/domains/realtime/errors.ts b/greenfield/src/server/domains/realtime/errors.ts similarity index 100% rename from src/server/domains/realtime/errors.ts rename to greenfield/src/server/domains/realtime/errors.ts diff --git a/src/server/domains/realtime/procedures.test.ts b/greenfield/src/server/domains/realtime/procedures.test.ts similarity index 100% rename from src/server/domains/realtime/procedures.test.ts rename to greenfield/src/server/domains/realtime/procedures.test.ts diff --git a/src/server/domains/realtime/procedures.ts b/greenfield/src/server/domains/realtime/procedures.ts similarity index 100% rename from src/server/domains/realtime/procedures.ts rename to greenfield/src/server/domains/realtime/procedures.ts diff --git a/src/server/domains/realtime/transport.test.ts b/greenfield/src/server/domains/realtime/transport.test.ts similarity index 100% rename from src/server/domains/realtime/transport.test.ts rename to greenfield/src/server/domains/realtime/transport.test.ts diff --git a/src/server/domains/realtime/transport.ts b/greenfield/src/server/domains/realtime/transport.ts similarity index 100% rename from src/server/domains/realtime/transport.ts rename to greenfield/src/server/domains/realtime/transport.ts diff --git a/src/server/domains/security/audit.test.ts b/greenfield/src/server/domains/security/audit.test.ts similarity index 100% rename from src/server/domains/security/audit.test.ts rename to greenfield/src/server/domains/security/audit.test.ts diff --git a/src/server/domains/security/audit.ts b/greenfield/src/server/domains/security/audit.ts similarity index 100% rename from src/server/domains/security/audit.ts rename to greenfield/src/server/domains/security/audit.ts diff --git a/src/server/domains/security/authPendingMfaRoutes.ts b/greenfield/src/server/domains/security/authPendingMfaRoutes.ts similarity index 100% rename from src/server/domains/security/authPendingMfaRoutes.ts rename to greenfield/src/server/domains/security/authPendingMfaRoutes.ts diff --git a/src/server/domains/security/authPendingMfaRoutes.webAuthn.test.ts b/greenfield/src/server/domains/security/authPendingMfaRoutes.webAuthn.test.ts similarity index 100% rename from src/server/domains/security/authPendingMfaRoutes.webAuthn.test.ts rename to greenfield/src/server/domains/security/authPendingMfaRoutes.webAuthn.test.ts diff --git a/src/server/domains/security/authPublicRoutes.ts b/greenfield/src/server/domains/security/authPublicRoutes.ts similarity index 100% rename from src/server/domains/security/authPublicRoutes.ts rename to greenfield/src/server/domains/security/authPublicRoutes.ts diff --git a/src/server/domains/security/authSessionRoutes.ts b/greenfield/src/server/domains/security/authSessionRoutes.ts similarity index 100% rename from src/server/domains/security/authSessionRoutes.ts rename to greenfield/src/server/domains/security/authSessionRoutes.ts diff --git a/src/server/domains/security/authenticationLifecycle.bootstrap.test.ts b/greenfield/src/server/domains/security/authenticationLifecycle.bootstrap.test.ts similarity index 100% rename from src/server/domains/security/authenticationLifecycle.bootstrap.test.ts rename to greenfield/src/server/domains/security/authenticationLifecycle.bootstrap.test.ts diff --git a/src/server/domains/security/authenticationLifecycle.login.test.ts b/greenfield/src/server/domains/security/authenticationLifecycle.login.test.ts similarity index 100% rename from src/server/domains/security/authenticationLifecycle.login.test.ts rename to greenfield/src/server/domains/security/authenticationLifecycle.login.test.ts diff --git a/src/server/domains/security/authenticationLifecycle.password.test.ts b/greenfield/src/server/domains/security/authenticationLifecycle.password.test.ts similarity index 100% rename from src/server/domains/security/authenticationLifecycle.password.test.ts rename to greenfield/src/server/domains/security/authenticationLifecycle.password.test.ts diff --git a/src/server/domains/security/authenticationLifecycle.rateLimit.test.ts b/greenfield/src/server/domains/security/authenticationLifecycle.rateLimit.test.ts similarity index 100% rename from src/server/domains/security/authenticationLifecycle.rateLimit.test.ts rename to greenfield/src/server/domains/security/authenticationLifecycle.rateLimit.test.ts diff --git a/src/server/domains/security/authenticationLifecycle.sessions.test.ts b/greenfield/src/server/domains/security/authenticationLifecycle.sessions.test.ts similarity index 100% rename from src/server/domains/security/authenticationLifecycle.sessions.test.ts rename to greenfield/src/server/domains/security/authenticationLifecycle.sessions.test.ts diff --git a/src/server/domains/security/authenticationLifecycle.ts b/greenfield/src/server/domains/security/authenticationLifecycle.ts similarity index 100% rename from src/server/domains/security/authenticationLifecycle.ts rename to greenfield/src/server/domains/security/authenticationLifecycle.ts diff --git a/src/server/domains/security/authenticationLifecycleBootstrap.ts b/greenfield/src/server/domains/security/authenticationLifecycleBootstrap.ts similarity index 100% rename from src/server/domains/security/authenticationLifecycleBootstrap.ts rename to greenfield/src/server/domains/security/authenticationLifecycleBootstrap.ts diff --git a/src/server/domains/security/authenticationLifecycleContext.ts b/greenfield/src/server/domains/security/authenticationLifecycleContext.ts similarity index 100% rename from src/server/domains/security/authenticationLifecycleContext.ts rename to greenfield/src/server/domains/security/authenticationLifecycleContext.ts diff --git a/src/server/domains/security/authenticationLifecycleLogin.ts b/greenfield/src/server/domains/security/authenticationLifecycleLogin.ts similarity index 100% rename from src/server/domains/security/authenticationLifecycleLogin.ts rename to greenfield/src/server/domains/security/authenticationLifecycleLogin.ts diff --git a/src/server/domains/security/authenticationLifecyclePassword.ts b/greenfield/src/server/domains/security/authenticationLifecyclePassword.ts similarity index 100% rename from src/server/domains/security/authenticationLifecyclePassword.ts rename to greenfield/src/server/domains/security/authenticationLifecyclePassword.ts diff --git a/src/server/domains/security/authenticationLifecycleRepository.test.ts b/greenfield/src/server/domains/security/authenticationLifecycleRepository.test.ts similarity index 100% rename from src/server/domains/security/authenticationLifecycleRepository.test.ts rename to greenfield/src/server/domains/security/authenticationLifecycleRepository.test.ts diff --git a/src/server/domains/security/authenticationLifecycleRepository.ts b/greenfield/src/server/domains/security/authenticationLifecycleRepository.ts similarity index 100% rename from src/server/domains/security/authenticationLifecycleRepository.ts rename to greenfield/src/server/domains/security/authenticationLifecycleRepository.ts diff --git a/src/server/domains/security/authenticationLifecycleSessions.ts b/greenfield/src/server/domains/security/authenticationLifecycleSessions.ts similarity index 100% rename from src/server/domains/security/authenticationLifecycleSessions.ts rename to greenfield/src/server/domains/security/authenticationLifecycleSessions.ts diff --git a/src/server/domains/security/authenticationLifecycleTypes.ts b/greenfield/src/server/domains/security/authenticationLifecycleTypes.ts similarity index 100% rename from src/server/domains/security/authenticationLifecycleTypes.ts rename to greenfield/src/server/domains/security/authenticationLifecycleTypes.ts diff --git a/src/server/domains/security/authenticationPolicy.test.ts b/greenfield/src/server/domains/security/authenticationPolicy.test.ts similarity index 100% rename from src/server/domains/security/authenticationPolicy.test.ts rename to greenfield/src/server/domains/security/authenticationPolicy.test.ts diff --git a/src/server/domains/security/authenticationPolicy.ts b/greenfield/src/server/domains/security/authenticationPolicy.ts similarity index 100% rename from src/server/domains/security/authenticationPolicy.ts rename to greenfield/src/server/domains/security/authenticationPolicy.ts diff --git a/src/server/domains/security/authenticationRateLimit.ts b/greenfield/src/server/domains/security/authenticationRateLimit.ts similarity index 100% rename from src/server/domains/security/authenticationRateLimit.ts rename to greenfield/src/server/domains/security/authenticationRateLimit.ts diff --git a/src/server/domains/security/authenticationRateLimitStore.ts b/greenfield/src/server/domains/security/authenticationRateLimitStore.ts similarity index 100% rename from src/server/domains/security/authenticationRateLimitStore.ts rename to greenfield/src/server/domains/security/authenticationRateLimitStore.ts diff --git a/src/server/domains/security/authenticationResolution.test.ts b/greenfield/src/server/domains/security/authenticationResolution.test.ts similarity index 100% rename from src/server/domains/security/authenticationResolution.test.ts rename to greenfield/src/server/domains/security/authenticationResolution.test.ts diff --git a/src/server/domains/security/authenticationResolution.ts b/greenfield/src/server/domains/security/authenticationResolution.ts similarity index 100% rename from src/server/domains/security/authenticationResolution.ts rename to greenfield/src/server/domains/security/authenticationResolution.ts diff --git a/src/server/domains/security/authenticationSession.test.ts b/greenfield/src/server/domains/security/authenticationSession.test.ts similarity index 100% rename from src/server/domains/security/authenticationSession.test.ts rename to greenfield/src/server/domains/security/authenticationSession.test.ts diff --git a/src/server/domains/security/authenticationSession.ts b/greenfield/src/server/domains/security/authenticationSession.ts similarity index 100% rename from src/server/domains/security/authenticationSession.ts rename to greenfield/src/server/domains/security/authenticationSession.ts diff --git a/src/server/domains/security/authenticationWorkBudget.test.ts b/greenfield/src/server/domains/security/authenticationWorkBudget.test.ts similarity index 100% rename from src/server/domains/security/authenticationWorkBudget.test.ts rename to greenfield/src/server/domains/security/authenticationWorkBudget.test.ts diff --git a/src/server/domains/security/authenticationWorkBudget.ts b/greenfield/src/server/domains/security/authenticationWorkBudget.ts similarity index 100% rename from src/server/domains/security/authenticationWorkBudget.ts rename to greenfield/src/server/domains/security/authenticationWorkBudget.ts diff --git a/src/server/domains/security/authenticationWorkGate.test.ts b/greenfield/src/server/domains/security/authenticationWorkGate.test.ts similarity index 100% rename from src/server/domains/security/authenticationWorkGate.test.ts rename to greenfield/src/server/domains/security/authenticationWorkGate.test.ts diff --git a/src/server/domains/security/authenticationWorkGate.ts b/greenfield/src/server/domains/security/authenticationWorkGate.ts similarity index 100% rename from src/server/domains/security/authenticationWorkGate.ts rename to greenfield/src/server/domains/security/authenticationWorkGate.ts diff --git a/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts b/greenfield/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts similarity index 100% rename from src/server/domains/security/authenticationWorkGate.webAuthn.test.ts rename to greenfield/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts diff --git a/src/server/domains/security/automation/credentialOperations.ts b/greenfield/src/server/domains/security/automation/credentialOperations.ts similarity index 100% rename from src/server/domains/security/automation/credentialOperations.ts rename to greenfield/src/server/domains/security/automation/credentialOperations.ts diff --git a/src/server/domains/security/automation/lifecycle.ts b/greenfield/src/server/domains/security/automation/lifecycle.ts similarity index 100% rename from src/server/domains/security/automation/lifecycle.ts rename to greenfield/src/server/domains/security/automation/lifecycle.ts diff --git a/src/server/domains/security/automation/lifecycleContext.ts b/greenfield/src/server/domains/security/automation/lifecycleContext.ts similarity index 100% rename from src/server/domains/security/automation/lifecycleContext.ts rename to greenfield/src/server/domains/security/automation/lifecycleContext.ts diff --git a/src/server/domains/security/automation/lifecycleCredential.test.ts b/greenfield/src/server/domains/security/automation/lifecycleCredential.test.ts similarity index 100% rename from src/server/domains/security/automation/lifecycleCredential.test.ts rename to greenfield/src/server/domains/security/automation/lifecycleCredential.test.ts diff --git a/src/server/domains/security/automation/lifecycleCredentials.ts b/greenfield/src/server/domains/security/automation/lifecycleCredentials.ts similarity index 100% rename from src/server/domains/security/automation/lifecycleCredentials.ts rename to greenfield/src/server/domains/security/automation/lifecycleCredentials.ts diff --git a/src/server/domains/security/automation/lifecyclePolicy.ts b/greenfield/src/server/domains/security/automation/lifecyclePolicy.ts similarity index 100% rename from src/server/domains/security/automation/lifecyclePolicy.ts rename to greenfield/src/server/domains/security/automation/lifecyclePolicy.ts diff --git a/src/server/domains/security/automation/lifecyclePrincipal.test.ts b/greenfield/src/server/domains/security/automation/lifecyclePrincipal.test.ts similarity index 100% rename from src/server/domains/security/automation/lifecyclePrincipal.test.ts rename to greenfield/src/server/domains/security/automation/lifecyclePrincipal.test.ts diff --git a/src/server/domains/security/automation/lifecycleRepository.test.ts b/greenfield/src/server/domains/security/automation/lifecycleRepository.test.ts similarity index 100% rename from src/server/domains/security/automation/lifecycleRepository.test.ts rename to greenfield/src/server/domains/security/automation/lifecycleRepository.test.ts diff --git a/src/server/domains/security/automation/lifecycleRepository.ts b/greenfield/src/server/domains/security/automation/lifecycleRepository.ts similarity index 100% rename from src/server/domains/security/automation/lifecycleRepository.ts rename to greenfield/src/server/domains/security/automation/lifecycleRepository.ts diff --git a/src/server/domains/security/automation/lifecycleRepositoryReader.ts b/greenfield/src/server/domains/security/automation/lifecycleRepositoryReader.ts similarity index 100% rename from src/server/domains/security/automation/lifecycleRepositoryReader.ts rename to greenfield/src/server/domains/security/automation/lifecycleRepositoryReader.ts diff --git a/src/server/domains/security/automation/lifecycleRepositoryRecords.ts b/greenfield/src/server/domains/security/automation/lifecycleRepositoryRecords.ts similarity index 100% rename from src/server/domains/security/automation/lifecycleRepositoryRecords.ts rename to greenfield/src/server/domains/security/automation/lifecycleRepositoryRecords.ts diff --git a/src/server/domains/security/automation/lifecycleRepositoryTypes.ts b/greenfield/src/server/domains/security/automation/lifecycleRepositoryTypes.ts similarity index 98% rename from src/server/domains/security/automation/lifecycleRepositoryTypes.ts rename to greenfield/src/server/domains/security/automation/lifecycleRepositoryTypes.ts index 751e6e6e9..00aafff90 100644 --- a/src/server/domains/security/automation/lifecycleRepositoryTypes.ts +++ b/greenfield/src/server/domains/security/automation/lifecycleRepositoryTypes.ts @@ -1,15 +1,15 @@ -import * as v from "valibot"; +import type * as v from "valibot"; import type { ApplicationCapability } from "../../../../contracts/security.ts"; -import { +import type { automationCredentialInsertSchema, automationCredentialSelectSchema, } from "../../../database/validation/automationCredentials.ts"; -import { +import type { automationPrincipalCapabilityInsertSchema, automationPrincipalCapabilitySelectSchema, } from "../../../database/validation/automationPrincipalCapabilities.ts"; -import { +import type { automationPrincipalInsertSchema, automationPrincipalSelectSchema, } from "../../../database/validation/automationPrincipals.ts"; diff --git a/src/server/domains/security/automation/lifecycleRepositoryUnitOfWork.ts b/greenfield/src/server/domains/security/automation/lifecycleRepositoryUnitOfWork.ts similarity index 100% rename from src/server/domains/security/automation/lifecycleRepositoryUnitOfWork.ts rename to greenfield/src/server/domains/security/automation/lifecycleRepositoryUnitOfWork.ts diff --git a/src/server/domains/security/automation/lifecycleSummaries.ts b/greenfield/src/server/domains/security/automation/lifecycleSummaries.ts similarity index 100% rename from src/server/domains/security/automation/lifecycleSummaries.ts rename to greenfield/src/server/domains/security/automation/lifecycleSummaries.ts diff --git a/src/server/domains/security/automation/lifecycleTypes.ts b/greenfield/src/server/domains/security/automation/lifecycleTypes.ts similarity index 100% rename from src/server/domains/security/automation/lifecycleTypes.ts rename to greenfield/src/server/domains/security/automation/lifecycleTypes.ts diff --git a/src/server/domains/security/automation/principalOperations.ts b/greenfield/src/server/domains/security/automation/principalOperations.ts similarity index 100% rename from src/server/domains/security/automation/principalOperations.ts rename to greenfield/src/server/domains/security/automation/principalOperations.ts diff --git a/src/server/domains/security/automation/procedures.test.ts b/greenfield/src/server/domains/security/automation/procedures.test.ts similarity index 100% rename from src/server/domains/security/automation/procedures.test.ts rename to greenfield/src/server/domains/security/automation/procedures.test.ts diff --git a/src/server/domains/security/automation/procedures.ts b/greenfield/src/server/domains/security/automation/procedures.ts similarity index 100% rename from src/server/domains/security/automation/procedures.ts rename to greenfield/src/server/domains/security/automation/procedures.ts diff --git a/src/server/domains/security/automation/routes.ts b/greenfield/src/server/domains/security/automation/routes.ts similarity index 100% rename from src/server/domains/security/automation/routes.ts rename to greenfield/src/server/domains/security/automation/routes.ts diff --git a/src/server/domains/security/automation/testSupport/lifecycle.ts b/greenfield/src/server/domains/security/automation/testSupport/lifecycle.ts similarity index 100% rename from src/server/domains/security/automation/testSupport/lifecycle.ts rename to greenfield/src/server/domains/security/automation/testSupport/lifecycle.ts diff --git a/src/server/domains/security/browserSessionStore.ts b/greenfield/src/server/domains/security/browserSessionStore.ts similarity index 100% rename from src/server/domains/security/browserSessionStore.ts rename to greenfield/src/server/domains/security/browserSessionStore.ts diff --git a/src/server/domains/security/mfa/accountFactorRoutes.ts b/greenfield/src/server/domains/security/mfa/accountFactorRoutes.ts similarity index 100% rename from src/server/domains/security/mfa/accountFactorRoutes.ts rename to greenfield/src/server/domains/security/mfa/accountFactorRoutes.ts diff --git a/src/server/domains/security/mfa/accountLifecycle.factors.test.ts b/greenfield/src/server/domains/security/mfa/accountLifecycle.factors.test.ts similarity index 100% rename from src/server/domains/security/mfa/accountLifecycle.factors.test.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycle.factors.test.ts diff --git a/src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts b/greenfield/src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts similarity index 100% rename from src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts diff --git a/src/server/domains/security/mfa/accountLifecycle.proofs.test.ts b/greenfield/src/server/domains/security/mfa/accountLifecycle.proofs.test.ts similarity index 100% rename from src/server/domains/security/mfa/accountLifecycle.proofs.test.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycle.proofs.test.ts diff --git a/src/server/domains/security/mfa/accountLifecycle.ts b/greenfield/src/server/domains/security/mfa/accountLifecycle.ts similarity index 100% rename from src/server/domains/security/mfa/accountLifecycle.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycle.ts diff --git a/src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts b/greenfield/src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts similarity index 100% rename from src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts diff --git a/src/server/domains/security/mfa/accountLifecycleContext.ts b/greenfield/src/server/domains/security/mfa/accountLifecycleContext.ts similarity index 100% rename from src/server/domains/security/mfa/accountLifecycleContext.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycleContext.ts diff --git a/src/server/domains/security/mfa/accountLifecycleCrypto.ts b/greenfield/src/server/domains/security/mfa/accountLifecycleCrypto.ts similarity index 100% rename from src/server/domains/security/mfa/accountLifecycleCrypto.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycleCrypto.ts diff --git a/src/server/domains/security/mfa/accountLifecycleFactors.ts b/greenfield/src/server/domains/security/mfa/accountLifecycleFactors.ts similarity index 100% rename from src/server/domains/security/mfa/accountLifecycleFactors.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycleFactors.ts diff --git a/src/server/domains/security/mfa/accountLifecycleState.ts b/greenfield/src/server/domains/security/mfa/accountLifecycleState.ts similarity index 100% rename from src/server/domains/security/mfa/accountLifecycleState.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycleState.ts diff --git a/src/server/domains/security/mfa/accountLifecycleTypes.ts b/greenfield/src/server/domains/security/mfa/accountLifecycleTypes.ts similarity index 100% rename from src/server/domains/security/mfa/accountLifecycleTypes.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycleTypes.ts diff --git a/src/server/domains/security/mfa/accountMaintenanceRoutes.ts b/greenfield/src/server/domains/security/mfa/accountMaintenanceRoutes.ts similarity index 100% rename from src/server/domains/security/mfa/accountMaintenanceRoutes.ts rename to greenfield/src/server/domains/security/mfa/accountMaintenanceRoutes.ts diff --git a/src/server/domains/security/mfa/accountMfaDisable.ts b/greenfield/src/server/domains/security/mfa/accountMfaDisable.ts similarity index 100% rename from src/server/domains/security/mfa/accountMfaDisable.ts rename to greenfield/src/server/domains/security/mfa/accountMfaDisable.ts diff --git a/src/server/domains/security/mfa/accountPasswordReauthentication.ts b/greenfield/src/server/domains/security/mfa/accountPasswordReauthentication.ts similarity index 100% rename from src/server/domains/security/mfa/accountPasswordReauthentication.ts rename to greenfield/src/server/domains/security/mfa/accountPasswordReauthentication.ts diff --git a/src/server/domains/security/mfa/accountProcedureResponses.ts b/greenfield/src/server/domains/security/mfa/accountProcedureResponses.ts similarity index 100% rename from src/server/domains/security/mfa/accountProcedureResponses.ts rename to greenfield/src/server/domains/security/mfa/accountProcedureResponses.ts diff --git a/src/server/domains/security/mfa/accountProofRoutes.ts b/greenfield/src/server/domains/security/mfa/accountProofRoutes.ts similarity index 100% rename from src/server/domains/security/mfa/accountProofRoutes.ts rename to greenfield/src/server/domains/security/mfa/accountProofRoutes.ts diff --git a/src/server/domains/security/mfa/accountRecoveryCodeRotation.ts b/greenfield/src/server/domains/security/mfa/accountRecoveryCodeRotation.ts similarity index 100% rename from src/server/domains/security/mfa/accountRecoveryCodeRotation.ts rename to greenfield/src/server/domains/security/mfa/accountRecoveryCodeRotation.ts diff --git a/src/server/domains/security/mfa/accountRecoveryStepUp.ts b/greenfield/src/server/domains/security/mfa/accountRecoveryStepUp.ts similarity index 100% rename from src/server/domains/security/mfa/accountRecoveryStepUp.ts rename to greenfield/src/server/domains/security/mfa/accountRecoveryStepUp.ts diff --git a/src/server/domains/security/mfa/accountSecuritySummary.ts b/greenfield/src/server/domains/security/mfa/accountSecuritySummary.ts similarity index 100% rename from src/server/domains/security/mfa/accountSecuritySummary.ts rename to greenfield/src/server/domains/security/mfa/accountSecuritySummary.ts diff --git a/src/server/domains/security/mfa/accountTotpEnrollmentBegin.ts b/greenfield/src/server/domains/security/mfa/accountTotpEnrollmentBegin.ts similarity index 100% rename from src/server/domains/security/mfa/accountTotpEnrollmentBegin.ts rename to greenfield/src/server/domains/security/mfa/accountTotpEnrollmentBegin.ts diff --git a/src/server/domains/security/mfa/accountTotpEnrollmentConfirmation.ts b/greenfield/src/server/domains/security/mfa/accountTotpEnrollmentConfirmation.ts similarity index 100% rename from src/server/domains/security/mfa/accountTotpEnrollmentConfirmation.ts rename to greenfield/src/server/domains/security/mfa/accountTotpEnrollmentConfirmation.ts diff --git a/src/server/domains/security/mfa/accountTotpFactorRemoval.ts b/greenfield/src/server/domains/security/mfa/accountTotpFactorRemoval.ts similarity index 100% rename from src/server/domains/security/mfa/accountTotpFactorRemoval.ts rename to greenfield/src/server/domains/security/mfa/accountTotpFactorRemoval.ts diff --git a/src/server/domains/security/mfa/accountTotpStepUp.ts b/greenfield/src/server/domains/security/mfa/accountTotpStepUp.ts similarity index 100% rename from src/server/domains/security/mfa/accountTotpStepUp.ts rename to greenfield/src/server/domains/security/mfa/accountTotpStepUp.ts diff --git a/src/server/domains/security/mfa/accountWebAuthnCredentialRemoval.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnCredentialRemoval.ts similarity index 100% rename from src/server/domains/security/mfa/accountWebAuthnCredentialRemoval.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnCredentialRemoval.ts diff --git a/src/server/domains/security/mfa/accountWebAuthnEnrollmentBegin.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentBegin.ts similarity index 100% rename from src/server/domains/security/mfa/accountWebAuthnEnrollmentBegin.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentBegin.ts diff --git a/src/server/domains/security/mfa/accountWebAuthnEnrollmentConfirmation.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentConfirmation.ts similarity index 100% rename from src/server/domains/security/mfa/accountWebAuthnEnrollmentConfirmation.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentConfirmation.ts diff --git a/src/server/domains/security/mfa/accountWebAuthnEnrollmentSettlement.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentSettlement.ts similarity index 100% rename from src/server/domains/security/mfa/accountWebAuthnEnrollmentSettlement.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentSettlement.ts diff --git a/src/server/domains/security/mfa/accountWebAuthnOperations.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnOperations.ts similarity index 100% rename from src/server/domains/security/mfa/accountWebAuthnOperations.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnOperations.ts diff --git a/src/server/domains/security/mfa/accountWebAuthnRoutes.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnRoutes.ts similarity index 100% rename from src/server/domains/security/mfa/accountWebAuthnRoutes.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnRoutes.ts diff --git a/src/server/domains/security/mfa/accountWebAuthnState.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnState.ts similarity index 100% rename from src/server/domains/security/mfa/accountWebAuthnState.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnState.ts diff --git a/src/server/domains/security/mfa/accountWebAuthnStepUp.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUp.ts similarity index 100% rename from src/server/domains/security/mfa/accountWebAuthnStepUp.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnStepUp.ts diff --git a/src/server/domains/security/mfa/accountWebAuthnStepUpBegin.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpBegin.ts similarity index 100% rename from src/server/domains/security/mfa/accountWebAuthnStepUpBegin.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpBegin.ts diff --git a/src/server/domains/security/mfa/accountWebAuthnStepUpSettlement.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpSettlement.ts similarity index 100% rename from src/server/domains/security/mfa/accountWebAuthnStepUpSettlement.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpSettlement.ts diff --git a/src/server/domains/security/mfa/lifecycleRepository.test.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepository.test.ts similarity index 100% rename from src/server/domains/security/mfa/lifecycleRepository.test.ts rename to greenfield/src/server/domains/security/mfa/lifecycleRepository.test.ts diff --git a/src/server/domains/security/mfa/lifecycleRepository.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepository.ts similarity index 100% rename from src/server/domains/security/mfa/lifecycleRepository.ts rename to greenfield/src/server/domains/security/mfa/lifecycleRepository.ts diff --git a/src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts similarity index 100% rename from src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts rename to greenfield/src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts diff --git a/src/server/domains/security/mfa/lifecycleRepositoryReader.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepositoryReader.ts similarity index 100% rename from src/server/domains/security/mfa/lifecycleRepositoryReader.ts rename to greenfield/src/server/domains/security/mfa/lifecycleRepositoryReader.ts diff --git a/src/server/domains/security/mfa/lifecycleRepositoryRecords.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepositoryRecords.ts similarity index 100% rename from src/server/domains/security/mfa/lifecycleRepositoryRecords.ts rename to greenfield/src/server/domains/security/mfa/lifecycleRepositoryRecords.ts diff --git a/src/server/domains/security/mfa/lifecycleRepositoryTypes.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepositoryTypes.ts similarity index 100% rename from src/server/domains/security/mfa/lifecycleRepositoryTypes.ts rename to greenfield/src/server/domains/security/mfa/lifecycleRepositoryTypes.ts diff --git a/src/server/domains/security/mfa/lifecycleRepositoryUnitOfWork.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepositoryUnitOfWork.ts similarity index 100% rename from src/server/domains/security/mfa/lifecycleRepositoryUnitOfWork.ts rename to greenfield/src/server/domains/security/mfa/lifecycleRepositoryUnitOfWork.ts diff --git a/src/server/domains/security/mfa/loginCoordinator.ts b/greenfield/src/server/domains/security/mfa/loginCoordinator.ts similarity index 100% rename from src/server/domains/security/mfa/loginCoordinator.ts rename to greenfield/src/server/domains/security/mfa/loginCoordinator.ts diff --git a/src/server/domains/security/mfa/loginLifecycle.pending.test.ts b/greenfield/src/server/domains/security/mfa/loginLifecycle.pending.test.ts similarity index 100% rename from src/server/domains/security/mfa/loginLifecycle.pending.test.ts rename to greenfield/src/server/domains/security/mfa/loginLifecycle.pending.test.ts diff --git a/src/server/domains/security/mfa/loginLifecycle.recovery.test.ts b/greenfield/src/server/domains/security/mfa/loginLifecycle.recovery.test.ts similarity index 100% rename from src/server/domains/security/mfa/loginLifecycle.recovery.test.ts rename to greenfield/src/server/domains/security/mfa/loginLifecycle.recovery.test.ts diff --git a/src/server/domains/security/mfa/loginLifecycle.totp.test.ts b/greenfield/src/server/domains/security/mfa/loginLifecycle.totp.test.ts similarity index 100% rename from src/server/domains/security/mfa/loginLifecycle.totp.test.ts rename to greenfield/src/server/domains/security/mfa/loginLifecycle.totp.test.ts diff --git a/src/server/domains/security/mfa/loginLifecycle.ts b/greenfield/src/server/domains/security/mfa/loginLifecycle.ts similarity index 100% rename from src/server/domains/security/mfa/loginLifecycle.ts rename to greenfield/src/server/domains/security/mfa/loginLifecycle.ts diff --git a/src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts b/greenfield/src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts similarity index 100% rename from src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts rename to greenfield/src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts diff --git a/src/server/domains/security/mfa/loginLifecycleContext.ts b/greenfield/src/server/domains/security/mfa/loginLifecycleContext.ts similarity index 100% rename from src/server/domains/security/mfa/loginLifecycleContext.ts rename to greenfield/src/server/domains/security/mfa/loginLifecycleContext.ts diff --git a/src/server/domains/security/mfa/loginLifecycleTypes.ts b/greenfield/src/server/domains/security/mfa/loginLifecycleTypes.ts similarity index 100% rename from src/server/domains/security/mfa/loginLifecycleTypes.ts rename to greenfield/src/server/domains/security/mfa/loginLifecycleTypes.ts diff --git a/src/server/domains/security/mfa/loginPendingLifecycle.ts b/greenfield/src/server/domains/security/mfa/loginPendingLifecycle.ts similarity index 100% rename from src/server/domains/security/mfa/loginPendingLifecycle.ts rename to greenfield/src/server/domains/security/mfa/loginPendingLifecycle.ts diff --git a/src/server/domains/security/mfa/loginRecoveryProof.ts b/greenfield/src/server/domains/security/mfa/loginRecoveryProof.ts similarity index 100% rename from src/server/domains/security/mfa/loginRecoveryProof.ts rename to greenfield/src/server/domains/security/mfa/loginRecoveryProof.ts diff --git a/src/server/domains/security/mfa/loginTotpProof.ts b/greenfield/src/server/domains/security/mfa/loginTotpProof.ts similarity index 100% rename from src/server/domains/security/mfa/loginTotpProof.ts rename to greenfield/src/server/domains/security/mfa/loginTotpProof.ts diff --git a/src/server/domains/security/mfa/loginWebAuthnChallenge.ts b/greenfield/src/server/domains/security/mfa/loginWebAuthnChallenge.ts similarity index 100% rename from src/server/domains/security/mfa/loginWebAuthnChallenge.ts rename to greenfield/src/server/domains/security/mfa/loginWebAuthnChallenge.ts diff --git a/src/server/domains/security/mfa/loginWebAuthnProof.ts b/greenfield/src/server/domains/security/mfa/loginWebAuthnProof.ts similarity index 100% rename from src/server/domains/security/mfa/loginWebAuthnProof.ts rename to greenfield/src/server/domains/security/mfa/loginWebAuthnProof.ts diff --git a/src/server/domains/security/mfa/procedures.test.ts b/greenfield/src/server/domains/security/mfa/procedures.test.ts similarity index 100% rename from src/server/domains/security/mfa/procedures.test.ts rename to greenfield/src/server/domains/security/mfa/procedures.test.ts diff --git a/src/server/domains/security/mfa/procedures.ts b/greenfield/src/server/domains/security/mfa/procedures.ts similarity index 100% rename from src/server/domains/security/mfa/procedures.ts rename to greenfield/src/server/domains/security/mfa/procedures.ts diff --git a/src/server/domains/security/mfa/recoveryCodes.test.ts b/greenfield/src/server/domains/security/mfa/recoveryCodes.test.ts similarity index 100% rename from src/server/domains/security/mfa/recoveryCodes.test.ts rename to greenfield/src/server/domains/security/mfa/recoveryCodes.test.ts diff --git a/src/server/domains/security/mfa/recoveryCodes.ts b/greenfield/src/server/domains/security/mfa/recoveryCodes.ts similarity index 100% rename from src/server/domains/security/mfa/recoveryCodes.ts rename to greenfield/src/server/domains/security/mfa/recoveryCodes.ts diff --git a/src/server/domains/security/mfa/testSupport/accountLifecycle.ts b/greenfield/src/server/domains/security/mfa/testSupport/accountLifecycle.ts similarity index 100% rename from src/server/domains/security/mfa/testSupport/accountLifecycle.ts rename to greenfield/src/server/domains/security/mfa/testSupport/accountLifecycle.ts diff --git a/src/server/domains/security/mfa/testSupport/loginLifecycle.ts b/greenfield/src/server/domains/security/mfa/testSupport/loginLifecycle.ts similarity index 100% rename from src/server/domains/security/mfa/testSupport/loginLifecycle.ts rename to greenfield/src/server/domains/security/mfa/testSupport/loginLifecycle.ts diff --git a/src/server/domains/security/mfa/totp.test.ts b/greenfield/src/server/domains/security/mfa/totp.test.ts similarity index 100% rename from src/server/domains/security/mfa/totp.test.ts rename to greenfield/src/server/domains/security/mfa/totp.test.ts diff --git a/src/server/domains/security/mfa/totp.ts b/greenfield/src/server/domains/security/mfa/totp.ts similarity index 100% rename from src/server/domains/security/mfa/totp.ts rename to greenfield/src/server/domains/security/mfa/totp.ts diff --git a/src/server/domains/security/mfa/totpSecretCipher.test.ts b/greenfield/src/server/domains/security/mfa/totpSecretCipher.test.ts similarity index 100% rename from src/server/domains/security/mfa/totpSecretCipher.test.ts rename to greenfield/src/server/domains/security/mfa/totpSecretCipher.test.ts diff --git a/src/server/domains/security/mfa/totpSecretCipher.ts b/greenfield/src/server/domains/security/mfa/totpSecretCipher.ts similarity index 100% rename from src/server/domains/security/mfa/totpSecretCipher.ts rename to greenfield/src/server/domains/security/mfa/totpSecretCipher.ts diff --git a/src/server/domains/security/mfa/webauthn/adapter.test.ts b/greenfield/src/server/domains/security/mfa/webauthn/adapter.test.ts similarity index 100% rename from src/server/domains/security/mfa/webauthn/adapter.test.ts rename to greenfield/src/server/domains/security/mfa/webauthn/adapter.test.ts diff --git a/src/server/domains/security/mfa/webauthn/adapter.ts b/greenfield/src/server/domains/security/mfa/webauthn/adapter.ts similarity index 100% rename from src/server/domains/security/mfa/webauthn/adapter.ts rename to greenfield/src/server/domains/security/mfa/webauthn/adapter.ts diff --git a/src/server/domains/security/mfa/webauthn/boundaryValidation.ts b/greenfield/src/server/domains/security/mfa/webauthn/boundaryValidation.ts similarity index 100% rename from src/server/domains/security/mfa/webauthn/boundaryValidation.ts rename to greenfield/src/server/domains/security/mfa/webauthn/boundaryValidation.ts diff --git a/src/server/domains/security/mfa/webauthn/credentialState.test.ts b/greenfield/src/server/domains/security/mfa/webauthn/credentialState.test.ts similarity index 100% rename from src/server/domains/security/mfa/webauthn/credentialState.test.ts rename to greenfield/src/server/domains/security/mfa/webauthn/credentialState.test.ts diff --git a/src/server/domains/security/mfa/webauthn/credentialState.ts b/greenfield/src/server/domains/security/mfa/webauthn/credentialState.ts similarity index 100% rename from src/server/domains/security/mfa/webauthn/credentialState.ts rename to greenfield/src/server/domains/security/mfa/webauthn/credentialState.ts diff --git a/src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.test.ts b/greenfield/src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.test.ts similarity index 100% rename from src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.test.ts rename to greenfield/src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.test.ts diff --git a/src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.ts b/greenfield/src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.ts similarity index 100% rename from src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.ts rename to greenfield/src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.ts diff --git a/src/server/domains/security/mfa/webauthn/testSupport/ceremonyFixture.ts b/greenfield/src/server/domains/security/mfa/webauthn/testSupport/ceremonyFixture.ts similarity index 100% rename from src/server/domains/security/mfa/webauthn/testSupport/ceremonyFixture.ts rename to greenfield/src/server/domains/security/mfa/webauthn/testSupport/ceremonyFixture.ts diff --git a/src/server/domains/security/password.test.ts b/greenfield/src/server/domains/security/password.test.ts similarity index 100% rename from src/server/domains/security/password.test.ts rename to greenfield/src/server/domains/security/password.test.ts diff --git a/src/server/domains/security/password.ts b/greenfield/src/server/domains/security/password.ts similarity index 100% rename from src/server/domains/security/password.ts rename to greenfield/src/server/domains/security/password.ts diff --git a/src/server/domains/security/procedureSupport.ts b/greenfield/src/server/domains/security/procedureSupport.ts similarity index 100% rename from src/server/domains/security/procedureSupport.ts rename to greenfield/src/server/domains/security/procedureSupport.ts diff --git a/src/server/domains/security/procedures.test.ts b/greenfield/src/server/domains/security/procedures.test.ts similarity index 100% rename from src/server/domains/security/procedures.test.ts rename to greenfield/src/server/domains/security/procedures.test.ts diff --git a/src/server/domains/security/procedures.ts b/greenfield/src/server/domains/security/procedures.ts similarity index 100% rename from src/server/domains/security/procedures.ts rename to greenfield/src/server/domains/security/procedures.ts diff --git a/src/server/domains/security/recentAuthentication.test.ts b/greenfield/src/server/domains/security/recentAuthentication.test.ts similarity index 100% rename from src/server/domains/security/recentAuthentication.test.ts rename to greenfield/src/server/domains/security/recentAuthentication.test.ts diff --git a/src/server/domains/security/recentAuthentication.ts b/greenfield/src/server/domains/security/recentAuthentication.ts similarity index 100% rename from src/server/domains/security/recentAuthentication.ts rename to greenfield/src/server/domains/security/recentAuthentication.ts diff --git a/src/server/domains/security/requestAuthentication.ts b/greenfield/src/server/domains/security/requestAuthentication.ts similarity index 100% rename from src/server/domains/security/requestAuthentication.ts rename to greenfield/src/server/domains/security/requestAuthentication.ts diff --git a/src/server/domains/security/requestAuthenticationAutomation.test.ts b/greenfield/src/server/domains/security/requestAuthenticationAutomation.test.ts similarity index 100% rename from src/server/domains/security/requestAuthenticationAutomation.test.ts rename to greenfield/src/server/domains/security/requestAuthenticationAutomation.test.ts diff --git a/src/server/domains/security/requestAuthenticationRepository.test.ts b/greenfield/src/server/domains/security/requestAuthenticationRepository.test.ts similarity index 100% rename from src/server/domains/security/requestAuthenticationRepository.test.ts rename to greenfield/src/server/domains/security/requestAuthenticationRepository.test.ts diff --git a/src/server/domains/security/requestAuthenticationRepository.ts b/greenfield/src/server/domains/security/requestAuthenticationRepository.ts similarity index 100% rename from src/server/domains/security/requestAuthenticationRepository.ts rename to greenfield/src/server/domains/security/requestAuthenticationRepository.ts diff --git a/src/server/domains/security/requestAuthenticationSession.test.ts b/greenfield/src/server/domains/security/requestAuthenticationSession.test.ts similarity index 100% rename from src/server/domains/security/requestAuthenticationSession.test.ts rename to greenfield/src/server/domains/security/requestAuthenticationSession.test.ts diff --git a/src/server/domains/security/securityAuditStore.ts b/greenfield/src/server/domains/security/securityAuditStore.ts similarity index 100% rename from src/server/domains/security/securityAuditStore.ts rename to greenfield/src/server/domains/security/securityAuditStore.ts diff --git a/src/server/domains/security/securityPersistenceTypes.ts b/greenfield/src/server/domains/security/securityPersistenceTypes.ts similarity index 95% rename from src/server/domains/security/securityPersistenceTypes.ts rename to greenfield/src/server/domains/security/securityPersistenceTypes.ts index 1af2c154e..7c4427060 100644 --- a/src/server/domains/security/securityPersistenceTypes.ts +++ b/greenfield/src/server/domains/security/securityPersistenceTypes.ts @@ -1,16 +1,19 @@ import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; -import * as v from "valibot"; +import type * as v from "valibot"; import type { AuthenticationRateLimitKind } from "../../database/schema/authRateLimitBuckets.ts"; -import { +import type { authRateLimitBucketInsertSchema, authRateLimitBucketSelectSchema, } from "../../database/validation/authRateLimitBuckets.ts"; -import { +import type { authSessionInsertSchema, authSessionSelectSchema, } from "../../database/validation/authSessions.ts"; -import { userInsertSchema, userSelectSchema } from "../../database/validation/users.ts"; +import type { + userInsertSchema, + userSelectSchema, +} from "../../database/validation/users.ts"; export type AuthRateLimitBucket = v.InferOutput; export type AuthRateLimitBucketInsert = v.InferOutput< diff --git a/src/server/domains/security/securityUserStore.ts b/greenfield/src/server/domains/security/securityUserStore.ts similarity index 100% rename from src/server/domains/security/securityUserStore.ts rename to greenfield/src/server/domains/security/securityUserStore.ts diff --git a/src/server/domains/security/testSupport/authentication.ts b/greenfield/src/server/domains/security/testSupport/authentication.ts similarity index 100% rename from src/server/domains/security/testSupport/authentication.ts rename to greenfield/src/server/domains/security/testSupport/authentication.ts diff --git a/src/server/domains/security/testSupport/authenticationLifecycle.ts b/greenfield/src/server/domains/security/testSupport/authenticationLifecycle.ts similarity index 100% rename from src/server/domains/security/testSupport/authenticationLifecycle.ts rename to greenfield/src/server/domains/security/testSupport/authenticationLifecycle.ts diff --git a/src/server/domains/system/procedures.ts b/greenfield/src/server/domains/system/procedures.ts similarity index 100% rename from src/server/domains/system/procedures.ts rename to greenfield/src/server/domains/system/procedures.ts diff --git a/src/server/platform/configuration/applicationConfigurationError.ts b/greenfield/src/server/platform/configuration/applicationConfigurationError.ts similarity index 100% rename from src/server/platform/configuration/applicationConfigurationError.ts rename to greenfield/src/server/platform/configuration/applicationConfigurationError.ts diff --git a/src/server/platform/configuration/configurationRegistry.test.ts b/greenfield/src/server/platform/configuration/configurationRegistry.test.ts similarity index 100% rename from src/server/platform/configuration/configurationRegistry.test.ts rename to greenfield/src/server/platform/configuration/configurationRegistry.test.ts diff --git a/src/server/platform/configuration/webConfiguration.test.ts b/greenfield/src/server/platform/configuration/webConfiguration.test.ts similarity index 100% rename from src/server/platform/configuration/webConfiguration.test.ts rename to greenfield/src/server/platform/configuration/webConfiguration.test.ts diff --git a/src/server/platform/configuration/webConfiguration.ts b/greenfield/src/server/platform/configuration/webConfiguration.ts similarity index 100% rename from src/server/platform/configuration/webConfiguration.ts rename to greenfield/src/server/platform/configuration/webConfiguration.ts diff --git a/src/server/platform/errors/safeFailure.test.ts b/greenfield/src/server/platform/errors/safeFailure.test.ts similarity index 100% rename from src/server/platform/errors/safeFailure.test.ts rename to greenfield/src/server/platform/errors/safeFailure.test.ts diff --git a/src/server/platform/errors/safeFailure.ts b/greenfield/src/server/platform/errors/safeFailure.ts similarity index 100% rename from src/server/platform/errors/safeFailure.ts rename to greenfield/src/server/platform/errors/safeFailure.ts diff --git a/src/server/platform/gateway/gatewayCredentialProtocol.test.ts b/greenfield/src/server/platform/gateway/gatewayCredentialProtocol.test.ts similarity index 100% rename from src/server/platform/gateway/gatewayCredentialProtocol.test.ts rename to greenfield/src/server/platform/gateway/gatewayCredentialProtocol.test.ts diff --git a/src/server/platform/gateway/gatewayCredentialProtocol.ts b/greenfield/src/server/platform/gateway/gatewayCredentialProtocol.ts similarity index 100% rename from src/server/platform/gateway/gatewayCredentialProtocol.ts rename to greenfield/src/server/platform/gateway/gatewayCredentialProtocol.ts diff --git a/src/server/platform/gateway/gatewayCredentialVerifier.test.ts b/greenfield/src/server/platform/gateway/gatewayCredentialVerifier.test.ts similarity index 100% rename from src/server/platform/gateway/gatewayCredentialVerifier.test.ts rename to greenfield/src/server/platform/gateway/gatewayCredentialVerifier.test.ts diff --git a/src/server/platform/gateway/gatewayCredentialVerifier.ts b/greenfield/src/server/platform/gateway/gatewayCredentialVerifier.ts similarity index 100% rename from src/server/platform/gateway/gatewayCredentialVerifier.ts rename to greenfield/src/server/platform/gateway/gatewayCredentialVerifier.ts diff --git a/src/server/platform/observability/effectLogger.test.ts b/greenfield/src/server/platform/observability/effectLogger.test.ts similarity index 100% rename from src/server/platform/observability/effectLogger.test.ts rename to greenfield/src/server/platform/observability/effectLogger.test.ts diff --git a/src/server/platform/observability/effectLogger.ts b/greenfield/src/server/platform/observability/effectLogger.ts similarity index 100% rename from src/server/platform/observability/effectLogger.ts rename to greenfield/src/server/platform/observability/effectLogger.ts diff --git a/src/server/platform/observability/structuredLogger.test.ts b/greenfield/src/server/platform/observability/structuredLogger.test.ts similarity index 100% rename from src/server/platform/observability/structuredLogger.test.ts rename to greenfield/src/server/platform/observability/structuredLogger.test.ts diff --git a/src/server/platform/observability/structuredLogger.ts b/greenfield/src/server/platform/observability/structuredLogger.ts similarity index 100% rename from src/server/platform/observability/structuredLogger.ts rename to greenfield/src/server/platform/observability/structuredLogger.ts diff --git a/src/server/platform/readiness/readinessState.ts b/greenfield/src/server/platform/readiness/readinessState.ts similarity index 100% rename from src/server/platform/readiness/readinessState.ts rename to greenfield/src/server/platform/readiness/readinessState.ts diff --git a/src/server/platform/realtime/boundedAsyncQueue.test.ts b/greenfield/src/server/platform/realtime/boundedAsyncQueue.test.ts similarity index 100% rename from src/server/platform/realtime/boundedAsyncQueue.test.ts rename to greenfield/src/server/platform/realtime/boundedAsyncQueue.test.ts diff --git a/src/server/platform/realtime/boundedAsyncQueue.ts b/greenfield/src/server/platform/realtime/boundedAsyncQueue.ts similarity index 100% rename from src/server/platform/realtime/boundedAsyncQueue.ts rename to greenfield/src/server/platform/realtime/boundedAsyncQueue.ts diff --git a/src/server/platform/realtime/eventPump.ts b/greenfield/src/server/platform/realtime/eventPump.ts similarity index 100% rename from src/server/platform/realtime/eventPump.ts rename to greenfield/src/server/platform/realtime/eventPump.ts diff --git a/src/server/platform/realtime/eventPumpContract.test.ts b/greenfield/src/server/platform/realtime/eventPumpContract.test.ts similarity index 100% rename from src/server/platform/realtime/eventPumpContract.test.ts rename to greenfield/src/server/platform/realtime/eventPumpContract.test.ts diff --git a/src/server/platform/realtime/eventPumpContract.ts b/greenfield/src/server/platform/realtime/eventPumpContract.ts similarity index 100% rename from src/server/platform/realtime/eventPumpContract.ts rename to greenfield/src/server/platform/realtime/eventPumpContract.ts diff --git a/src/server/platform/realtime/eventPumpPolling.test.ts b/greenfield/src/server/platform/realtime/eventPumpPolling.test.ts similarity index 100% rename from src/server/platform/realtime/eventPumpPolling.test.ts rename to greenfield/src/server/platform/realtime/eventPumpPolling.test.ts diff --git a/src/server/platform/realtime/eventPumpPolling.ts b/greenfield/src/server/platform/realtime/eventPumpPolling.ts similarity index 100% rename from src/server/platform/realtime/eventPumpPolling.ts rename to greenfield/src/server/platform/realtime/eventPumpPolling.ts diff --git a/src/server/platform/realtime/eventPumpService.ts b/greenfield/src/server/platform/realtime/eventPumpService.ts similarity index 100% rename from src/server/platform/realtime/eventPumpService.ts rename to greenfield/src/server/platform/realtime/eventPumpService.ts diff --git a/src/server/platform/realtime/eventPumpServiceFairness.test.ts b/greenfield/src/server/platform/realtime/eventPumpServiceFairness.test.ts similarity index 100% rename from src/server/platform/realtime/eventPumpServiceFairness.test.ts rename to greenfield/src/server/platform/realtime/eventPumpServiceFairness.test.ts diff --git a/src/server/platform/realtime/eventPumpServiceLifecycle.test.ts b/greenfield/src/server/platform/realtime/eventPumpServiceLifecycle.test.ts similarity index 100% rename from src/server/platform/realtime/eventPumpServiceLifecycle.test.ts rename to greenfield/src/server/platform/realtime/eventPumpServiceLifecycle.test.ts diff --git a/src/server/platform/realtime/eventPumpServicePolling.test.ts b/greenfield/src/server/platform/realtime/eventPumpServicePolling.test.ts similarity index 100% rename from src/server/platform/realtime/eventPumpServicePolling.test.ts rename to greenfield/src/server/platform/realtime/eventPumpServicePolling.test.ts diff --git a/src/server/platform/realtime/eventPumpServiceSubscription.test.ts b/greenfield/src/server/platform/realtime/eventPumpServiceSubscription.test.ts similarity index 99% rename from src/server/platform/realtime/eventPumpServiceSubscription.test.ts rename to greenfield/src/server/platform/realtime/eventPumpServiceSubscription.test.ts index c821c850e..9ca310ea4 100644 --- a/src/server/platform/realtime/eventPumpServiceSubscription.test.ts +++ b/greenfield/src/server/platform/realtime/eventPumpServiceSubscription.test.ts @@ -166,7 +166,7 @@ test("retries subscription replay reads through the scoped Effect runtime", asyn : [ { entityId: "1", - entityType: "qualification", + entityType: "test-entity", expiresAt: toDate(60_000), id: 1, occurredAt: toDate(1000), diff --git a/src/server/platform/realtime/eventPumpState.ts b/greenfield/src/server/platform/realtime/eventPumpState.ts similarity index 100% rename from src/server/platform/realtime/eventPumpState.ts rename to greenfield/src/server/platform/realtime/eventPumpState.ts diff --git a/src/server/platform/realtime/eventPumpStreamErrors.ts b/greenfield/src/server/platform/realtime/eventPumpStreamErrors.ts similarity index 77% rename from src/server/platform/realtime/eventPumpStreamErrors.ts rename to greenfield/src/server/platform/realtime/eventPumpStreamErrors.ts index d4386c691..1a79e9b48 100644 --- a/src/server/platform/realtime/eventPumpStreamErrors.ts +++ b/greenfield/src/server/platform/realtime/eventPumpStreamErrors.ts @@ -5,7 +5,9 @@ import type { RealtimeSubscriptionInputErrorCode, } from "./eventPumpContract.ts"; -export class RealtimeEventCursorStreamError extends Schema.TaggedErrorClass( +const TaggedErrorClass = Schema.TaggedError; + +export class RealtimeEventCursorStreamError extends TaggedErrorClass( "mira-dashboard/server/platform/realtime/RealtimeEventCursorStreamError" )("RealtimeEventCursorStreamError", { code: Schema.Literals([ @@ -15,19 +17,19 @@ export class RealtimeEventCursorStreamError extends Schema.TaggedErrorClass( +export class RealtimeEventStoreStreamError extends TaggedErrorClass( "mira-dashboard/server/platform/realtime/RealtimeEventStoreStreamError" )("RealtimeEventStoreStreamError", { message: Schema.String, }) {} -export class RealtimeEventSlowConsumerStreamError extends Schema.TaggedErrorClass( +export class RealtimeEventSlowConsumerStreamError extends TaggedErrorClass( "mira-dashboard/server/platform/realtime/RealtimeEventSlowConsumerStreamError" )("RealtimeEventSlowConsumerStreamError", { message: Schema.String, }) {} -export class RealtimeEventSubscriptionStreamError extends Schema.TaggedErrorClass( +export class RealtimeEventSubscriptionStreamError extends TaggedErrorClass( "mira-dashboard/server/platform/realtime/RealtimeEventSubscriptionStreamError" )("RealtimeEventSubscriptionStreamError", { code: Schema.Literals([ diff --git a/src/server/platform/realtime/eventPumpSubscription.ts b/greenfield/src/server/platform/realtime/eventPumpSubscription.ts similarity index 100% rename from src/server/platform/realtime/eventPumpSubscription.ts rename to greenfield/src/server/platform/realtime/eventPumpSubscription.ts diff --git a/src/server/platform/realtime/eventPumpSubscriptionBackpressure.test.ts b/greenfield/src/server/platform/realtime/eventPumpSubscriptionBackpressure.test.ts similarity index 100% rename from src/server/platform/realtime/eventPumpSubscriptionBackpressure.test.ts rename to greenfield/src/server/platform/realtime/eventPumpSubscriptionBackpressure.test.ts diff --git a/src/server/platform/realtime/eventPumpSubscriptionCancellation.test.ts b/greenfield/src/server/platform/realtime/eventPumpSubscriptionCancellation.test.ts similarity index 100% rename from src/server/platform/realtime/eventPumpSubscriptionCancellation.test.ts rename to greenfield/src/server/platform/realtime/eventPumpSubscriptionCancellation.test.ts diff --git a/src/server/platform/realtime/eventPumpSubscriptionReplay.test.ts b/greenfield/src/server/platform/realtime/eventPumpSubscriptionReplay.test.ts similarity index 100% rename from src/server/platform/realtime/eventPumpSubscriptionReplay.test.ts rename to greenfield/src/server/platform/realtime/eventPumpSubscriptionReplay.test.ts diff --git a/src/server/platform/realtime/eventPumpSubscriptionRetention.test.ts b/greenfield/src/server/platform/realtime/eventPumpSubscriptionRetention.test.ts similarity index 100% rename from src/server/platform/realtime/eventPumpSubscriptionRetention.test.ts rename to greenfield/src/server/platform/realtime/eventPumpSubscriptionRetention.test.ts diff --git a/src/server/platform/realtime/eventStore.test.ts b/greenfield/src/server/platform/realtime/eventStore.test.ts similarity index 99% rename from src/server/platform/realtime/eventStore.test.ts rename to greenfield/src/server/platform/realtime/eventStore.test.ts index 9e5aaa0d2..0971afd13 100644 --- a/src/server/platform/realtime/eventStore.test.ts +++ b/greenfield/src/server/platform/realtime/eventStore.test.ts @@ -176,7 +176,7 @@ describe("realtime event store", () => { operation, payload_json, topic - ) VALUES ('entity-1', 'qualification', 2000, 1000, 'updated', 'not-json', 'topic.a') + ) VALUES ('entity-1', 'test-entity', 2000, 1000, 'updated', 'not-json', 'topic.a') `); database.sqlite.run("PRAGMA ignore_check_constraints = OFF"); diff --git a/src/server/platform/realtime/eventStore.ts b/greenfield/src/server/platform/realtime/eventStore.ts similarity index 100% rename from src/server/platform/realtime/eventStore.ts rename to greenfield/src/server/platform/realtime/eventStore.ts diff --git a/src/server/platform/realtime/eventStoreEffect.ts b/greenfield/src/server/platform/realtime/eventStoreEffect.ts similarity index 92% rename from src/server/platform/realtime/eventStoreEffect.ts rename to greenfield/src/server/platform/realtime/eventStoreEffect.ts index b8ddd896f..7047e7f9a 100644 --- a/src/server/platform/realtime/eventStoreEffect.ts +++ b/greenfield/src/server/platform/realtime/eventStoreEffect.ts @@ -1,14 +1,16 @@ import { Duration, Effect, Predicate, Schedule, Schema } from "effect"; import * as v from "valibot"; -export class RealtimeEventStoreBusyError extends Schema.TaggedErrorClass( +const TaggedErrorClass = Schema.TaggedError; + +export class RealtimeEventStoreBusyError extends TaggedErrorClass( "mira-dashboard/server/platform/realtime/RealtimeEventStoreBusyError" )("RealtimeEventStoreBusyError", { cause: Schema.Defect(), code: Schema.String, }) {} -export class RealtimeEventStoreUnavailableError extends Schema.TaggedErrorClass( +export class RealtimeEventStoreUnavailableError extends TaggedErrorClass( "mira-dashboard/server/platform/realtime/RealtimeEventStoreUnavailableError" )("RealtimeEventStoreUnavailableError", { cause: Schema.Defect(), diff --git a/src/server/platform/realtime/renewableStreamLease.test.ts b/greenfield/src/server/platform/realtime/renewableStreamLease.test.ts similarity index 100% rename from src/server/platform/realtime/renewableStreamLease.test.ts rename to greenfield/src/server/platform/realtime/renewableStreamLease.test.ts diff --git a/src/server/platform/realtime/renewableStreamLease.ts b/greenfield/src/server/platform/realtime/renewableStreamLease.ts similarity index 95% rename from src/server/platform/realtime/renewableStreamLease.ts rename to greenfield/src/server/platform/realtime/renewableStreamLease.ts index 06bb98e74..59effbefa 100644 --- a/src/server/platform/realtime/renewableStreamLease.ts +++ b/greenfield/src/server/platform/realtime/renewableStreamLease.ts @@ -6,6 +6,7 @@ import { } from "date-fns"; import { Clock, Duration, Effect, Fiber, Schema, Stream } from "effect"; +const TaggedErrorClass = Schema.TaggedError; const maximumLeaseWaitMs = minutesToMilliseconds(5); const renewalTimeoutMs = secondsToMilliseconds(5); const nanosecondsPerMillisecond = 1_000_000n; @@ -17,14 +18,14 @@ export interface RenewableStreamLease { } /** Typed operational failure when a lease provider does not answer in time. */ -export class RenewableStreamLeaseTimeoutError extends Schema.TaggedErrorClass( +export class RenewableStreamLeaseTimeoutError extends TaggedErrorClass( "mira-dashboard/server/platform/realtime/RenewableStreamLeaseTimeoutError" )("RenewableStreamLeaseTimeoutError", { message: Schema.String, }) {} /** Typed invariant failure when a provider returns an already expired lease. */ -export class RenewableStreamLeaseInvalidError extends Schema.TaggedErrorClass( +export class RenewableStreamLeaseInvalidError extends TaggedErrorClass( "mira-dashboard/server/platform/realtime/RenewableStreamLeaseInvalidError" )("RenewableStreamLeaseInvalidError", { message: Schema.String, diff --git a/src/server/platform/realtime/testSupport/eventPump.ts b/greenfield/src/server/platform/realtime/testSupport/eventPump.ts similarity index 98% rename from src/server/platform/realtime/testSupport/eventPump.ts rename to greenfield/src/server/platform/realtime/testSupport/eventPump.ts index 3b00c0c98..7c9f27161 100644 --- a/src/server/platform/realtime/testSupport/eventPump.ts +++ b/greenfield/src/server/platform/realtime/testSupport/eventPump.ts @@ -29,7 +29,7 @@ export function insertEvent( .insert(realtimeEvents) .values({ entityId: `entity-${options.occurredAtMs}`, - entityType: "qualification", + entityType: "test-entity", expiresAt: addMinutes(options.occurredAtMs, 1), occurredAt: toDate(options.occurredAtMs), operation: "updated", @@ -46,7 +46,7 @@ export function storedEvent(id: number, topic = "topic.a"): StoredRealtimeEvent const occurredAtMs = secondsToMilliseconds(id); return { entityId: `entity-${id}`, - entityType: "qualification", + entityType: "test-entity", expiresAt: addMinutes(occurredAtMs, 1), id, occurredAt: toDate(occurredAtMs), diff --git a/src/server/platform/realtime/testSupport/eventPumpService.ts b/greenfield/src/server/platform/realtime/testSupport/eventPumpService.ts similarity index 98% rename from src/server/platform/realtime/testSupport/eventPumpService.ts rename to greenfield/src/server/platform/realtime/testSupport/eventPumpService.ts index 69809813a..918c4b401 100644 --- a/src/server/platform/realtime/testSupport/eventPumpService.ts +++ b/greenfield/src/server/platform/realtime/testSupport/eventPumpService.ts @@ -37,7 +37,7 @@ export function changeDelivery(id: string): RealtimeEventDelivery { return { event: { entityId: id, - entityType: "qualification", + entityType: "test-entity", occurredAtMs: 1000, operation: "updated", payloadJson: "{}", diff --git a/src/server/platform/runtime/applicationRuntime.test.ts b/greenfield/src/server/platform/runtime/applicationRuntime.test.ts similarity index 100% rename from src/server/platform/runtime/applicationRuntime.test.ts rename to greenfield/src/server/platform/runtime/applicationRuntime.test.ts diff --git a/src/server/platform/runtime/applicationRuntime.ts b/greenfield/src/server/platform/runtime/applicationRuntime.ts similarity index 100% rename from src/server/platform/runtime/applicationRuntime.ts rename to greenfield/src/server/platform/runtime/applicationRuntime.ts diff --git a/src/server/platform/runtime/readRuntimeIdentity.test.ts b/greenfield/src/server/platform/runtime/readRuntimeIdentity.test.ts similarity index 100% rename from src/server/platform/runtime/readRuntimeIdentity.test.ts rename to greenfield/src/server/platform/runtime/readRuntimeIdentity.test.ts diff --git a/src/server/platform/runtime/readRuntimeIdentity.ts b/greenfield/src/server/platform/runtime/readRuntimeIdentity.ts similarity index 100% rename from src/server/platform/runtime/readRuntimeIdentity.ts rename to greenfield/src/server/platform/runtime/readRuntimeIdentity.ts diff --git a/src/server/rawHttp/authenticationClientSource.test.ts b/greenfield/src/server/rawHttp/authenticationClientSource.test.ts similarity index 100% rename from src/server/rawHttp/authenticationClientSource.test.ts rename to greenfield/src/server/rawHttp/authenticationClientSource.test.ts diff --git a/src/server/rawHttp/authenticationClientSource.ts b/greenfield/src/server/rawHttp/authenticationClientSource.ts similarity index 100% rename from src/server/rawHttp/authenticationClientSource.ts rename to greenfield/src/server/rawHttp/authenticationClientSource.ts diff --git a/src/server/rawHttp/authenticationCredentials.test.ts b/greenfield/src/server/rawHttp/authenticationCredentials.test.ts similarity index 100% rename from src/server/rawHttp/authenticationCredentials.test.ts rename to greenfield/src/server/rawHttp/authenticationCredentials.test.ts diff --git a/src/server/rawHttp/authenticationCredentials.ts b/greenfield/src/server/rawHttp/authenticationCredentials.ts similarity index 100% rename from src/server/rawHttp/authenticationCredentials.ts rename to greenfield/src/server/rawHttp/authenticationCredentials.ts diff --git a/src/server/rawHttp/health.ts b/greenfield/src/server/rawHttp/health.ts similarity index 100% rename from src/server/rawHttp/health.ts rename to greenfield/src/server/rawHttp/health.ts diff --git a/src/server/rawHttp/pendingLoginCookie.test.ts b/greenfield/src/server/rawHttp/pendingLoginCookie.test.ts similarity index 100% rename from src/server/rawHttp/pendingLoginCookie.test.ts rename to greenfield/src/server/rawHttp/pendingLoginCookie.test.ts diff --git a/src/server/rawHttp/pendingLoginCookie.ts b/greenfield/src/server/rawHttp/pendingLoginCookie.ts similarity index 100% rename from src/server/rawHttp/pendingLoginCookie.ts rename to greenfield/src/server/rawHttp/pendingLoginCookie.ts diff --git a/src/server/rawHttp/requestSecurity.test.ts b/greenfield/src/server/rawHttp/requestSecurity.test.ts similarity index 100% rename from src/server/rawHttp/requestSecurity.test.ts rename to greenfield/src/server/rawHttp/requestSecurity.test.ts diff --git a/src/server/rawHttp/requestSecurity.ts b/greenfield/src/server/rawHttp/requestSecurity.ts similarity index 100% rename from src/server/rawHttp/requestSecurity.ts rename to greenfield/src/server/rawHttp/requestSecurity.ts diff --git a/src/server/rawHttp/sessionCookie.test.ts b/greenfield/src/server/rawHttp/sessionCookie.test.ts similarity index 100% rename from src/server/rawHttp/sessionCookie.test.ts rename to greenfield/src/server/rawHttp/sessionCookie.test.ts diff --git a/src/server/rawHttp/sessionCookie.ts b/greenfield/src/server/rawHttp/sessionCookie.ts similarity index 100% rename from src/server/rawHttp/sessionCookie.ts rename to greenfield/src/server/rawHttp/sessionCookie.ts diff --git a/src/server/shared/crypto.test.ts b/greenfield/src/server/shared/crypto.test.ts similarity index 100% rename from src/server/shared/crypto.test.ts rename to greenfield/src/server/shared/crypto.test.ts diff --git a/src/server/shared/crypto.ts b/greenfield/src/server/shared/crypto.ts similarity index 100% rename from src/server/shared/crypto.ts rename to greenfield/src/server/shared/crypto.ts diff --git a/src/server/shared/opaqueToken.test.ts b/greenfield/src/server/shared/opaqueToken.test.ts similarity index 100% rename from src/server/shared/opaqueToken.test.ts rename to greenfield/src/server/shared/opaqueToken.test.ts diff --git a/src/server/shared/opaqueToken.ts b/greenfield/src/server/shared/opaqueToken.ts similarity index 100% rename from src/server/shared/opaqueToken.ts rename to greenfield/src/server/shared/opaqueToken.ts diff --git a/src/server/shared/passwordHash.ts b/greenfield/src/server/shared/passwordHash.ts similarity index 100% rename from src/server/shared/passwordHash.ts rename to greenfield/src/server/shared/passwordHash.ts diff --git a/src/server/shared/pendingLoginPolicy.ts b/greenfield/src/server/shared/pendingLoginPolicy.ts similarity index 100% rename from src/server/shared/pendingLoginPolicy.ts rename to greenfield/src/server/shared/pendingLoginPolicy.ts diff --git a/src/server/shared/totpSecretFormat.ts b/greenfield/src/server/shared/totpSecretFormat.ts similarity index 100% rename from src/server/shared/totpSecretFormat.ts rename to greenfield/src/server/shared/totpSecretFormat.ts diff --git a/src/server/test/contracts/superjsonTransport.test.ts b/greenfield/src/server/test/contracts/superjsonTransport.test.ts similarity index 100% rename from src/server/test/contracts/superjsonTransport.test.ts rename to greenfield/src/server/test/contracts/superjsonTransport.test.ts diff --git a/src/server/test/contracts/trpcErrors.test.ts b/greenfield/src/server/test/contracts/trpcErrors.test.ts similarity index 100% rename from src/server/test/contracts/trpcErrors.test.ts rename to greenfield/src/server/test/contracts/trpcErrors.test.ts diff --git a/src/server/test/support/authenticationWorkGate.ts b/greenfield/src/server/test/support/authenticationWorkGate.ts similarity index 100% rename from src/server/test/support/authenticationWorkGate.ts rename to greenfield/src/server/test/support/authenticationWorkGate.ts diff --git a/src/server/test/support/automationHttpSystem.ts b/greenfield/src/server/test/support/automationHttpSystem.ts similarity index 100% rename from src/server/test/support/automationHttpSystem.ts rename to greenfield/src/server/test/support/automationHttpSystem.ts diff --git a/src/server/test/support/freshDatabase.ts b/greenfield/src/server/test/support/freshDatabase.ts similarity index 100% rename from src/server/test/support/freshDatabase.ts rename to greenfield/src/server/test/support/freshDatabase.ts diff --git a/src/server/test/support/gatewayCredentialVerifier.ts b/greenfield/src/server/test/support/gatewayCredentialVerifier.ts similarity index 100% rename from src/server/test/support/gatewayCredentialVerifier.ts rename to greenfield/src/server/test/support/gatewayCredentialVerifier.ts diff --git a/src/server/test/support/mfaHttpSystem.ts b/greenfield/src/server/test/support/mfaHttpSystem.ts similarity index 100% rename from src/server/test/support/mfaHttpSystem.ts rename to greenfield/src/server/test/support/mfaHttpSystem.ts diff --git a/src/server/test/support/promise.ts b/greenfield/src/server/test/support/promise.ts similarity index 100% rename from src/server/test/support/promise.ts rename to greenfield/src/server/test/support/promise.ts diff --git a/src/server/test/support/requestContext.ts b/greenfield/src/server/test/support/requestContext.ts similarity index 100% rename from src/server/test/support/requestContext.ts rename to greenfield/src/server/test/support/requestContext.ts diff --git a/src/server/test/support/securityPassword.ts b/greenfield/src/server/test/support/securityPassword.ts similarity index 100% rename from src/server/test/support/securityPassword.ts rename to greenfield/src/server/test/support/securityPassword.ts diff --git a/src/server/test/system/serverAuthenticationResponses.test.ts b/greenfield/src/server/test/system/serverAuthenticationResponses.test.ts similarity index 100% rename from src/server/test/system/serverAuthenticationResponses.test.ts rename to greenfield/src/server/test/system/serverAuthenticationResponses.test.ts diff --git a/src/server/test/system/serverAuthenticationTransport.test.ts b/greenfield/src/server/test/system/serverAuthenticationTransport.test.ts similarity index 100% rename from src/server/test/system/serverAuthenticationTransport.test.ts rename to greenfield/src/server/test/system/serverAuthenticationTransport.test.ts diff --git a/src/server/test/system/serverAutomationSecurity.test.ts b/greenfield/src/server/test/system/serverAutomationSecurity.test.ts similarity index 100% rename from src/server/test/system/serverAutomationSecurity.test.ts rename to greenfield/src/server/test/system/serverAutomationSecurity.test.ts diff --git a/src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts b/greenfield/src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts similarity index 100% rename from src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts rename to greenfield/src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts diff --git a/src/server/test/system/serverAutomationSecurityLostResponse.test.ts b/greenfield/src/server/test/system/serverAutomationSecurityLostResponse.test.ts similarity index 100% rename from src/server/test/system/serverAutomationSecurityLostResponse.test.ts rename to greenfield/src/server/test/system/serverAutomationSecurityLostResponse.test.ts diff --git a/src/server/test/system/serverFoundation.test.ts b/greenfield/src/server/test/system/serverFoundation.test.ts similarity index 100% rename from src/server/test/system/serverFoundation.test.ts rename to greenfield/src/server/test/system/serverFoundation.test.ts diff --git a/src/server/test/system/serverGatewayCredentialVerification.test.ts b/greenfield/src/server/test/system/serverGatewayCredentialVerification.test.ts similarity index 100% rename from src/server/test/system/serverGatewayCredentialVerification.test.ts rename to greenfield/src/server/test/system/serverGatewayCredentialVerification.test.ts diff --git a/src/server/test/system/serverMfaAuthentication.test.ts b/greenfield/src/server/test/system/serverMfaAuthentication.test.ts similarity index 100% rename from src/server/test/system/serverMfaAuthentication.test.ts rename to greenfield/src/server/test/system/serverMfaAuthentication.test.ts diff --git a/src/server/test/system/serverRealtime.test.ts b/greenfield/src/server/test/system/serverRealtime.test.ts similarity index 100% rename from src/server/test/system/serverRealtime.test.ts rename to greenfield/src/server/test/system/serverRealtime.test.ts diff --git a/src/server/test/system/serverShutdown.test.ts b/greenfield/src/server/test/system/serverShutdown.test.ts similarity index 100% rename from src/server/test/system/serverShutdown.test.ts rename to greenfield/src/server/test/system/serverShutdown.test.ts diff --git a/src/server/test/system/serverWebAuthnAuthentication.test.ts b/greenfield/src/server/test/system/serverWebAuthnAuthentication.test.ts similarity index 100% rename from src/server/test/system/serverWebAuthnAuthentication.test.ts rename to greenfield/src/server/test/system/serverWebAuthnAuthentication.test.ts diff --git a/src/server/trpc/appRouter.test.ts b/greenfield/src/server/trpc/appRouter.test.ts similarity index 100% rename from src/server/trpc/appRouter.test.ts rename to greenfield/src/server/trpc/appRouter.test.ts diff --git a/src/server/trpc/appRouter.ts b/greenfield/src/server/trpc/appRouter.ts similarity index 100% rename from src/server/trpc/appRouter.ts rename to greenfield/src/server/trpc/appRouter.ts diff --git a/src/server/trpc/context.test.ts b/greenfield/src/server/trpc/context.test.ts similarity index 100% rename from src/server/trpc/context.test.ts rename to greenfield/src/server/trpc/context.test.ts diff --git a/src/server/trpc/context.ts b/greenfield/src/server/trpc/context.ts similarity index 100% rename from src/server/trpc/context.ts rename to greenfield/src/server/trpc/context.ts diff --git a/src/server/trpc/procedureErrorPolicy.test.ts b/greenfield/src/server/trpc/procedureErrorPolicy.test.ts similarity index 100% rename from src/server/trpc/procedureErrorPolicy.test.ts rename to greenfield/src/server/trpc/procedureErrorPolicy.test.ts diff --git a/src/server/trpc/procedureErrorPolicy.ts b/greenfield/src/server/trpc/procedureErrorPolicy.ts similarity index 100% rename from src/server/trpc/procedureErrorPolicy.ts rename to greenfield/src/server/trpc/procedureErrorPolicy.ts diff --git a/src/server/trpc/trpc.test.ts b/greenfield/src/server/trpc/trpc.test.ts similarity index 100% rename from src/server/trpc/trpc.test.ts rename to greenfield/src/server/trpc/trpc.test.ts diff --git a/src/server/trpc/trpc.ts b/greenfield/src/server/trpc/trpc.ts similarity index 100% rename from src/server/trpc/trpc.ts rename to greenfield/src/server/trpc/trpc.ts diff --git a/src/shared/bunRuntimePolicy.ts b/greenfield/src/shared/bunRuntimePolicy.ts similarity index 100% rename from src/shared/bunRuntimePolicy.ts rename to greenfield/src/shared/bunRuntimePolicy.ts diff --git a/src/shared/configuration/applicationConfigurationRegistry.ts b/greenfield/src/shared/configuration/applicationConfigurationRegistry.ts similarity index 100% rename from src/shared/configuration/applicationConfigurationRegistry.ts rename to greenfield/src/shared/configuration/applicationConfigurationRegistry.ts diff --git a/src/shared/dateTime.test.ts b/greenfield/src/shared/dateTime.test.ts similarity index 100% rename from src/shared/dateTime.test.ts rename to greenfield/src/shared/dateTime.test.ts diff --git a/src/shared/dateTime.ts b/greenfield/src/shared/dateTime.ts similarity index 100% rename from src/shared/dateTime.ts rename to greenfield/src/shared/dateTime.ts diff --git a/src/shared/encoding.test.ts b/greenfield/src/shared/encoding.test.ts similarity index 100% rename from src/shared/encoding.test.ts rename to greenfield/src/shared/encoding.test.ts diff --git a/src/shared/encoding.ts b/greenfield/src/shared/encoding.ts similarity index 100% rename from src/shared/encoding.ts rename to greenfield/src/shared/encoding.ts diff --git a/src/shared/json.test.ts b/greenfield/src/shared/json.test.ts similarity index 100% rename from src/shared/json.test.ts rename to greenfield/src/shared/json.test.ts diff --git a/src/shared/json.ts b/greenfield/src/shared/json.ts similarity index 100% rename from src/shared/json.ts rename to greenfield/src/shared/json.ts diff --git a/src/shared/recoveryCodePolicy.ts b/greenfield/src/shared/recoveryCodePolicy.ts similarity index 100% rename from src/shared/recoveryCodePolicy.ts rename to greenfield/src/shared/recoveryCodePolicy.ts diff --git a/src/shared/validation.test.ts b/greenfield/src/shared/validation.test.ts similarity index 100% rename from src/shared/validation.test.ts rename to greenfield/src/shared/validation.test.ts diff --git a/src/shared/validation.ts b/greenfield/src/shared/validation.ts similarity index 100% rename from src/shared/validation.ts rename to greenfield/src/shared/validation.ts diff --git a/qualification/build/frontendBuildQualification.test.ts b/greenfield/src/test/integration/build/frontendBuildScenario.test.ts similarity index 95% rename from qualification/build/frontendBuildQualification.test.ts rename to greenfield/src/test/integration/build/frontendBuildScenario.test.ts index 5720efe45..abec812a1 100644 --- a/qualification/build/frontendBuildQualification.test.ts +++ b/greenfield/src/test/integration/build/frontendBuildScenario.test.ts @@ -5,13 +5,13 @@ import path from "node:path"; import { assertSelfHostedFrontendHtml, - buildQualificationFrontend, - qualificationFrontendPluginOrder, -} from "./frontendBuildQualification"; + buildFrontendScenario, + frontendBuildPluginOrder, +} from "./frontendBuildScenario.ts"; const hashedAssetPattern = /^assets\/.+-[a-z\d]{8}\.(?:css|js)$/u; -describe("Bun frontend build qualification", () => { +describe("Bun frontend build scenario", () => { test("proves compiler-first HTML mechanics, Tailwind, lazy chunks, and delivery policy", async () => { const developmentOutdir = await mkdtemp( path.join(tmpdir(), "mira-build-development-") @@ -21,12 +21,12 @@ describe("Bun frontend build qualification", () => { ); try { - expect(qualificationFrontendPluginOrder).toEqual([ + expect(frontendBuildPluginOrder).toEqual([ "react-compiler", "@tailwindcss/bun", ]); - const development = await buildQualificationFrontend( + const development = await buildFrontendScenario( "development", developmentOutdir ); @@ -44,7 +44,7 @@ describe("Bun frontend build qualification", () => { ); expect(developmentJavaScript).toContain("useMemoCache"); - const production = await buildQualificationFrontend( + const production = await buildFrontendScenario( "production", productionOutdir ); diff --git a/qualification/build/frontendBuildQualification.ts b/greenfield/src/test/integration/build/frontendBuildScenario.ts similarity index 84% rename from qualification/build/frontendBuildQualification.ts rename to greenfield/src/test/integration/build/frontendBuildScenario.ts index c44e855f9..f39cd5fff 100644 --- a/qualification/build/frontendBuildQualification.ts +++ b/greenfield/src/test/integration/build/frontendBuildScenario.ts @@ -10,12 +10,12 @@ import { type FrontendBundleMetrics, writeFrontendHtmlAppEntrypoint, writePrecompressedFrontendAssets, -} from "../../scripts/frontendBuildArtifacts"; -import reactCompilerPlugin from "../../scripts/reactCompilerPlugin"; +} from "../../../../scripts/frontendBuildArtifacts.ts"; +import reactCompilerPlugin from "../../../../scripts/reactCompilerPlugin.ts"; -export type QualificationFrontendBuildMode = "development" | "production"; +export type FrontendBuildScenarioMode = "development" | "production"; -export interface QualificationFrontendBuildEvidence { +export interface FrontendBuildScenarioEvidence { compressedFileCount: number; initialOutputPaths: string[]; metafile: Bun.BuildMetafile; @@ -23,12 +23,14 @@ export interface QualificationFrontendBuildEvidence { outputPaths: string[]; } -const qualificationFrontendEntrypoint = path.resolve( +const frontendBuildFixtureEntrypoint = path.resolve( import.meta.dir, - "fixtures/frontend/index.html" + "../../../browser/testSupport/frontendBuildFixture/index.html" ); +const frontendBuildFixtureAppInput = + "src/browser/testSupport/frontendBuildFixture/src/main.tsx"; -export const qualificationFrontendPluginOrder = [ +export const frontendBuildPluginOrder = [ reactCompilerPlugin.name, tailwindPlugin.name, ] as const; @@ -54,10 +56,10 @@ const frontendHtmlSourceSetAttributes = new Set(["imagesrcset", "srcset"]); * @param outdir Disposable build output directory. * @returns Build metadata and delivery evidence. */ -export async function buildQualificationFrontend( - mode: QualificationFrontendBuildMode, +export async function buildFrontendScenario( + mode: FrontendBuildScenarioMode, outdir: string -): Promise { +): Promise { const resolvedOutdir = path.resolve(outdir); const isProduction = mode === "production"; @@ -68,7 +70,7 @@ export async function buildQualificationFrontend( define: { "process.env.NODE_ENV": JSON.stringify(mode), }, - entrypoints: [qualificationFrontendEntrypoint], + entrypoints: [frontendBuildFixtureEntrypoint], minify: isProduction, metafile: true, naming: { @@ -84,16 +86,20 @@ export async function buildQualificationFrontend( }); if (!result.success) { - throw new AggregateError(result.logs, "Qualification frontend build failed"); + throw new AggregateError(result.logs, "Frontend build scenario failed"); } if (!result.metafile) { - throw new Error("Qualification frontend build did not produce metadata"); + throw new Error("Frontend build scenario did not produce metadata"); } - await writeFrontendHtmlAppEntrypoint(result.metafile, resolvedOutdir); - const initialOutputPaths = [...initialFrontendOutputKeys(result.metafile)].map( - (outputPath) => normalizedOutputPath(outputPath, resolvedOutdir) + await writeFrontendHtmlAppEntrypoint( + result.metafile, + resolvedOutdir, + frontendBuildFixtureAppInput ); + const initialOutputPaths = [ + ...initialFrontendOutputKeys(result.metafile, frontendBuildFixtureAppInput), + ].map((outputPath) => normalizedOutputPath(outputPath, resolvedOutdir)); const outputPaths = result.outputs.map(({ path: outputPath }) => normalizedOutputPath(outputPath, resolvedOutdir) ); @@ -107,7 +113,11 @@ export async function buildQualificationFrontend( }; } - const metrics = await measureFrontendBundle(result.metafile, resolvedOutdir); + const metrics = await measureFrontendBundle( + result.metafile, + resolvedOutdir, + frontendBuildFixtureAppInput + ); assertFrontendBundleBudgets(metrics.measurements); const compressedFileCount = await writePrecompressedFrontendAssets( result.outputs.map(({ path: outputPath }) => outputPath) @@ -223,7 +233,7 @@ function isSelfHostedResourceReference(value: string): boolean { return false; } try { - const base = new URL("https://qualification.invalid/"); + const base = new URL("https://integration.invalid/"); const resolved = new URL(reference, base); return ( resolved.origin === base.origin && resolved.pathname.startsWith("/assets/") diff --git a/greenfield/src/test/integration/build/runFrontendBuildEvidence.ts b/greenfield/src/test/integration/build/runFrontendBuildEvidence.ts new file mode 100644 index 000000000..c33547e53 --- /dev/null +++ b/greenfield/src/test/integration/build/runFrontendBuildEvidence.ts @@ -0,0 +1,45 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { buildFrontendScenario } from "./frontendBuildScenario.ts"; + +/** Resource-budget evidence emitted by the isolated frontend build fixture. */ +export interface FrontendBuildEvidence { + compressedSidecarCount: number; + formatVersion: 1; + initialAssetCount: number; + outputFileCount: number; + sourceMapsIncluded: false; +} + +/** + * Runs the self-contained production-shaped frontend build scenario. + * @param outdir Disposable build output directory. + * @returns Bounded build and delivery evidence. + */ +export async function runFrontendBuildEvidence( + outdir: string +): Promise { + const evidence = await buildFrontendScenario("production", outdir); + const sourceMapsIncluded = evidence.outputPaths.some((file) => file.endsWith(".map")); + if (sourceMapsIncluded || !evidence.metrics) { + throw new Error("Production frontend scenario emitted invalid evidence"); + } + return { + compressedSidecarCount: evidence.compressedFileCount, + formatVersion: evidence.metrics.formatVersion, + initialAssetCount: evidence.initialOutputPaths.length, + outputFileCount: evidence.outputPaths.length, + sourceMapsIncluded: false, + }; +} + +if (import.meta.main) { + const outdir = await mkdtemp(path.join(tmpdir(), "mira-frontend-build-evidence-")); + try { + console.log(JSON.stringify(await runFrontendBuildEvidence(outdir), undefined, 2)); + } finally { + await rm(outdir, { force: true, recursive: true }); + } +} diff --git a/qualification/files/boundedFile.test.ts b/greenfield/src/test/integration/files/boundedFile.test.ts similarity index 98% rename from qualification/files/boundedFile.test.ts rename to greenfield/src/test/integration/files/boundedFile.test.ts index ed1f81e89..6328ce7b9 100644 --- a/qualification/files/boundedFile.test.ts +++ b/greenfield/src/test/integration/files/boundedFile.test.ts @@ -14,10 +14,10 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { - type BoundedFileReadQualificationHooks, + type BoundedFileReadTestHooks, readBoundedRegularFile, readBoundedUtf8RegularFile, -} from "./boundedFile.ts"; +} from "../../../../scripts/files/boundedFile.ts"; const invalidStateMessage = "Fixture has invalid file state"; @@ -28,7 +28,7 @@ async function rejectedError(operation: Promise): Promise { } function createInitialStatBarrier(): { - hooks: BoundedFileReadQualificationHooks; + hooks: BoundedFileReadTestHooks; reached: Promise; release: () => void; } { diff --git a/qualification/openclaw/sourceAudit.test.ts b/greenfield/src/test/integration/openclaw/sourceAudit.test.ts similarity index 97% rename from qualification/openclaw/sourceAudit.test.ts rename to greenfield/src/test/integration/openclaw/sourceAudit.test.ts index dd15cf3c2..614efc0b8 100644 --- a/qualification/openclaw/sourceAudit.test.ts +++ b/greenfield/src/test/integration/openclaw/sourceAudit.test.ts @@ -9,10 +9,13 @@ import { defaultReviewedOpenClawFixtureRoot, loadReviewedOpenClawFixtures, writeOpenClawAuditCandidate, -} from "./reviewedFixtures.ts"; -import { parseSourceAuditCliArguments } from "./runSourceAudit.ts"; -import { auditInstalledOpenClaw } from "./sourceAudit.ts"; -import { chatFixtureSchema, parseFixtureDocument } from "./sourceAuditSchemas.ts"; +} from "../../../../scripts/audits/openclaw/reviewedFixtures.ts"; +import { parseSourceAuditCliArguments } from "../../../../scripts/audits/openclaw/runSourceAudit.ts"; +import { auditInstalledOpenClaw } from "../../../../scripts/audits/openclaw/sourceAudit.ts"; +import { + chatFixtureSchema, + parseFixtureDocument, +} from "../../../../scripts/audits/openclaw/sourceAuditSchemas.ts"; const sourceVersion = "2026.7.2-beta.7"; const sourceCommit = "dabe1915362e20c25704af91612a32a8f4c96e83"; diff --git a/qualification/outbox/runSqliteOutboxEvidence.ts b/greenfield/src/test/integration/outbox/runSqliteOutboxEvidence.ts similarity index 92% rename from qualification/outbox/runSqliteOutboxEvidence.ts rename to greenfield/src/test/integration/outbox/runSqliteOutboxEvidence.ts index 6c3a4d1e8..f1a002e1b 100644 --- a/qualification/outbox/runSqliteOutboxEvidence.ts +++ b/greenfield/src/test/integration/outbox/runSqliteOutboxEvidence.ts @@ -1,9 +1,9 @@ import { Clock, Effect } from "effect"; import { - sqliteOutboxQualification, + sqliteOutboxScenario, summarizeOutboxLatencies, -} from "./sqliteOutboxQualification.ts"; +} from "./sqliteOutboxScenario.ts"; const defaultSampleCount = 5; const maximumSampleCount = 20; @@ -30,7 +30,7 @@ const evidence = Effect.gen(function* () { () => Effect.gen(function* () { const startedAt = yield* Clock.monotonicTimeNanos; - yield* sqliteOutboxQualification; + yield* sqliteOutboxScenario; const endedAt = yield* Clock.monotonicTimeNanos; return Number(endedAt - startedAt) / nanosecondsPerMillisecond; }), diff --git a/qualification/outbox/sqliteOutboxChild.ts b/greenfield/src/test/integration/outbox/sqliteOutboxChild.ts similarity index 79% rename from qualification/outbox/sqliteOutboxChild.ts rename to greenfield/src/test/integration/outbox/sqliteOutboxChild.ts index 4b6b14764..61ff172bd 100644 --- a/qualification/outbox/sqliteOutboxChild.ts +++ b/greenfield/src/test/integration/outbox/sqliteOutboxChild.ts @@ -7,11 +7,11 @@ import { type SqliteOutboxChildStatus, } from "./sqliteOutboxProtocol.ts"; import { - appendQualificationOutboxBatch, - claimQualificationOutboxBatch, - deliverQualificationOutboxClaims, - openQualificationOutboxDatabase, - retryQualificationSqliteOperation, + appendIntegrationOutboxBatch, + claimIntegrationOutboxBatch, + deliverIntegrationOutboxClaims, + openIntegrationOutboxDatabase, + retryIntegrationSqliteOperation, } from "./sqliteOutboxStore.ts"; const pathSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(4096)); @@ -59,20 +59,20 @@ const childCommandSchema = v.variant("kind", [ type ChildCommand = v.InferOutput; -class QualificationChildArgumentError extends Data.TaggedError( - "QualificationChildArgumentError" +class IntegrationChildArgumentError extends Data.TaggedError( + "IntegrationChildArgumentError" )<{ readonly message: string; }> {} -class QualificationChildStatusWriteError extends Data.TaggedError( - "QualificationChildStatusWriteError" +class IntegrationChildStatusWriteError extends Data.TaggedError( + "IntegrationChildStatusWriteError" )<{ readonly cause: unknown; }> {} -class QualificationOutboxPollingExhaustedError extends Data.TaggedError( - "QualificationOutboxPollingExhaustedError" +class IntegrationOutboxPollingExhaustedError extends Data.TaggedError( + "IntegrationOutboxPollingExhaustedError" )<{ readonly maximumPolls: number; }> {} @@ -106,7 +106,7 @@ function parseCommand(arguments_: readonly string[]): ChildCommand { workerId: identifier, }); if (command.leaseUntil <= command.now) { - throw new QualificationChildArgumentError({ + throw new IntegrationChildArgumentError({ message: "Claim lease must expire after its logical claim time", }); } @@ -124,8 +124,8 @@ function parseCommand(arguments_: readonly string[]): ChildCommand { }); } default: { - throw new QualificationChildArgumentError({ - message: "Unrecognized SQLite outbox qualification child command", + throw new IntegrationChildArgumentError({ + message: "Unrecognized SQLite outbox integration child command", }); } } @@ -134,15 +134,15 @@ function parseCommand(arguments_: readonly string[]): ChildCommand { function writeStatus( statusPath: string, status: SqliteOutboxChildStatus -): Effect.Effect { +): Effect.Effect { return Effect.tryPromise({ - catch: (cause) => new QualificationChildStatusWriteError({ cause }), + catch: (cause) => new IntegrationChildStatusWriteError({ cause }), try: () => Bun.write(statusPath, `${JSON.stringify(status)}\n`), }).pipe(Effect.asVoid); } function runDrainCommand( - database: ReturnType, + database: ReturnType, command: Extract ) { const maximumPolls = sqliteOutboxMaximumDrainNonemptyPolls + 1; @@ -150,8 +150,8 @@ function runDrainCommand( let claimedCount = 0; let deliveredCount = 0; for (let poll = 0; poll < maximumPolls; poll += 1) { - const claimedEventIds = yield* retryQualificationSqliteOperation(() => - claimQualificationOutboxBatch( + const claimedEventIds = yield* retryIntegrationSqliteOperation(() => + claimIntegrationOutboxBatch( database, command.workerId, command.now, @@ -163,14 +163,14 @@ function runDrainCommand( if (claimedEventIds.length === 0) { return { claimedCount, deliveredCount }; } - const deliveredEventIds = yield* retryQualificationSqliteOperation(() => - deliverQualificationOutboxClaims(database, command.workerId, command.now) + const deliveredEventIds = yield* retryIntegrationSqliteOperation(() => + deliverIntegrationOutboxClaims(database, command.workerId, command.now) ); deliveredCount += deliveredEventIds.length; yield* Effect.sleep("1 millis"); } return yield* Effect.fail( - new QualificationOutboxPollingExhaustedError({ maximumPolls }) + new IntegrationOutboxPollingExhaustedError({ maximumPolls }) ); }); } @@ -179,14 +179,14 @@ function childProgram(command: ChildCommand) { return Effect.scoped( Effect.gen(function* () { const database = yield* Effect.acquireRelease( - Effect.sync(() => openQualificationOutboxDatabase(command.databasePath)), + Effect.sync(() => openIntegrationOutboxDatabase(command.databasePath)), (acquiredDatabase) => Effect.sync(() => acquiredDatabase.close(true)) ); switch (command.kind) { case "produce": { - const batch = yield* retryQualificationSqliteOperation(() => - appendQualificationOutboxBatch( + const batch = yield* retryIntegrationSqliteOperation(() => + appendIntegrationOutboxBatch( database, command.producerId, command.count, @@ -202,8 +202,8 @@ function childProgram(command: ChildCommand) { return; } case "claim-and-hold": { - const eventIds = yield* retryQualificationSqliteOperation(() => - claimQualificationOutboxBatch( + const eventIds = yield* retryIntegrationSqliteOperation(() => + claimIntegrationOutboxBatch( database, command.workerId, command.now, @@ -235,6 +235,6 @@ try { const command = parseCommand(process.argv.slice(2)); await Effect.runPromise(childProgram(command)); } catch { - process.stderr.write("SQLite outbox qualification child failed\n"); + process.stderr.write("SQLite outbox integration child failed\n"); process.exitCode = 1; } diff --git a/qualification/outbox/sqliteOutboxProtocol.ts b/greenfield/src/test/integration/outbox/sqliteOutboxProtocol.ts similarity index 95% rename from qualification/outbox/sqliteOutboxProtocol.ts rename to greenfield/src/test/integration/outbox/sqliteOutboxProtocol.ts index 8cc1409f5..96c10cdc0 100644 --- a/qualification/outbox/sqliteOutboxProtocol.ts +++ b/greenfield/src/test/integration/outbox/sqliteOutboxProtocol.ts @@ -40,7 +40,7 @@ export const sqliteOutboxChildStatusSchema = v.variant("kind", [ export type SqliteOutboxChildStatus = v.InferOutput; /** - * Parses one bounded status file emitted by a qualification child. + * Parses one bounded status file emitted by an integration child. * @param value Parsed JSON value. * @returns Strictly validated child status. */ diff --git a/qualification/outbox/sqliteOutboxQualification.test.ts b/greenfield/src/test/integration/outbox/sqliteOutboxScenario.test.ts similarity index 77% rename from qualification/outbox/sqliteOutboxQualification.test.ts rename to greenfield/src/test/integration/outbox/sqliteOutboxScenario.test.ts index 304fae072..f9ebe0238 100644 --- a/qualification/outbox/sqliteOutboxQualification.test.ts +++ b/greenfield/src/test/integration/outbox/sqliteOutboxScenario.test.ts @@ -6,17 +6,17 @@ import path from "node:path"; import { Effect } from "effect"; import { - sqliteOutboxQualification, + sqliteOutboxScenario, summarizeOutboxLatencies, -} from "./sqliteOutboxQualification.ts"; +} from "./sqliteOutboxScenario.ts"; import { - appendQualificationOutboxBatch, - classifyQualificationSqliteError, - countQualificationRows, - initializeQualificationOutboxDatabase, - openQualificationOutboxDatabase, - QualificationSqliteContentionError, - readQualificationJournalMode, + appendIntegrationOutboxBatch, + classifyIntegrationSqliteError, + countIntegrationRows, + initializeIntegrationOutboxDatabase, + openIntegrationOutboxDatabase, + IntegrationSqliteContentionError, + readIntegrationJournalMode, } from "./sqliteOutboxStore.ts"; function temporaryDirectoryResource() { @@ -31,12 +31,12 @@ function temporaryDirectoryResource() { function databaseResource(databasePath: string, readonly = false) { return Effect.acquireRelease( - Effect.sync(() => openQualificationOutboxDatabase(databasePath, { readonly })), + Effect.sync(() => openIntegrationOutboxDatabase(databasePath, { readonly })), (database) => Effect.sync(() => database.close(true)) ); } -describe("file-backed Bun SQLite qualification", () => { +describe("file-backed Bun SQLite integration", () => { test("qualifies WAL reader/writer snapshots and writer contention", async () => { const result = await Effect.runPromise( Effect.scoped( @@ -44,44 +44,39 @@ describe("file-backed Bun SQLite qualification", () => { const directoryPath = yield* temporaryDirectoryResource(); const databasePath = path.join(directoryPath, "wal.sqlite"); const writer = yield* databaseResource(databasePath); - yield* Effect.sync(() => - initializeQualificationOutboxDatabase(writer) - ); + yield* Effect.sync(() => initializeIntegrationOutboxDatabase(writer)); const reader = yield* databaseResource(databasePath, true); const competingWriter = yield* databaseResource(databasePath); yield* Effect.sync(() => { - appendQualificationOutboxBatch(writer, "reader-before", 1, 1000); + appendIntegrationOutboxBatch(writer, "reader-before", 1, 1000); reader.run("BEGIN"); }); const snapshotBeforeWrite = yield* Effect.sync(() => - countQualificationRows(reader, "qualification_outbox_events") + countIntegrationRows(reader, "integration_outbox_events") ); yield* Effect.sync(() => - appendQualificationOutboxBatch(writer, "reader-after", 1, 2000) + appendIntegrationOutboxBatch(writer, "reader-after", 1, 2000) ); const stableReaderSnapshot = yield* Effect.sync(() => - countQualificationRows(reader, "qualification_outbox_events") + countIntegrationRows(reader, "integration_outbox_events") ); const refreshedReaderSnapshot = yield* Effect.sync(() => { reader.run("COMMIT"); - return countQualificationRows( - reader, - "qualification_outbox_events" - ); + return countIntegrationRows(reader, "integration_outbox_events"); }); const contention = yield* Effect.sync(() => { writer.run("BEGIN IMMEDIATE"); let competingWriterAcquired = false; let classifiedError: ReturnType< - typeof classifyQualificationSqliteError + typeof classifyIntegrationSqliteError >; try { competingWriter.run("BEGIN IMMEDIATE"); competingWriterAcquired = true; } catch (error) { - classifiedError = classifyQualificationSqliteError(error); + classifiedError = classifyIntegrationSqliteError(error); } finally { if (competingWriterAcquired) { competingWriter.run("ROLLBACK"); @@ -98,7 +93,7 @@ describe("file-backed Bun SQLite qualification", () => { return { contention, - journalMode: readQualificationJournalMode(writer), + journalMode: readIntegrationJournalMode(writer), refreshedReaderSnapshot, snapshotBeforeWrite, stableReaderSnapshot, @@ -107,7 +102,7 @@ describe("file-backed Bun SQLite qualification", () => { ) ); - expect(result.contention).toBeInstanceOf(QualificationSqliteContentionError); + expect(result.contention).toBeInstanceOf(IntegrationSqliteContentionError); expect(result.journalMode).toBe("wal"); expect(result.refreshedReaderSnapshot).toBe(2); expect(result.snapshotBeforeWrite).toBe(1); @@ -116,13 +111,13 @@ describe("file-backed Bun SQLite qualification", () => { const lockedError = Object.assign(new Error("shared cache locked"), { code: "SQLITE_LOCKED_SHAREDCACHE", }); - const classifiedLockedError = classifyQualificationSqliteError(lockedError); - expect(classifiedLockedError).toBeInstanceOf(QualificationSqliteContentionError); + const classifiedLockedError = classifyIntegrationSqliteError(lockedError); + expect(classifiedLockedError).toBeInstanceOf(IntegrationSqliteContentionError); }); test("qualifies nested savepoints and deterministic native disposal", async () => { let releasedDatabase: - | ReturnType + | ReturnType | undefined; const result = await Effect.runPromise( Effect.scoped( @@ -132,21 +127,21 @@ describe("file-backed Bun SQLite qualification", () => { const database = yield* databaseResource(databasePath); releasedDatabase = database; yield* Effect.sync(() => { - initializeQualificationOutboxDatabase(database); + initializeIntegrationOutboxDatabase(database); database.run( - "CREATE TABLE qualification_savepoints (id INTEGER PRIMARY KEY NOT NULL) STRICT" + "CREATE TABLE integration_savepoints (id INTEGER PRIMARY KEY NOT NULL) STRICT" ); }); const nested = database.transaction(() => { - database.run("INSERT INTO qualification_savepoints VALUES (2)"); + database.run("INSERT INTO integration_savepoints VALUES (2)"); throw new Error("rollback nested savepoint"); }); yield* Effect.sync(() => database .transaction(() => { database.run( - "INSERT INTO qualification_savepoints VALUES (1)" + "INSERT INTO integration_savepoints VALUES (1)" ); try { nested(); @@ -154,14 +149,14 @@ describe("file-backed Bun SQLite qualification", () => { if (!(error instanceof Error)) throw error; } database.run( - "INSERT INTO qualification_savepoints VALUES (3)" + "INSERT INTO integration_savepoints VALUES (3)" ); }) .immediate() ); const statement = database.prepare<{ id: number }, []>( - "SELECT id FROM qualification_savepoints ORDER BY id" + "SELECT id FROM integration_savepoints ORDER BY id" ); const rows = statement.all(); statement.finalize(); @@ -187,9 +182,9 @@ describe("file-backed Bun SQLite qualification", () => { }); }); -describe("multi-process SQLite outbox qualification", () => { +describe("multi-process SQLite outbox integration", () => { test("recovers terminated claims with no event gaps or duplicate deliveries", async () => { - const report = await Effect.runPromise(sqliteOutboxQualification); + const report = await Effect.runPromise(sqliteOutboxScenario); const expectedEventIds = Array.from({ length: 42 }, (_, index) => index + 1); const expectedProducerSequences = [ ...Array.from({ length: 19 }, (_, index) => `web-a:${index + 1}`), diff --git a/qualification/outbox/sqliteOutboxQualification.ts b/greenfield/src/test/integration/outbox/sqliteOutboxScenario.ts similarity index 77% rename from qualification/outbox/sqliteOutboxQualification.ts rename to greenfield/src/test/integration/outbox/sqliteOutboxScenario.ts index dc312cd5f..d141d88df 100644 --- a/qualification/outbox/sqliteOutboxQualification.ts +++ b/greenfield/src/test/integration/outbox/sqliteOutboxScenario.ts @@ -10,14 +10,14 @@ import { type SqliteOutboxChildStatus, } from "./sqliteOutboxProtocol.ts"; import { - createQualificationOutboxBackup, - initializeQualificationOutboxDatabase, - openQualificationOutboxDatabase, - readQualificationDeliveryLatencies, - readQualificationIntegrityCheck, - readQualificationJournalMode, - readQualificationOutboxSnapshot, - type QualificationOutboxSnapshot, + createIntegrationOutboxBackup, + initializeIntegrationOutboxDatabase, + openIntegrationOutboxDatabase, + readIntegrationDeliveryLatencies, + readIntegrationIntegrityCheck, + readIntegrationJournalMode, + readIntegrationOutboxSnapshot, + type IntegrationOutboxSnapshot, } from "./sqliteOutboxStore.ts"; const childModulePath = path.join( @@ -30,23 +30,23 @@ const statusPollingSchedule = Schedule.spaced("5 millis").pipe( Schedule.upTo({ times: 1000 }) ); -type QualificationChildProcess = Bun.Subprocess<"ignore", "ignore", "ignore">; +type IntegrationChildProcess = Bun.Subprocess<"ignore", "ignore", "ignore">; -export class QualificationChildProcessError extends Data.TaggedError( - "QualificationChildProcessError" +export class IntegrationChildProcessError extends Data.TaggedError( + "IntegrationChildProcessError" )<{ readonly exitCode?: number; readonly operation: string; }> {} -export class QualificationDeadlineError extends Data.TaggedError( - "QualificationDeadlineError" +export class IntegrationDeadlineError extends Data.TaggedError( + "IntegrationDeadlineError" )<{ readonly operation: string; }> {} -class QualificationStatusPendingError extends Data.TaggedError( - "QualificationStatusPendingError" +class IntegrationStatusPendingError extends Data.TaggedError( + "IntegrationStatusPendingError" )<{ readonly cause?: unknown; }> {} @@ -58,16 +58,16 @@ export interface OutboxLatencySummary { readonly sampleCount: number; } -export interface SqliteOutboxQualificationReport { +export interface SqliteOutboxScenarioReport { readonly crashedClaimEventIds: readonly number[]; readonly crashedWorkerSignal: NodeJS.Signals; - readonly finalSnapshot: QualificationOutboxSnapshot; + readonly finalSnapshot: IntegrationOutboxSnapshot; readonly integrityCheck: string; readonly journalMode: string; readonly latency: OutboxLatencySummary; readonly producerCounts: readonly number[]; readonly restoredIntegrityCheck: string; - readonly restoredSnapshot: QualificationOutboxSnapshot; + readonly restoredSnapshot: IntegrationOutboxSnapshot; readonly workerClaimCounts: readonly number[]; readonly workerDeliveryCounts: readonly number[]; } @@ -80,7 +80,7 @@ function percentile(sortedValues: readonly number[], fraction: number): number { /** * Summarizes logical delivery latency without imposing wall-clock CI thresholds. - * Performance qualification can publish the same shape from capped CLI runs. + * Performance integration can publish the same shape from capped CLI runs. * @param values Logical delivery-latency samples. * @returns Deterministic percentile summary with no wall-clock pass threshold. */ @@ -96,16 +96,16 @@ export function summarizeOutboxLatencies( }); } -function childDeadlineFailure(operation: string): QualificationDeadlineError { - return new QualificationDeadlineError({ operation }); +function childDeadlineFailure(operation: string): IntegrationDeadlineError { + return new IntegrationDeadlineError({ operation }); } function awaitChildExit( - child: QualificationChildProcess, + child: IntegrationChildProcess, operation: string -): Effect.Effect { +): Effect.Effect { return Effect.tryPromise({ - catch: () => new QualificationChildProcessError({ operation }), + catch: () => new IntegrationChildProcessError({ operation }), try: () => child.exited, }).pipe( Effect.timeoutOrElse({ @@ -116,7 +116,7 @@ function awaitChildExit( } function stopChild( - child: QualificationChildProcess, + child: IntegrationChildProcess, operation: string ): Effect.Effect { if (child.exitCode !== null || child.signalCode !== null) return Effect.void; @@ -139,12 +139,12 @@ function stopChild( function spawnChild( operation: string, arguments_: readonly string[] -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const signal = yield* Effect.abortSignal; return yield* Effect.acquireRelease( Effect.try({ - catch: () => new QualificationChildProcessError({ operation }), + catch: () => new IntegrationChildProcessError({ operation }), try: () => Bun.spawn([process.execPath, childModulePath, ...arguments_], { killSignal: "SIGTERM", @@ -162,14 +162,14 @@ function spawnChild( function readStatus( statusPath: string, operation: string -): Effect.Effect { +): Effect.Effect { const attempt = Effect.tryPromise({ - catch: (cause) => new QualificationStatusPendingError({ cause }), + catch: (cause) => new IntegrationStatusPendingError({ cause }), try: async () => { const statusFile = Bun.file(statusPath); if (!(await statusFile.exists())) throw new Error("status pending"); if (statusFile.size > childStatusMaximumBytes) { - throw new Error("status exceeds qualification bound"); + throw new Error("status exceeds integration bound"); } const statusText = await statusFile.text(); const statusValue: unknown = JSON.parse(statusText); @@ -178,7 +178,7 @@ function readStatus( }); return attempt.pipe( Effect.retry({ schedule: statusPollingSchedule }), - Effect.catchTag("QualificationStatusPendingError", () => + Effect.catchTag("IntegrationStatusPendingError", () => Effect.fail(childDeadlineFailure(operation)) ), Effect.timeoutOrElse({ @@ -194,7 +194,7 @@ function runOneShotChild( arguments_: readonly string[] ): Effect.Effect< SqliteOutboxChildStatus, - QualificationChildProcessError | QualificationDeadlineError + IntegrationChildProcessError | IntegrationDeadlineError > { return Effect.scoped( Effect.gen(function* () { @@ -202,7 +202,7 @@ function runOneShotChild( const exitCode = yield* awaitChildExit(child, operation); if (exitCode !== 0) { return yield* Effect.fail( - new QualificationChildProcessError({ exitCode, operation }) + new IntegrationChildProcessError({ exitCode, operation }) ); } return yield* readStatus(statusPath, operation); @@ -218,7 +218,7 @@ function claimAndTerminateChild( readonly signal: NodeJS.Signals; readonly status: SqliteOutboxChildStatus; }, - QualificationChildProcessError | QualificationDeadlineError + IntegrationChildProcessError | IntegrationDeadlineError > { return Effect.scoped( Effect.gen(function* () { @@ -238,7 +238,7 @@ function claimAndTerminateChild( const signal = child.signalCode; if (signal === null) { return yield* Effect.fail( - new QualificationChildProcessError({ + new IntegrationChildProcessError({ exitCode: child.exitCode ?? undefined, operation: `${operation}:missing-signal`, }) @@ -251,7 +251,7 @@ function claimAndTerminateChild( function databaseResource(databasePath: string, readonly = false) { return Effect.acquireRelease( - Effect.sync(() => openQualificationOutboxDatabase(databasePath, { readonly })), + Effect.sync(() => openIntegrationOutboxDatabase(databasePath, { readonly })), (database) => Effect.sync(() => database.close(true)) ); } @@ -260,7 +260,7 @@ function temporaryWorkspace() { return Effect.acquireRelease( Effect.tryPromise({ catch: () => - new QualificationChildProcessError({ operation: "temp-directory" }), + new IntegrationChildProcessError({ operation: "temp-directory" }), try: () => mkdtemp(path.join(tmpdir(), "mira-dashboard-outbox-")), }), (workspacePath) => @@ -271,16 +271,16 @@ function temporaryWorkspace() { } /** - * Runs the file-backed multi-process outbox qualification in one Effect scope. + * Runs the file-backed multi-process outbox scenario in one Effect scope. * Logical lease timestamps keep recovery assertions deterministic in CI. */ -export const sqliteOutboxQualification = Effect.scoped( +export const sqliteOutboxScenario = Effect.scoped( Effect.gen(function* () { const workspacePath = yield* temporaryWorkspace(); - const databasePath = path.join(workspacePath, "qualification.sqlite"); - const backupPath = path.join(workspacePath, "qualification.backup.sqlite"); + const databasePath = path.join(workspacePath, "scenario.sqlite"); + const backupPath = path.join(workspacePath, "integration.backup.sqlite"); const database = yield* databaseResource(databasePath); - yield* Effect.sync(() => initializeQualificationOutboxDatabase(database)); + yield* Effect.sync(() => initializeIntegrationOutboxDatabase(database)); const producerSpecifications = [ { count: 19, createdAt: 1000, id: "web-a" }, @@ -318,7 +318,7 @@ export const sqliteOutboxQualification = Effect.scoped( return yield* Effect.die("Claim child returned an unexpected status kind"); } const afterTermination = yield* Effect.sync(() => - readQualificationOutboxSnapshot(database) + readIntegrationOutboxSnapshot(database) ); if (afterTermination.claimedCount !== terminated.status.eventIds.length) { return yield* Effect.die( @@ -350,25 +350,25 @@ export const sqliteOutboxQualification = Effect.scoped( }); const finalSnapshot = yield* Effect.sync(() => - readQualificationOutboxSnapshot(database) + readIntegrationOutboxSnapshot(database) ); const journalMode = yield* Effect.sync(() => - readQualificationJournalMode(database) + readIntegrationJournalMode(database) ); const integrityCheck = yield* Effect.sync(() => - readQualificationIntegrityCheck(database) + readIntegrationIntegrityCheck(database) ); const latency = yield* Effect.sync(() => - summarizeOutboxLatencies(readQualificationDeliveryLatencies(database)) + summarizeOutboxLatencies(readIntegrationDeliveryLatencies(database)) ); - yield* Effect.sync(() => createQualificationOutboxBackup(database, backupPath)); + yield* Effect.sync(() => createIntegrationOutboxBackup(database, backupPath)); const restoredDatabase = yield* databaseResource(backupPath, true); const restoredSnapshot = yield* Effect.sync(() => - readQualificationOutboxSnapshot(restoredDatabase) + readIntegrationOutboxSnapshot(restoredDatabase) ); const restoredIntegrityCheck = yield* Effect.sync(() => - readQualificationIntegrityCheck(restoredDatabase) + readIntegrationIntegrityCheck(restoredDatabase) ); return Object.freeze({ @@ -387,6 +387,6 @@ export const sqliteOutboxQualification = Effect.scoped( workerDeliveryCounts: Object.freeze( drained.map((status) => status.deliveredCount) ), - } satisfies SqliteOutboxQualificationReport); + } satisfies SqliteOutboxScenarioReport); }) ); diff --git a/qualification/outbox/sqliteOutboxStore.ts b/greenfield/src/test/integration/outbox/sqliteOutboxStore.ts similarity index 75% rename from qualification/outbox/sqliteOutboxStore.ts rename to greenfield/src/test/integration/outbox/sqliteOutboxStore.ts index 516f86197..ec397061c 100644 --- a/qualification/outbox/sqliteOutboxStore.ts +++ b/greenfield/src/test/integration/outbox/sqliteOutboxStore.ts @@ -4,34 +4,34 @@ import { Data, Duration, Effect, Predicate, Schedule } from "effect"; import * as v from "valibot"; const outboxSchemaStatements = [ - `CREATE TABLE qualification_records ( + `CREATE TABLE integration_records ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, payload TEXT NOT NULL, producer_id TEXT NOT NULL, producer_sequence INTEGER NOT NULL, UNIQUE (producer_id, producer_sequence) ) STRICT`, - `CREATE TABLE qualification_outbox_events ( + `CREATE TABLE integration_outbox_events ( claim_owner TEXT, created_at INTEGER NOT NULL, delivered_at INTEGER, id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, lease_until INTEGER, record_id INTEGER NOT NULL UNIQUE - REFERENCES qualification_records(id) ON DELETE CASCADE, + REFERENCES integration_records(id) ON DELETE CASCADE, state TEXT NOT NULL DEFAULT 'pending', - CONSTRAINT qualification_outbox_state_check CHECK ( + CONSTRAINT integration_outbox_state_check CHECK ( (state = 'pending' AND claim_owner IS NULL AND lease_until IS NULL AND delivered_at IS NULL) OR (state = 'claimed' AND claim_owner IS NOT NULL AND lease_until IS NOT NULL AND delivered_at IS NULL) OR (state = 'delivered' AND claim_owner IS NULL AND lease_until IS NULL AND delivered_at IS NOT NULL) ) ) STRICT`, - `CREATE INDEX qualification_outbox_claim_idx - ON qualification_outbox_events (state, lease_until, id)`, - `CREATE TABLE qualification_outbox_deliveries ( + `CREATE INDEX integration_outbox_claim_idx + ON integration_outbox_events (state, lease_until, id)`, + `CREATE TABLE integration_outbox_deliveries ( delivered_at INTEGER NOT NULL, event_id INTEGER NOT NULL UNIQUE - REFERENCES qualification_outbox_events(id) ON DELETE RESTRICT, + REFERENCES integration_outbox_events(id) ON DELETE RESTRICT, id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, worker_id TEXT NOT NULL ) STRICT`, @@ -41,40 +41,39 @@ const sqliteErrorSchema = v.object({ code: v.pipe(v.string(), v.startsWith("SQLITE_")), }); -const qualificationCountStatements = Object.freeze({ - qualification_outbox_deliveries: - "SELECT count(*) AS count FROM qualification_outbox_deliveries", - qualification_outbox_events: - "SELECT count(*) AS count FROM qualification_outbox_events", - qualification_records: "SELECT count(*) AS count FROM qualification_records", +const integrationCountStatements = Object.freeze({ + integration_outbox_deliveries: + "SELECT count(*) AS count FROM integration_outbox_deliveries", + integration_outbox_events: "SELECT count(*) AS count FROM integration_outbox_events", + integration_records: "SELECT count(*) AS count FROM integration_records", }); -export type QualificationTableName = keyof typeof qualificationCountStatements; +export type IntegrationTableName = keyof typeof integrationCountStatements; -export class QualificationSqliteContentionError extends Data.TaggedError( - "QualificationSqliteContentionError" +export class IntegrationSqliteContentionError extends Data.TaggedError( + "IntegrationSqliteContentionError" )<{ readonly cause: unknown; readonly code: string; }> {} -export class QualificationSqliteUnavailableError extends Data.TaggedError( - "QualificationSqliteUnavailableError" +export class IntegrationSqliteUnavailableError extends Data.TaggedError( + "IntegrationSqliteUnavailableError" )<{ readonly cause: unknown; readonly code: string; }> {} -export type QualificationSqliteOperationError = - | QualificationSqliteContentionError - | QualificationSqliteUnavailableError; +export type IntegrationSqliteOperationError = + | IntegrationSqliteContentionError + | IntegrationSqliteUnavailableError; export interface AppendedOutboxBatch { readonly eventIds: readonly number[]; readonly producerId: string; } -export interface QualificationOutboxSnapshot { +export interface IntegrationOutboxSnapshot { readonly claimedCount: number; readonly deliveredCount: number; readonly deliveredEventIds: readonly number[]; @@ -125,21 +124,21 @@ function isContentionCode(code: string): boolean { } /** - * Converts Bun SQLite failures into stable qualification failure tags. + * Converts Bun SQLite failures into stable integration failure tags. * @param error Unknown thrown value. * @returns A classified SQLite failure, or undefined for non-SQLite defects. */ -export function classifyQualificationSqliteError( +export function classifyIntegrationSqliteError( error: unknown -): QualificationSqliteOperationError | undefined { +): IntegrationSqliteOperationError | undefined { const code = sqliteErrorCode(error); if (code === undefined) return undefined; return isContentionCode(code) - ? new QualificationSqliteContentionError({ cause: error, code }) - : new QualificationSqliteUnavailableError({ cause: error, code }); + ? new IntegrationSqliteContentionError({ cause: error, code }) + : new IntegrationSqliteUnavailableError({ cause: error, code }); } -const isContentionError = Predicate.isTagged("QualificationSqliteContentionError"); +const isContentionError = Predicate.isTagged("IntegrationSqliteContentionError"); const contentionRetrySchedule = Schedule.exponential(Duration.millis(1)).pipe( Schedule.modifyDelay(({ duration }) => { @@ -156,14 +155,14 @@ const contentionRetrySchedule = Schedule.exponential(Duration.millis(1)).pipe( * @param operation Synchronous SQLite operation. * @returns An Effect with bounded contention retries and tagged expected failures. */ -export function retryQualificationSqliteOperation( +export function retryIntegrationSqliteOperation( operation: () => A -): Effect.Effect { +): Effect.Effect { const attempt = Effect.suspend(() => { try { return Effect.succeed(operation()); } catch (error) { - const failure = classifyQualificationSqliteError(error); + const failure = classifyIntegrationSqliteError(error); return failure === undefined ? Effect.die(error) : Effect.fail(failure); } }); @@ -171,12 +170,12 @@ export function retryQualificationSqliteOperation( } /** - * Opens one strict file-backed qualification connection. + * Opens one strict file-backed integration connection. * @param databasePath Absolute temporary database path. * @param options Connection access mode. * @returns The opened Bun SQLite connection. */ -export function openQualificationOutboxDatabase( +export function openIntegrationOutboxDatabase( databasePath: string, options: { readonly?: boolean } = {} ): Database { @@ -193,14 +192,14 @@ export function openQualificationOutboxDatabase( /** * Initializes the deterministic WAL-backed outbox fixture. - * @param database Writable qualification database. + * @param database Writable integration database. */ -export function initializeQualificationOutboxDatabase(database: Database): void { +export function initializeIntegrationOutboxDatabase(database: Database): void { const journalMode = database .query("PRAGMA journal_mode = WAL") .get()?.journal_mode; if (journalMode?.toLowerCase() !== "wal") { - throw new Error("Qualification database did not enter WAL mode"); + throw new Error("Integration database did not enter WAL mode"); } database.run("PRAGMA synchronous = NORMAL"); for (const statement of outboxSchemaStatements) database.run(statement); @@ -208,10 +207,10 @@ export function initializeQualificationOutboxDatabase(database: Database): void /** * Returns the connection-visible journal mode. - * @param database Open qualification database. + * @param database Open integration database. * @returns Lowercase SQLite journal mode. */ -export function readQualificationJournalMode(database: Database): string { +export function readIntegrationJournalMode(database: Database): string { const row = database.query("PRAGMA journal_mode").get(); if (row === null) throw new Error("SQLite returned no journal mode"); return row.journal_mode.toLowerCase(); @@ -219,24 +218,24 @@ export function readQualificationJournalMode(database: Database): string { /** * Appends domain rows and their outbox events in one synchronous immediate transaction. - * @param database Writable qualification database. + * @param database Writable integration database. * @param producerId Stable child producer identifier. * @param count Number of records and events to append. * @param createdAt Deterministic logical creation timestamp. * @returns Inserted event identifiers. */ -export function appendQualificationOutboxBatch( +export function appendIntegrationOutboxBatch( database: Database, producerId: string, count: number, createdAt: number ): AppendedOutboxBatch { const insertRecord = database.prepare( - `INSERT INTO qualification_records (producer_id, producer_sequence, payload) + `INSERT INTO integration_records (producer_id, producer_sequence, payload) VALUES (?, ?, ?)` ); const insertEvent = database.prepare( - `INSERT INTO qualification_outbox_events (record_id, created_at) + `INSERT INTO integration_outbox_events (record_id, created_at) VALUES (?, ?)` ); try { @@ -267,14 +266,14 @@ export function appendQualificationOutboxBatch( /** * Claims one ordered batch, including claims whose logical lease expired. - * @param database Writable qualification database. + * @param database Writable integration database. * @param workerId Stable worker identifier. * @param now Deterministic logical claim timestamp. * @param leaseUntil Deterministic logical lease expiry. * @param limit Maximum events to claim. * @returns Ordered claimed event identifiers. */ -export function claimQualificationOutboxBatch( +export function claimIntegrationOutboxBatch( database: Database, workerId: string, now: number, @@ -283,14 +282,14 @@ export function claimQualificationOutboxBatch( ): readonly number[] { const select = database.prepare( `SELECT id - FROM qualification_outbox_events + FROM integration_outbox_events WHERE state = 'pending' OR (state = 'claimed' AND lease_until <= ?) ORDER BY id LIMIT ?` ); const update = database.prepare( - `UPDATE qualification_outbox_events + `UPDATE integration_outbox_events SET state = 'claimed', claim_owner = ?, lease_until = ? WHERE id = ? AND (state = 'pending' OR (state = 'claimed' AND lease_until <= ?))` @@ -319,28 +318,28 @@ export function claimQualificationOutboxBatch( /** * Persists exactly-once delivery evidence and terminal event state atomically. - * @param database Writable qualification database. + * @param database Writable integration database. * @param workerId Claim owner and delivery worker. * @param deliveredAt Deterministic logical delivery timestamp. * @returns Ordered delivered event identifiers. */ -export function deliverQualificationOutboxClaims( +export function deliverIntegrationOutboxClaims( database: Database, workerId: string, deliveredAt: number ): readonly number[] { const select = database.prepare( `SELECT id - FROM qualification_outbox_events + FROM integration_outbox_events WHERE state = 'claimed' AND claim_owner = ? ORDER BY id` ); const insertDelivery = database.prepare( - `INSERT INTO qualification_outbox_deliveries (event_id, worker_id, delivered_at) + `INSERT INTO integration_outbox_deliveries (event_id, worker_id, delivered_at) VALUES (?, ?, ?)` ); const markDelivered = database.prepare( - `UPDATE qualification_outbox_events + `UPDATE integration_outbox_events SET state = 'delivered', claim_owner = NULL, lease_until = NULL, delivered_at = ? WHERE id = ? AND state = 'claimed' AND claim_owner = ?` ); @@ -370,19 +369,19 @@ export function deliverQualificationOutboxClaims( /** * Captures all deterministic event and delivery invariants for assertions/evidence. - * @param database Open qualification database. + * @param database Open integration database. * @returns Immutable state snapshot. */ -export function readQualificationOutboxSnapshot( +export function readIntegrationOutboxSnapshot( database: Database -): QualificationOutboxSnapshot { +): IntegrationOutboxSnapshot { const eventIds = database - .query("SELECT id FROM qualification_outbox_events ORDER BY id") + .query("SELECT id FROM integration_outbox_events ORDER BY id") .all() .map((row) => row.id); const deliveredEventIds = database .query( - "SELECT event_id AS id FROM qualification_outbox_deliveries ORDER BY event_id" + "SELECT event_id AS id FROM integration_outbox_deliveries ORDER BY event_id" ) .all() .map((row) => row.id); @@ -390,7 +389,7 @@ export function readQualificationOutboxSnapshot( database .query( `SELECT state, count(*) AS count - FROM qualification_outbox_events + FROM integration_outbox_events GROUP BY state` ) .all() @@ -399,7 +398,7 @@ export function readQualificationOutboxSnapshot( const producerSequences = database .query( `SELECT producer_id AS producerId, producer_sequence AS producerSequence - FROM qualification_records + FROM integration_records ORDER BY producer_id, producer_sequence` ) .all() @@ -418,17 +417,15 @@ export function readQualificationOutboxSnapshot( /** * Reads deterministic logical delivery latencies for later percentile evidence. - * @param database Open qualification database. + * @param database Open integration database. * @returns Logical event delivery latencies in event order. */ -export function readQualificationDeliveryLatencies( - database: Database -): readonly number[] { +export function readIntegrationDeliveryLatencies(database: Database): readonly number[] { return Object.freeze( database .query( `SELECT events.delivered_at - events.created_at AS latencyMs - FROM qualification_outbox_events AS events + FROM integration_outbox_events AS events WHERE events.state = 'delivered' ORDER BY events.id` ) @@ -439,10 +436,10 @@ export function readQualificationDeliveryLatencies( /** * Returns SQLite's full integrity result. - * @param database Open qualification database. + * @param database Open integration database. * @returns SQLite integrity-check response. */ -export function readQualificationIntegrityCheck(database: Database): string { +export function readIntegrationIntegrityCheck(database: Database): string { const row = database .query<{ integrityCheck: string }, []>( "SELECT integrity_check AS integrityCheck FROM pragma_integrity_check" @@ -454,10 +451,10 @@ export function readQualificationIntegrityCheck(database: Database): string { /** * Creates a consistent standalone backup after explicitly checkpointing WAL. - * @param database Writable qualification database. + * @param database Writable integration database. * @param backupPath New standalone backup path. */ -export function createQualificationOutboxBackup( +export function createIntegrationOutboxBackup( database: Database, backupPath: string ): void { @@ -472,17 +469,15 @@ export function createQualificationOutboxBackup( /** * Counts one allowlisted table without exposing a cached prepared statement. - * @param database Open qualification database. - * @param tableName Allowlisted qualification table. + * @param database Open integration database. + * @param tableName Allowlisted integration table. * @returns Table row count. */ -export function countQualificationRows( +export function countIntegrationRows( database: Database, - tableName: QualificationTableName + tableName: IntegrationTableName ): number { - const row = database - .query(qualificationCountStatements[tableName]) - .get(); + const row = database.query(integrationCountStatements[tableName]).get(); if (row === null) throw new Error("SQLite returned no count"); return row.count; } diff --git a/qualification/budgets/resourceBudgetCommand.ts b/greenfield/src/test/integration/resourceBudgets/resourceBudgetCommand.ts similarity index 87% rename from qualification/budgets/resourceBudgetCommand.ts rename to greenfield/src/test/integration/resourceBudgets/resourceBudgetCommand.ts index bb811033c..bbb98f486 100644 --- a/qualification/budgets/resourceBudgetCommand.ts +++ b/greenfield/src/test/integration/resourceBudgets/resourceBudgetCommand.ts @@ -53,7 +53,7 @@ function sanitizedEnvironment( temporaryDirectory: string ): Readonly> { const home = source.HOME; - if (!home) throw new Error("HOME is required for resource-budget qualification"); + if (!home) throw new Error("HOME is required for resource-budget evidence"); return Object.freeze({ CI: "1", FORCE_COLOR: "0", @@ -90,27 +90,24 @@ export function buildResourceBudgetWorkloadCommand( ): ResourceBudgetWorkloadCommand { assertAbsolutePath("Repository root", repositoryRoot); assertAbsolutePath("Bun executable", bunExecutable); - const qualification = (...segments: string[]) => - path.join(repositoryRoot, "qualification", ...segments); + const integrationTest = (...segments: string[]) => + path.join(repositoryRoot, "src", "test", "integration", ...segments); const testFiles = [ - qualification("runtimeCandidate.test.ts"), - qualification("resources", "cgroupV2.test.ts"), - qualification("build", "frontendBuildQualification.test.ts"), - qualification("browser", "queryCollectionAdapter.test.ts"), - qualification("openclaw", "sourceAudit.test.ts"), - qualification("chat", "chatBatching.test.ts"), + integrationTest("runtime", "runtimeCandidate.test.ts"), + integrationTest("resources", "cgroupV2.test.ts"), + integrationTest("build", "frontendBuildScenario.test.ts"), + integrationTest("openclaw", "sourceAudit.test.ts"), ]; const scenarioArguments: Record = { - "chat-batching": [qualification("chat", "runChatBatchingQualification.ts")], "child-cancellation": [ - qualification("budgets", "runSafeChildCancellationEvidence.ts"), + integrationTest("resourceBudgets", "runSafeChildCancellationEvidence.ts"), ], "complete-shutdown": [ - qualification("shutdown", "runCompleteShutdownEvidence.ts"), + integrationTest("shutdown", "runCompleteShutdownEvidence.ts"), ], - "frontend-build": [qualification("build", "runFrontendBuildQualification.ts")], + "frontend-build": [integrationTest("build", "runFrontendBuildEvidence.ts")], "representative-tests": ["test", ...testFiles], - "sqlite-outbox": [qualification("outbox", "runSqliteOutboxEvidence.ts"), "1"], + "sqlite-outbox": [integrationTest("outbox", "runSqliteOutboxEvidence.ts"), "1"], }; return Object.freeze({ argv: Object.freeze([bunExecutable, ...scenarioArguments[scenarioId]]), diff --git a/qualification/budgets/resourceBudgetOrchestration.test.ts b/greenfield/src/test/integration/resourceBudgets/resourceBudgetOrchestration.test.ts similarity index 100% rename from qualification/budgets/resourceBudgetOrchestration.test.ts rename to greenfield/src/test/integration/resourceBudgets/resourceBudgetOrchestration.test.ts diff --git a/qualification/budgets/resourceBudgetOrchestration.ts b/greenfield/src/test/integration/resourceBudgets/resourceBudgetOrchestration.ts similarity index 98% rename from qualification/budgets/resourceBudgetOrchestration.ts rename to greenfield/src/test/integration/resourceBudgets/resourceBudgetOrchestration.ts index f6d05592c..7044b35b0 100644 --- a/qualification/budgets/resourceBudgetOrchestration.ts +++ b/greenfield/src/test/integration/resourceBudgets/resourceBudgetOrchestration.ts @@ -67,7 +67,7 @@ interface ResourceBudgetExecutables { readonly systemdRun: string; } -export interface ResourceBudgetQualificationReport { +export interface ResourceBudgetEvidenceReport { readonly assessments: readonly Readonly[]; readonly bunRevision: string; readonly bunVersion: string; @@ -398,7 +398,7 @@ function runScenario( childEntrypoint: path.join(import.meta.dir, "resourceBudgetUnit.ts"), envExecutable: executables.env, environment: process.env, - repositoryRoot: path.resolve(import.meta.dir, "../.."), + repositoryRoot: path.resolve(import.meta.dir, "../../../.."), resultPath, scenarioId, systemctlExecutable: executables.systemctl, @@ -461,8 +461,8 @@ function runScenario( } /** Executes all representative workloads sequentially under reviewed cgroup limits. */ -export const resourceBudgetQualification: Effect.Effect< - ResourceBudgetQualificationReport, +export const resourceBudgetEvidence: Effect.Effect< + ResourceBudgetEvidenceReport, ResourceBudgetOrchestrationError | ResourceBudgetOrchestrationDeadlineError > = Effect.scoped( Effect.gen(function* () { diff --git a/qualification/budgets/resourceBudgetPolicy.test.ts b/greenfield/src/test/integration/resourceBudgets/resourceBudgetPolicy.test.ts similarity index 90% rename from qualification/budgets/resourceBudgetPolicy.test.ts rename to greenfield/src/test/integration/resourceBudgets/resourceBudgetPolicy.test.ts index 9735c0320..4941bbf27 100644 --- a/qualification/budgets/resourceBudgetPolicy.test.ts +++ b/greenfield/src/test/integration/resourceBudgets/resourceBudgetPolicy.test.ts @@ -17,7 +17,7 @@ import { const userId = 1001; const wrapperProcessId = 4242; const unitName = createResourceBudgetUnitName( - "chat-batching", + "child-cancellation", "00000000-0000-4000-8000-000000000001" ); @@ -64,8 +64,8 @@ function validEvidence() { limits: { cpuPeriodMicros: 100_000, cpuQuotaMicros: 100_000, - memoryHighBytes: 128 * 1024 * 1024, - memoryMaxBytes: 192 * 1024 * 1024, + memoryHighBytes: 192 * 1024 * 1024, + memoryMaxBytes: 256 * 1024 * 1024, memorySwapMaxBytes: 0, oomGroup: true, pidsMax: 64, @@ -74,7 +74,7 @@ function validEvidence() { bunRevision: "0".repeat(40), bunVersion: "1.4.0", }, - scenarioId: "chat-batching", + scenarioId: "child-cancellation", unitName, workload: { durationMs: 1200, @@ -115,7 +115,8 @@ describe("resource-budget policy", () => { test("builds an argv-only transient unit with no inherited application secrets", () => { const command = buildResourceBudgetLauncherCommand({ bunExecutable: "/home/test/.bun/bin/bun", - childEntrypoint: "/repo/qualification/budgets/resourceBudgetUnit.ts", + childEntrypoint: + "/repo/src/test/integration/resourceBudgets/resourceBudgetUnit.ts", envExecutable: "/usr/bin/env", environment: { DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1001/bus", @@ -128,7 +129,7 @@ describe("resource-budget policy", () => { }, repositoryRoot: "/repo", resultPath: "/tmp/result.json", - scenarioId: "chat-batching", + scenarioId: "child-cancellation", systemctlExecutable: "/usr/bin/systemctl", systemdRunExecutable: "/usr/bin/systemd-run", temporaryDirectory: "/tmp/workload", @@ -136,8 +137,8 @@ describe("resource-budget policy", () => { }); expect(command.argv).toContain("--collect"); - expect(command.argv).toContain("--property=MemoryHigh=134217728"); - expect(command.argv).toContain("--property=MemoryMax=201326592"); + expect(command.argv).toContain("--property=MemoryHigh=201326592"); + expect(command.argv).toContain("--property=MemoryMax=268435456"); expect(command.argv).toContain("--property=MemorySwapMax=0"); expect(command.argv).toContain("--property=TasksMax=64"); expect(command.argv).toContain("--property=CPUQuota=100%"); @@ -152,7 +153,7 @@ describe("resource-budget policy", () => { }); }); - test("maps every scenario to a bounded first-party qualification command", () => { + test("maps every scenario to a bounded first-party integration command", () => { const environment = Object.freeze({ HOME: "/home/test" }); for (const scenarioId of resourceBudgetScenarioIds) { const command = buildResourceBudgetWorkloadCommand( @@ -162,7 +163,7 @@ describe("resource-budget policy", () => { environment ); expect(command.argv[0]).toBe("/home/test/.bun/bin/bun"); - expect(command.argv.join(" ")).toContain("/repo/qualification/"); + expect(command.argv.join(" ")).toContain("/repo/src/test/integration/"); expect(command.environment).toBe(environment); } }); @@ -177,11 +178,11 @@ describe("resource-budget policy", () => { cpuThrottledMicros: 20, cpuUsageMicros: 70_000, durationMs: 1200, - memoryHeadroomBytes: 64 * 1024 * 1024, + memoryHeadroomBytes: 128 * 1024 * 1024, memoryPeakBytes: 64 * 1024 * 1024, memoryPressureMicros: 4, pidsPeak: 8, - scenarioId: "chat-batching", + scenarioId: "child-cancellation", }); }); @@ -198,7 +199,7 @@ describe("resource-budget policy", () => { [ "crossed memory.high", (candidate) => { - candidate.report.cgroup.memoryPeakBytes = 128 * 1024 * 1024; + candidate.report.cgroup.memoryPeakBytes = 192 * 1024 * 1024; }, ], [ @@ -242,7 +243,7 @@ describe("resource-budget policy", () => { ) ).toThrow(); expect(() => - createResourceBudgetUnitName("chat-batching", "../attacker") + createResourceBudgetUnitName("child-cancellation", "../attacker") ).toThrow("unit identifier"); }); }); diff --git a/qualification/budgets/resourceBudgetPolicy.ts b/greenfield/src/test/integration/resourceBudgets/resourceBudgetPolicy.ts similarity index 95% rename from qualification/budgets/resourceBudgetPolicy.ts rename to greenfield/src/test/integration/resourceBudgets/resourceBudgetPolicy.ts index 93dbeff86..79101da73 100644 --- a/qualification/budgets/resourceBudgetPolicy.ts +++ b/greenfield/src/test/integration/resourceBudgets/resourceBudgetPolicy.ts @@ -10,7 +10,6 @@ export const resourceBudgetScenarioIds = [ "frontend-build", "representative-tests", "sqlite-outbox", - "chat-batching", "complete-shutdown", "child-cancellation", ] as const; @@ -55,11 +54,6 @@ function frozenScenario( } const resourceBudgetScenarios = Object.freeze({ - "chat-batching": frozenScenario("OpenClaw-shaped deterministic chat batching", { - ...sharedSmallWorkloadLimits, - memoryHighBytes: 128 * mebibyte, - memoryMaxBytes: 192 * mebibyte, - }), "child-cancellation": frozenScenario( "Effect interruption and detached process-group cleanup", { @@ -89,19 +83,16 @@ const resourceBudgetScenarios = Object.freeze({ workloadDeadlineMs: 165_000, } ), - "representative-tests": frozenScenario( - "Bounded representative Phase 0 qualification tests", - { - cpuQuotaPercent: 200, - memoryHighBytes: 768 * mebibyte, - memoryMaxBytes: 1024 * mebibyte, - memorySwapMaxBytes: 0, - outerDeadlineMs: 195_000, - runtimeMaxSeconds: 180, - tasksMax: 96, - workloadDeadlineMs: 165_000, - } - ), + "representative-tests": frozenScenario("Bounded representative runtime scenarios", { + cpuQuotaPercent: 200, + memoryHighBytes: 768 * mebibyte, + memoryMaxBytes: 1024 * mebibyte, + memorySwapMaxBytes: 0, + outerDeadlineMs: 195_000, + runtimeMaxSeconds: 180, + tasksMax: 96, + workloadDeadlineMs: 165_000, + }), "sqlite-outbox": frozenScenario( "Multi-process SQLite outbox, crash recovery, and restore", { @@ -230,7 +221,7 @@ export function createResourceBudgetUnitName( } /** - * Rejects unit names outside the exact qualification grammar. + * Rejects unit names outside the exact transient-unit grammar. * @param unitName Candidate transient service name. */ export function assertResourceBudgetUnitName(unitName: string): void { diff --git a/qualification/budgets/resourceBudgetUnit.ts b/greenfield/src/test/integration/resourceBudgets/resourceBudgetUnit.ts similarity index 100% rename from qualification/budgets/resourceBudgetUnit.ts rename to greenfield/src/test/integration/resourceBudgets/resourceBudgetUnit.ts diff --git a/qualification/budgets/runResourceBudgetEvidence.ts b/greenfield/src/test/integration/resourceBudgets/runResourceBudgetEvidence.ts similarity index 63% rename from qualification/budgets/runResourceBudgetEvidence.ts rename to greenfield/src/test/integration/resourceBudgets/runResourceBudgetEvidence.ts index 0a511da7d..3403d9352 100644 --- a/qualification/budgets/runResourceBudgetEvidence.ts +++ b/greenfield/src/test/integration/resourceBudgets/runResourceBudgetEvidence.ts @@ -1,9 +1,9 @@ import { Effect } from "effect"; -import { resourceBudgetQualification } from "./resourceBudgetOrchestration.ts"; +import { resourceBudgetEvidence } from "./resourceBudgetOrchestration.ts"; try { - const report = await Effect.runPromise(resourceBudgetQualification); + const report = await Effect.runPromise(resourceBudgetEvidence); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); } catch (error) { process.stderr.write( diff --git a/qualification/budgets/runSafeChildCancellationEvidence.ts b/greenfield/src/test/integration/resourceBudgets/runSafeChildCancellationEvidence.ts similarity index 77% rename from qualification/budgets/runSafeChildCancellationEvidence.ts rename to greenfield/src/test/integration/resourceBudgets/runSafeChildCancellationEvidence.ts index d79aa9278..8b078cd7f 100644 --- a/qualification/budgets/runSafeChildCancellationEvidence.ts +++ b/greenfield/src/test/integration/resourceBudgets/runSafeChildCancellationEvidence.ts @@ -1,8 +1,8 @@ import { Effect } from "effect"; -import { interruptedShutdownQualification } from "../shutdown/completeShutdownQualification.ts"; +import { interruptedShutdownScenario } from "../shutdown/completeShutdownScenario.ts"; -const report = await Effect.runPromise(interruptedShutdownQualification); +const report = await Effect.runPromise(interruptedShutdownScenario); if ( report.processGroupMembersWhileReady.length === 0 || report.processGroupMembersAfterInterruption.length > 0 || diff --git a/qualification/resources/cgroupV2.test.ts b/greenfield/src/test/integration/resources/cgroupV2.test.ts similarity index 100% rename from qualification/resources/cgroupV2.test.ts rename to greenfield/src/test/integration/resources/cgroupV2.test.ts diff --git a/qualification/resources/cgroupV2.ts b/greenfield/src/test/integration/resources/cgroupV2.ts similarity index 100% rename from qualification/resources/cgroupV2.ts rename to greenfield/src/test/integration/resources/cgroupV2.ts diff --git a/qualification/resources/cgroupV2Hierarchy.test.ts b/greenfield/src/test/integration/resources/cgroupV2Hierarchy.test.ts similarity index 100% rename from qualification/resources/cgroupV2Hierarchy.test.ts rename to greenfield/src/test/integration/resources/cgroupV2Hierarchy.test.ts diff --git a/qualification/resources/cgroupV2Hierarchy.ts b/greenfield/src/test/integration/resources/cgroupV2Hierarchy.ts similarity index 100% rename from qualification/resources/cgroupV2Hierarchy.ts rename to greenfield/src/test/integration/resources/cgroupV2Hierarchy.ts diff --git a/qualification/resources/pausedTlsSseClient.test.ts b/greenfield/src/test/integration/resources/pausedTlsSseClient.test.ts similarity index 86% rename from qualification/resources/pausedTlsSseClient.test.ts rename to greenfield/src/test/integration/resources/pausedTlsSseClient.test.ts index 4d0fcdcd2..4610b0fff 100644 --- a/qualification/resources/pausedTlsSseClient.test.ts +++ b/greenfield/src/test/integration/resources/pausedTlsSseClient.test.ts @@ -3,15 +3,15 @@ import { describe, expect, test } from "bun:test"; import { Effect, Exit, Fiber, Result, Scope } from "effect"; import { TestClock } from "effect/testing"; +import { AsyncCleanupStack } from "../../support/asyncCleanupStack.ts"; +import { waitFor } from "../../support/waitFor.ts"; import { - QualificationEventFeed, - qualificationEventLimits, -} from "../realtime/eventFeed.ts"; -import { AsyncCleanupStack } from "../test/asyncCleanupStack.ts"; -import { waitFor } from "../test/waitFor.ts"; -import { startHttpsReverseProxy } from "../topology/httpsReverseProxy.ts"; -import { createTestTlsIdentity } from "../topology/testTlsIdentity.ts"; -import { startQualificationServer } from "../trpc/server.ts"; + IntegrationEventFeed, + integrationEventLimits, +} from "../transport/realtime/eventFeed.ts"; +import { startHttpsReverseProxy } from "../transport/topology/httpsReverseProxy.ts"; +import { createTestTlsIdentity } from "../transport/topology/testTlsIdentity.ts"; +import { startIntegrationServer } from "../transport/trpc/server.ts"; import { openPausedTlsSseClient, PausedTlsSseClientArgumentError, @@ -20,9 +20,9 @@ import { pausedTlsSseClientResource, withPausedTlsSseClientDeadline, } from "./pausedTlsSseClient.ts"; -import { sseMemoryQualificationPolicy } from "./resourcePolicy.ts"; +import { sseMemoryEvidencePolicy } from "./resourcePolicy.ts"; -const qualificationCookie = "mira_qualification=paused-native-client"; +const evidenceCookie = "mira_evidence=paused-native-client"; const connectedFrame = Buffer.from("event: connected\ndata: {}\n\n", "ascii"); function createPausedClientReadBoundary(clientPaused: Promise) { @@ -59,8 +59,8 @@ function createPausedClientReadBoundary(clientPaused: Promise) { describe("paused native TLS SSE client", () => { test("rejects CR and LF in the raw cookie header", async () => { for (const invalidCookie of [ - `${qualificationCookie}\rInjected: true`, - `${qualificationCookie}\nInjected: true`, + `${evidenceCookie}\rInjected: true`, + `${evidenceCookie}\nInjected: true`, ]) { const failure = await openPausedTlsSseClient( new URL("https://127.0.0.1:1"), @@ -97,7 +97,7 @@ describe("paused native TLS SSE client", () => { const failure = await openPausedTlsSseClient( new URL(`https://127.0.0.1:${closedPort}`), tlsIdentity.certificate, - qualificationCookie, + evidenceCookie, 1000 ).then( () => null, @@ -150,13 +150,13 @@ describe("paused native TLS SSE client", () => { try { const tlsIdentity = await createTestTlsIdentity(); cleanup.defer("paused client TLS identity", () => tlsIdentity.dispose()); - const eventFeed = new QualificationEventFeed(); - const release = startQualificationServer({ + const eventFeed = new IntegrationEventFeed(); + const release = startIntegrationServer({ eventFeed, hostname: "127.0.0.1", maximumStreamDurationMs: 9000, releaseId: "paused-native-client", - requiredCookie: qualificationCookie, + requiredCookie: evidenceCookie, requireSecureProxy: true, }); cleanup.defer("paused client release", () => release.stop(true)); @@ -179,7 +179,7 @@ describe("paused native TLS SSE client", () => { pausedTlsSseClientResource( proxy.url, tlsIdentity.certificate, - qualificationCookie, + evidenceCookie, 2000 ) ) @@ -192,14 +192,14 @@ describe("paused native TLS SSE client", () => { await readBoundary.boundaryHeld; await waitFor(() => eventFeed.activeSubscriberCount === 1); - const payload = "x".repeat(qualificationEventLimits.maximumPayloadBytes); + const payload = "x".repeat(integrationEventLimits.maximumPayloadBytes); for ( let sequence = 1; - sequence <= sseMemoryQualificationPolicy.scenario.maximumEventsPerRound; + sequence <= sseMemoryEvidencePolicy.scenario.maximumEventsPerRound; sequence += 1 ) { eventFeed.publish({ - kind: "qualification.changed", + kind: "integration.changed", payload, value: sequence, }); @@ -223,9 +223,9 @@ describe("paused native TLS SSE client", () => { } expect(eventFeed.metricsSnapshot()).toMatchObject({ maximumObservedQueueDepth: - qualificationEventLimits.maximumSubscriberQueueEvents, + integrationEventLimits.maximumSubscriberQueueEvents, maximumObservedQueuedPayloadBytes: - qualificationEventLimits.maximumSubscriberQueuedPayloadBytes, + integrationEventLimits.maximumSubscriberQueuedPayloadBytes, }); await Effect.runPromise(Scope.close(clientScope, Exit.void)); diff --git a/qualification/resources/pausedTlsSseClient.ts b/greenfield/src/test/integration/resources/pausedTlsSseClient.ts similarity index 99% rename from qualification/resources/pausedTlsSseClient.ts rename to greenfield/src/test/integration/resources/pausedTlsSseClient.ts index 12e926193..b6e783b65 100644 --- a/qualification/resources/pausedTlsSseClient.ts +++ b/greenfield/src/test/integration/resources/pausedTlsSseClient.ts @@ -417,7 +417,7 @@ function openPausedTlsSseClientEffect( * Effect-scoped paused client whose finalizer bounds and confirms native closure. * @param publicUrl Stable HTTPS proxy URL. * @param certificateAuthority PEM certificate trusted only for this client. - * @param cookie Qualification cookie required by the upstream server. + * @param cookie Evidence cookie required by the upstream server. * @param timeoutMs Maximum handshake, connected-frame, and close wait. * @returns Scoped paused native socket. */ @@ -438,7 +438,7 @@ export function pausedTlsSseClientResource( * Opens a CA-verified TLS socket and pauses reads immediately after tRPC connects. * @param publicUrl Stable HTTPS proxy URL. * @param certificateAuthority PEM certificate trusted only for this client. - * @param cookie Qualification cookie required by the upstream server. + * @param cookie Evidence cookie required by the upstream server. * @param timeoutMs Maximum handshake and connected-frame wait. * @returns Paused native socket controlled by the caller. */ diff --git a/qualification/resources/pausedTlsSseHandshake.test.ts b/greenfield/src/test/integration/resources/pausedTlsSseHandshake.test.ts similarity index 100% rename from qualification/resources/pausedTlsSseHandshake.test.ts rename to greenfield/src/test/integration/resources/pausedTlsSseHandshake.test.ts diff --git a/qualification/resources/pausedTlsSseHandshake.ts b/greenfield/src/test/integration/resources/pausedTlsSseHandshake.ts similarity index 98% rename from qualification/resources/pausedTlsSseHandshake.ts rename to greenfield/src/test/integration/resources/pausedTlsSseHandshake.ts index 0059238cc..56e1a6eb8 100644 --- a/qualification/resources/pausedTlsSseHandshake.ts +++ b/greenfield/src/test/integration/resources/pausedTlsSseHandshake.ts @@ -1,6 +1,6 @@ import * as v from "valibot"; -import { nonnegativeSafeIntegerSchema } from "../../src/shared/validation.ts"; +import { nonnegativeSafeIntegerSchema } from "../../../shared/validation.ts"; const connectedFrame = Buffer.from("event: connected\ndata: {}\n\n", "ascii"); const headerTerminator = Buffer.from("\r\n\r\n", "ascii"); diff --git a/qualification/resources/processMemory.test.ts b/greenfield/src/test/integration/resources/processMemory.test.ts similarity index 100% rename from qualification/resources/processMemory.test.ts rename to greenfield/src/test/integration/resources/processMemory.test.ts diff --git a/qualification/resources/processMemory.ts b/greenfield/src/test/integration/resources/processMemory.ts similarity index 94% rename from qualification/resources/processMemory.ts rename to greenfield/src/test/integration/resources/processMemory.ts index f17a5ce21..4fd4cd208 100644 --- a/qualification/resources/processMemory.ts +++ b/greenfield/src/test/integration/resources/processMemory.ts @@ -1,15 +1,15 @@ import { parseSchemaWithRangeError, positiveSafeIntegerSchema, -} from "../../src/shared/validation.ts"; +} from "../../../shared/validation.ts"; -/** Process-level memory observed by one qualification sample. */ +/** Process-level memory observed by one evidence sample. */ export interface ProcessMemorySnapshot { rssBytes: number; unsafeFootprintBytes: number | null; } -/** Controlled periodic sampler used only by the capped qualification. */ +/** Controlled periodic sampler used only by the capped evidence. */ export interface ProcessMemorySampler { sample(): ProcessMemorySnapshot; stop(): ProcessMemorySnapshot; diff --git a/qualification/resources/resourcePolicy.test.ts b/greenfield/src/test/integration/resources/resourcePolicy.test.ts similarity index 92% rename from qualification/resources/resourcePolicy.test.ts rename to greenfield/src/test/integration/resources/resourcePolicy.test.ts index 35567ce25..d289b07cf 100644 --- a/qualification/resources/resourcePolicy.test.ts +++ b/greenfield/src/test/integration/resources/resourcePolicy.test.ts @@ -5,7 +5,7 @@ import type { CgroupV2AncestorSnapshot } from "./cgroupV2Hierarchy.ts"; import { assertCgroupAncestorResourcePolicy, assertCgroupResourcePolicy, - sseMemoryQualificationPolicy, + sseMemoryEvidencePolicy, } from "./resourcePolicy.ts"; function policySnapshot( @@ -58,9 +58,9 @@ function ancestorSnapshot( }; } -describe("SSE memory qualification resource policy", () => { +describe("SSE memory evidence resource policy", () => { test("exports the reviewed immutable cgroup and scenario boundaries", () => { - expect(sseMemoryQualificationPolicy).toEqual({ + expect(sseMemoryEvidencePolicy).toEqual({ cgroup: { cpuQuotaPercent: 50, memoryHighBytes: 268_435_456, @@ -86,9 +86,9 @@ describe("SSE memory qualification resource policy", () => { stabilizationMs: 100, }, }); - expect(Object.isFrozen(sseMemoryQualificationPolicy)).toBeTrue(); - expect(Object.isFrozen(sseMemoryQualificationPolicy.cgroup)).toBeTrue(); - expect(Object.isFrozen(sseMemoryQualificationPolicy.scenario)).toBeTrue(); + expect(Object.isFrozen(sseMemoryEvidencePolicy)).toBeTrue(); + expect(Object.isFrozen(sseMemoryEvidencePolicy.cgroup)).toBeTrue(); + expect(Object.isFrozen(sseMemoryEvidencePolicy.scenario)).toBeTrue(); }); test("accepts the exact reviewed cgroup policy", () => { @@ -124,7 +124,7 @@ describe("SSE memory qualification resource policy", () => { ]) { const snapshot = policySnapshot(overrides); expect(() => assertCgroupResourcePolicy(snapshot, snapshot.path)).toThrow( - "SSE memory qualification requires" + "SSE memory evidence requires" ); } }); @@ -142,7 +142,7 @@ describe("SSE memory qualification resource policy", () => { ]) { const snapshot = policySnapshot(overrides); expect(() => assertCgroupResourcePolicy(snapshot, snapshot.path)).toThrow( - "SSE memory qualification requires" + "SSE memory evidence requires" ); } }); diff --git a/qualification/resources/resourcePolicy.ts b/greenfield/src/test/integration/resources/resourcePolicy.ts similarity index 78% rename from qualification/resources/resourcePolicy.ts rename to greenfield/src/test/integration/resources/resourcePolicy.ts index e76ea979b..f77ac50c0 100644 --- a/qualification/resources/resourcePolicy.ts +++ b/greenfield/src/test/integration/resources/resourcePolicy.ts @@ -1,6 +1,6 @@ import * as v from "valibot"; -import { positiveSafeIntegerSchema } from "../../src/shared/validation.ts"; +import { positiveSafeIntegerSchema } from "../../../shared/validation.ts"; import type { CgroupV2Limit, CgroupV2Snapshot } from "./cgroupV2.ts"; import type { CgroupV2AncestorSnapshot } from "./cgroupV2Hierarchy.ts"; import { assertSseMemoryUnitCgroupPath } from "./unitIdentity.ts"; @@ -12,8 +12,8 @@ function isPositiveCpuValue(value: unknown): value is number { return v.safeParse(positiveCpuValueSchema, value).success; } -/** Fixed cgroup and load boundaries for the SSE memory qualification. */ -export const sseMemoryQualificationPolicy = Object.freeze({ +/** Fixed cgroup and load boundaries for the SSE memory evidence. */ +export const sseMemoryEvidencePolicy = Object.freeze({ cgroup: Object.freeze({ cpuQuotaPercent: 50, memoryHighBytes: 256 * mebibyte, @@ -43,7 +43,7 @@ export const sseMemoryQualificationPolicy = Object.freeze({ function assertExactLimit(label: string, actual: CgroupV2Limit, expected: number): void { if (actual !== expected) { throw new Error( - `SSE memory qualification requires ${label}=${expected}; observed ${String(actual)}` + `SSE memory evidence requires ${label}=${expected}; observed ${String(actual)}` ); } } @@ -55,7 +55,7 @@ function assertAncestorLimit( ): void { if (actual !== "max" && actual < leafLimit) { throw new Error( - `SSE memory qualification ancestor ${label} must not be stricter than ${leafLimit}; observed ${actual}` + `SSE memory evidence ancestor ${label} must not be stricter than ${leafLimit}; observed ${actual}` ); } } @@ -69,9 +69,9 @@ export function assertCgroupAncestorResourcePolicy( ancestors: readonly Readonly[] ): void { if (ancestors.length === 0) { - throw new Error("SSE memory qualification requires cgroup ancestor evidence"); + throw new Error("SSE memory evidence requires cgroup ancestor evidence"); } - const policy = sseMemoryQualificationPolicy.cgroup; + const policy = sseMemoryEvidencePolicy.cgroup; for (const ancestor of ancestors) { assertAncestorLimit( `${ancestor.path} memory.high`, @@ -99,7 +99,7 @@ export function assertCgroupAncestorResourcePolicy( !isPositiveCpuValue(ancestor.cpuQuotaMicros)) ) { throw new Error( - `SSE memory qualification ancestor ${ancestor.path} has an invalid cpu.max policy` + `SSE memory evidence ancestor ${ancestor.path} has an invalid cpu.max policy` ); } if ( @@ -108,14 +108,14 @@ export function assertCgroupAncestorResourcePolicy( BigInt(ancestor.cpuPeriodMicros) * BigInt(policy.cpuQuotaPercent) ) { throw new Error( - `SSE memory qualification ancestor ${ancestor.path} cpu.max is stricter than ${policy.cpuQuotaPercent}%` + `SSE memory evidence ancestor ${ancestor.path} cpu.max is stricter than ${policy.cpuQuotaPercent}%` ); } } } /** - * Requires the current cgroup to match the reviewed qualification policy exactly. + * Requires the current cgroup to match the reviewed evidence policy exactly. * @param snapshot Current cgroup v2 resource state. * @throws {Error} When any required controller cap is absent, weaker, or nonexact. */ @@ -123,7 +123,7 @@ export function assertCgroupResourcePolicy( snapshot: Readonly, expectedCgroupPath: string ): void { - const policy = sseMemoryQualificationPolicy.cgroup; + const policy = sseMemoryEvidencePolicy.cgroup; assertExactLimit("memory.high", snapshot.memoryHighBytes, policy.memoryHighBytes); assertExactLimit("memory.max", snapshot.memoryMaxBytes, policy.memoryMaxBytes); assertExactLimit( @@ -142,13 +142,11 @@ export function assertCgroupResourcePolicy( BigInt(snapshot.cpuPeriodMicros) * BigInt(policy.cpuQuotaPercent) ) { throw new Error( - `SSE memory qualification requires cpu.max=${policy.cpuQuotaPercent}%; observed ${String(cpuQuota)} ${snapshot.cpuPeriodMicros}` + `SSE memory evidence requires cpu.max=${policy.cpuQuotaPercent}%; observed ${String(cpuQuota)} ${snapshot.cpuPeriodMicros}` ); } if (!snapshot.oomGroup) { - throw new Error( - `SSE memory qualification requires OOMPolicy=${policy.oomPolicy}` - ); + throw new Error(`SSE memory evidence requires OOMPolicy=${policy.oomPolicy}`); } assertSseMemoryUnitCgroupPath(snapshot.path, expectedCgroupPath); } diff --git a/qualification/resources/runSseMemoryQualification.test.ts b/greenfield/src/test/integration/resources/runSseMemoryEvidence.test.ts similarity index 78% rename from qualification/resources/runSseMemoryQualification.test.ts rename to greenfield/src/test/integration/resources/runSseMemoryEvidence.test.ts index b9e1b4183..466580a3e 100644 --- a/qualification/resources/runSseMemoryQualification.test.ts +++ b/greenfield/src/test/integration/resources/runSseMemoryEvidence.test.ts @@ -1,13 +1,13 @@ import { describe, expect, test } from "bun:test"; import { - formatSseMemoryQualificationError, + formatSseMemoryEvidenceError, parseSseMemoryCliArguments, -} from "./runSseMemoryQualification.ts"; +} from "./runSseMemoryEvidence.ts"; const unitName = "mira-dashboard-sse-memory-019fcb3d-6cf6-7000-8000-000000000001"; -describe("SSE memory qualification CLI", () => { +describe("SSE memory evidence CLI", () => { test("uses parent mode by default", () => { expect(parseSseMemoryCliArguments([])).toEqual({ mode: "parent" }); }); @@ -16,12 +16,12 @@ describe("SSE memory qualification CLI", () => { expect( parseSseMemoryCliArguments([ "--child", - "--result=/tmp/qualification/evidence.json", + "--result=/tmp/integration-evidence/evidence.json", `--unit=${unitName}`, ]) ).toEqual({ mode: "child", - resultPath: "/tmp/qualification/evidence.json", + resultPath: "/tmp/integration-evidence/evidence.json", unitName, }); for (const arguments_ of [ @@ -42,13 +42,13 @@ describe("SSE memory qualification CLI", () => { }); test("formats nested and aggregate CLI failures without losing causes", () => { - const nested = formatSseMemoryQualificationError( + const nested = formatSseMemoryEvidenceError( new Error("outer failure", { cause: new Error("inner failure") }) ); expect(nested).toContain("outer failure"); expect(nested).toContain("inner failure"); - const aggregate = formatSseMemoryQualificationError( + const aggregate = formatSseMemoryEvidenceError( new AggregateError( [new Error("operation failed"), new Error("cleanup failed")], "combined failure" @@ -57,8 +57,8 @@ describe("SSE memory qualification CLI", () => { expect(aggregate).toContain("combined failure"); expect(aggregate).toContain("operation failed"); expect(aggregate).toContain("cleanup failed"); - expect(formatSseMemoryQualificationError("not an error")).toBe( - "SSE memory qualification failed" + expect(formatSseMemoryEvidenceError("not an error")).toBe( + "SSE memory evidence failed" ); }); }); diff --git a/qualification/resources/runSseMemoryQualification.ts b/greenfield/src/test/integration/resources/runSseMemoryEvidence.ts similarity index 77% rename from qualification/resources/runSseMemoryQualification.ts rename to greenfield/src/test/integration/resources/runSseMemoryEvidence.ts index d4ecd7477..6e5317092 100644 --- a/qualification/resources/runSseMemoryQualification.ts +++ b/greenfield/src/test/integration/resources/runSseMemoryEvidence.ts @@ -2,37 +2,37 @@ import { mkdtemp, rename, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { parseSseMemoryQualificationEvidence } from "./sseMemoryEvidence.ts"; +import { parseSseMemoryEvidence } from "./sseMemoryEvidence.ts"; import { runSseMemoryScenario } from "./sseMemoryScenario.ts"; import { buildSystemdLauncherCommand, createSseMemoryUnitName, - runSystemdQualification, + runSystemdEvidence, } from "./systemdLauncher.ts"; import { assertSseMemoryUnitName } from "./unitIdentity.ts"; -type QualificationCliArguments = +type EvidenceCliArguments = | { mode: "child"; resultPath: string; unitName: string } | { mode: "parent" }; -const qualificationFailureMessage = "SSE memory qualification failed"; +const evidenceFailureMessage = "SSE memory evidence failed"; function requiredExecutable(name: string): string { const executable = Bun.which(name); if (executable === null) { - throw new Error(`${name} is required for the SSE memory qualification`); + throw new Error(`${name} is required for the SSE memory evidence`); } return executable; } /** - * Parses the intentionally narrow parent/child qualification interface. + * Parses the intentionally narrow parent/child evidence interface. * @param arguments_ Command-line arguments after the Bun entrypoint. * @returns Parent mode or a child result path. */ export function parseSseMemoryCliArguments( arguments_: readonly string[] -): QualificationCliArguments { +): EvidenceCliArguments { if (arguments_.length === 0) { return { mode: "parent" }; } @@ -51,17 +51,17 @@ export function parseSseMemoryCliArguments( } } throw new TypeError( - "Usage: runSseMemoryQualification.ts [--child --result=/absolute/path --unit=mira-dashboard-sse-memory-]" + "Usage: runSseMemoryEvidence.ts [--child --result=/absolute/path --unit=mira-dashboard-sse-memory-]" ); } /** - * Formats qualification failures without discarding nested causes or aggregate errors. + * Formats evidence failures without discarding nested causes or aggregate errors. * @param error Unknown failure caught at the CLI boundary. * @returns Plain-text diagnostic safe for stderr. */ -export function formatSseMemoryQualificationError(error: unknown): string { - if (!(error instanceof Error)) return qualificationFailureMessage; +export function formatSseMemoryEvidenceError(error: unknown): string { + if (!(error instanceof Error)) return evidenceFailureMessage; const inspected = Bun.inspect(error, { colors: false }); return error.message.length > 0 && !inspected.includes(error.message) ? `${error.message}\n${inspected}` @@ -86,22 +86,20 @@ async function runParent(): Promise { childEntrypoint: import.meta.path, envExecutable: requiredExecutable("env"), environment: process.env, - repositoryRoot: path.resolve(import.meta.dir, "../.."), + repositoryRoot: path.resolve(import.meta.dir, "../../../.."), resultPath, systemctlExecutable: requiredExecutable("systemctl"), systemdRunExecutable: requiredExecutable("systemd-run"), unitName: createSseMemoryUnitName(), }); - const result = await runSystemdQualification(command); + const result = await runSystemdEvidence(command); if (result.exitCode !== 0) { const diagnostic = result.stderr.trim() || result.stdout.trim(); throw new Error( - `SSE memory qualification child exited ${result.exitCode}${diagnostic ? `: ${diagnostic}` : ""}` + `SSE memory evidence child exited ${result.exitCode}${diagnostic ? `: ${diagnostic}` : ""}` ); } - const evidence = parseSseMemoryQualificationEvidence( - await Bun.file(resultPath).text() - ); + const evidence = parseSseMemoryEvidence(await Bun.file(resultPath).text()); process.stdout.write(`${JSON.stringify(evidence, null, 2)}\n`); } finally { await rm(temporaryDirectory, { force: true, recursive: true }); @@ -121,7 +119,7 @@ if (import.meta.main) { try { await main(Bun.argv.slice(2)); } catch (error) { - process.stderr.write(`${formatSseMemoryQualificationError(error)}\n`); + process.stderr.write(`${formatSseMemoryEvidenceError(error)}\n`); process.exitCode = 1; } } diff --git a/qualification/resources/sseMemoryEvidence.test.ts b/greenfield/src/test/integration/resources/sseMemoryEvidence.test.ts similarity index 87% rename from qualification/resources/sseMemoryEvidence.test.ts rename to greenfield/src/test/integration/resources/sseMemoryEvidence.test.ts index a9146470e..cfdeabdf1 100644 --- a/qualification/resources/sseMemoryEvidence.test.ts +++ b/greenfield/src/test/integration/resources/sseMemoryEvidence.test.ts @@ -1,35 +1,32 @@ import { describe, expect, test } from "bun:test"; -import { qualificationEventLimits } from "../realtime/eventFeed.ts"; +import { integrationEventLimits } from "../transport/realtime/eventFeed.ts"; import type { CgroupV2Snapshot } from "./cgroupV2.ts"; import { ancestorCgroupV2Paths, type CgroupV2AncestorSnapshot, } from "./cgroupV2Hierarchy.ts"; -import { sseMemoryQualificationPolicy } from "./resourcePolicy.ts"; +import { sseMemoryEvidencePolicy } from "./resourcePolicy.ts"; import { memoryEventDifference, - parseSseMemoryQualificationEvidence, + parseSseMemoryEvidence, type SseMemoryEvidenceCandidate, validateSseMemoryEvidence, } from "./sseMemoryEvidence.ts"; import { - currentQualificationUserId, + currentIntegrationUserId, expectedSseMemoryUnitCgroupPath, } from "./unitIdentity.ts"; const mebibyte = 1024 * 1024; const unitName = "mira-dashboard-sse-memory-019fcb3d-6cf6-7000-8000-000000000001"; -const cgroupPath = expectedSseMemoryUnitCgroupPath( - currentQualificationUserId(), - unitName -); -const cgroupPolicy = sseMemoryQualificationPolicy.cgroup; -const scenarioPolicy = sseMemoryQualificationPolicy.scenario; +const cgroupPath = expectedSseMemoryUnitCgroupPath(currentIntegrationUserId(), unitName); +const cgroupPolicy = sseMemoryEvidencePolicy.cgroup; +const scenarioPolicy = sseMemoryEvidencePolicy.scenario; const cpuPeriodMicros = 100_000; const cpuQuotaMicros = (cpuPeriodMicros * cgroupPolicy.cpuQuotaPercent) / 100; const expectedSubscriptionCount = scenarioPolicy.consumerCount * scenarioPolicy.rounds; -const eventsPerRound = qualificationEventLimits.maximumSubscriberQueueEvents + 2; +const eventsPerRound = integrationEventLimits.maximumSubscriberQueueEvents + 2; const totalPublishedEvents = eventsPerRound * scenarioPolicy.rounds; const roundDurationMs = Math.min( @@ -107,12 +104,12 @@ function validCandidate(): SseMemoryEvidenceCandidate { droppedSlowSubscribers: expectedSubscriptionCount, latestSequence: totalPublishedEvents, maximumObservedQueueDepth: - qualificationEventLimits.maximumSubscriberQueueEvents, + integrationEventLimits.maximumSubscriberQueueEvents, maximumObservedQueuedPayloadBytes: - qualificationEventLimits.maximumSubscriberQueuedPayloadBytes, + integrationEventLimits.maximumSubscriberQueuedPayloadBytes, retainedEvents: Math.min( totalPublishedEvents, - qualificationEventLimits.maximumRetainedEvents + integrationEventLimits.maximumRetainedEvents ), }, process: { @@ -144,7 +141,7 @@ function validCandidate(): SseMemoryEvidenceCandidate { }; } -describe("SSE memory qualification evidence", () => { +describe("SSE memory evidence validation", () => { test("marks evidence validated only after all bounds pass", () => { expect(validateSseMemoryEvidence(validCandidate())).toMatchObject({ memoryEventDelta: { @@ -311,40 +308,34 @@ describe("SSE memory qualification evidence", () => { test("strictly reparses and revalidates child evidence", () => { const evidence = validateSseMemoryEvidence(validCandidate()); - expect(parseSseMemoryQualificationEvidence(JSON.stringify(evidence))).toEqual( - evidence - ); - expect(() => - parseSseMemoryQualificationEvidence('{"verdict":"VALIDATED"}') - ).toThrow(); + expect(parseSseMemoryEvidence(JSON.stringify(evidence))).toEqual(evidence); + expect(() => parseSseMemoryEvidence('{"verdict":"VALIDATED"}')).toThrow(); const unknownField = { ...evidence, unexpected: true }; - expect(() => - parseSseMemoryQualificationEvidence(JSON.stringify(unknownField)) - ).toThrow(); + expect(() => parseSseMemoryEvidence(JSON.stringify(unknownField))).toThrow(); const tamperedInvariant = { ...evidence, feed: { ...evidence.feed, activeSubscribers: 1 }, }; - expect(() => - parseSseMemoryQualificationEvidence(JSON.stringify(tamperedInvariant)) - ).toThrow("Active subscribers after cleanup"); + expect(() => parseSseMemoryEvidence(JSON.stringify(tamperedInvariant))).toThrow( + "Active subscribers after cleanup" + ); const invalidRevision = { ...evidence, runtime: { ...evidence.runtime, revision: "A".repeat(40) }, }; - expect(() => - parseSseMemoryQualificationEvidence(JSON.stringify(invalidRevision)) - ).toThrow("Bun revision is not a full commit SHA"); + expect(() => parseSseMemoryEvidence(JSON.stringify(invalidRevision))).toThrow( + "Bun revision is not a full commit SHA" + ); const tamperedDelta = { ...evidence, memoryEventDelta: { ...evidence.memoryEventDelta, high: 1 }, }; - expect(() => - parseSseMemoryQualificationEvidence(JSON.stringify(tamperedDelta)) - ).toThrow("invalid high"); + expect(() => parseSseMemoryEvidence(JSON.stringify(tamperedDelta))).toThrow( + "invalid high" + ); }); }); diff --git a/qualification/resources/sseMemoryEvidence.ts b/greenfield/src/test/integration/resources/sseMemoryEvidence.ts similarity index 73% rename from qualification/resources/sseMemoryEvidence.ts rename to greenfield/src/test/integration/resources/sseMemoryEvidence.ts index 023fad0fe..c20118bec 100644 --- a/qualification/resources/sseMemoryEvidence.ts +++ b/greenfield/src/test/integration/resources/sseMemoryEvidence.ts @@ -1,10 +1,10 @@ export { memoryEventDifference, - parseSseMemoryQualificationEvidence, + parseSseMemoryEvidence, validateSseMemoryEvidence, } from "./sseMemoryEvidencePolicy.ts"; export type { SseMemoryEvidenceCandidate, - SseMemoryQualificationEvidence, + SseMemoryEvidence, SseMemoryRoundEvidence, } from "./sseMemoryEvidenceSchema.ts"; diff --git a/qualification/resources/sseMemoryEvidencePolicy.ts b/greenfield/src/test/integration/resources/sseMemoryEvidencePolicy.ts similarity index 88% rename from qualification/resources/sseMemoryEvidencePolicy.ts rename to greenfield/src/test/integration/resources/sseMemoryEvidencePolicy.ts index 0ee2b5fc0..0c9a70f1d 100644 --- a/qualification/resources/sseMemoryEvidencePolicy.ts +++ b/greenfield/src/test/integration/resources/sseMemoryEvidencePolicy.ts @@ -1,22 +1,22 @@ import * as v from "valibot"; -import { fullCommitShaSchema } from "../../src/shared/validation.ts"; -import { qualificationEventLimits } from "../realtime/eventFeed.ts"; +import { fullCommitShaSchema } from "../../../shared/validation.ts"; +import { integrationEventLimits } from "../transport/realtime/eventFeed.ts"; import type { CgroupV2MemoryEvents } from "./cgroupV2.ts"; import { ancestorCgroupV2Paths } from "./cgroupV2Hierarchy.ts"; import { assertCgroupAncestorResourcePolicy, assertCgroupResourcePolicy, - sseMemoryQualificationPolicy, + sseMemoryEvidencePolicy, } from "./resourcePolicy.ts"; import { - canonicalizeSseMemoryQualificationEvidence, - parseSerializedSseMemoryQualificationEvidence, + canonicalizeSseMemoryEvidence, + parseSerializedSseMemoryEvidence, type SseMemoryEvidenceCandidate, - type SseMemoryQualificationEvidence, + type SseMemoryEvidence, } from "./sseMemoryEvidenceSchema.ts"; import { - currentQualificationUserId, + currentIntegrationUserId, expectedSseMemoryUnitCgroupPath, } from "./unitIdentity.ts"; @@ -25,7 +25,7 @@ const bunRevisionSchema = fullCommitShaSchema(); function difference(label: string, finalValue: number, baselineValue: number): number { const value = finalValue - baselineValue; if (value < 0) { - throw new Error(`${label} decreased during the qualification`); + throw new Error(`${label} decreased between evidence snapshots`); } return value; } @@ -61,7 +61,7 @@ function assertSameAncestorValue( ): void { if (baseline !== final) { throw new Error( - `Cgroup ancestor ${label} changed during qualification: ${String(baseline)} -> ${String(final)}` + `Cgroup ancestor ${label} changed between evidence snapshots: ${String(baseline)} -> ${String(final)}` ); } } @@ -157,7 +157,7 @@ function assertAncestorEvidence( } function assertProcessMemory(processMemory: SseMemoryEvidenceCandidate["process"]): void { - const policy = sseMemoryQualificationPolicy.scenario; + const policy = sseMemoryEvidencePolicy.scenario; assertAtLeast( "Sampled process RSS", processMemory.sampledPeak.rssBytes, @@ -193,22 +193,22 @@ function assertProcessMemory(processMemory: SseMemoryEvidenceCandidate["process" /** * Validates every bounded-load and memory invariant before publishing evidence. - * @param candidate Raw qualification measurements. + * @param candidate Raw evidence measurements. * @returns Evidence marked as validated. * @throws {Error} When any resource or behavior invariant fails. */ export function validateSseMemoryEvidence( candidate: SseMemoryEvidenceCandidate -): Readonly { +): Readonly { const expectedCgroupPath = expectedSseMemoryUnitCgroupPath( - currentQualificationUserId(), + currentIntegrationUserId(), candidate.unitName ); assertCgroupResourcePolicy(candidate.cgroup.initial, expectedCgroupPath); assertCgroupResourcePolicy(candidate.cgroup.baseline, expectedCgroupPath); assertCgroupResourcePolicy(candidate.cgroup.final, expectedCgroupPath); assertAncestorEvidence(candidate.cgroup.initial.path, candidate.cgroup.ancestors); - const policy = sseMemoryQualificationPolicy; + const policy = sseMemoryEvidencePolicy; const expectedDrops = policy.scenario.consumerCount * policy.scenario.rounds; const eventDelta = memoryEventDifference( candidate.cgroup.baseline.memoryEvents, @@ -237,15 +237,15 @@ export function validateSseMemoryEvidence( assertExact( "Subscriber queue event high-water", candidate.feed.maximumObservedQueueDepth, - qualificationEventLimits.maximumSubscriberQueueEvents + integrationEventLimits.maximumSubscriberQueueEvents ); assertExact( "Subscriber queue payload high-water", candidate.feed.maximumObservedQueuedPayloadBytes, - qualificationEventLimits.maximumSubscriberQueuedPayloadBytes + integrationEventLimits.maximumSubscriberQueuedPayloadBytes ); - if (candidate.feed.retainedEvents > qualificationEventLimits.maximumRetainedEvents) { - throw new Error("Qualification event retention exceeded its fixed limit"); + if (candidate.feed.retainedEvents > integrationEventLimits.maximumRetainedEvents) { + throw new Error("Evidence event retention exceeded its fixed limit"); } let publishedEvents = 0; @@ -289,7 +289,7 @@ export function validateSseMemoryEvidence( throw new Error("Bun revision is not a full commit SHA"); } - return canonicalizeSseMemoryQualificationEvidence(candidate, eventDelta); + return canonicalizeSseMemoryEvidence(candidate, eventDelta); } /** @@ -297,10 +297,8 @@ export function validateSseMemoryEvidence( * @param value Serialized evidence emitted by the capped child. * @returns Canonical evidence reconstructed by the parent validator. */ -export function parseSseMemoryQualificationEvidence( - value: string -): Readonly { - const parsed = parseSerializedSseMemoryQualificationEvidence(value); +export function parseSseMemoryEvidence(value: string): Readonly { + const parsed = parseSerializedSseMemoryEvidence(value); const validated = validateSseMemoryEvidence({ cgroup: parsed.cgroup, durationMs: parsed.durationMs, @@ -317,7 +315,7 @@ export function parseSseMemoryQualificationEvidence( keyof CgroupV2MemoryEvents >) { if (parsed.memoryEventDelta[name] !== validated.memoryEventDelta[name]) { - throw new Error(`SSE memory qualification child returned invalid ${name}`); + throw new Error(`SSE memory evidence child returned invalid ${name}`); } } return validated; diff --git a/qualification/resources/sseMemoryEvidenceSchema.ts b/greenfield/src/test/integration/resources/sseMemoryEvidenceSchema.ts similarity index 90% rename from qualification/resources/sseMemoryEvidenceSchema.ts rename to greenfield/src/test/integration/resources/sseMemoryEvidenceSchema.ts index a46209566..a1aaa1c71 100644 --- a/qualification/resources/sseMemoryEvidenceSchema.ts +++ b/greenfield/src/test/integration/resources/sseMemoryEvidenceSchema.ts @@ -1,8 +1,8 @@ import * as v from "valibot"; -import { nonnegativeSafeIntegerSchema } from "../../src/shared/validation.ts"; -import type { QualificationEventFeedMetrics } from "../realtime/eventFeed.ts"; -import type { RuntimeIdentity } from "../runtimeCandidate.ts"; +import { nonnegativeSafeIntegerSchema } from "../../../shared/validation.ts"; +import type { RuntimeIdentity } from "../runtime/runtimeCandidate.ts"; +import type { IntegrationEventFeedMetrics } from "../transport/realtime/eventFeed.ts"; import type { CgroupV2MemoryEvents, CgroupV2Snapshot } from "./cgroupV2.ts"; import type { CgroupV2AncestorSnapshot } from "./cgroupV2Hierarchy.ts"; import type { ProcessMemorySnapshot } from "./processMemory.ts"; @@ -25,7 +25,7 @@ export interface SseMemoryEvidenceCandidate { initial: Readonly; }; durationMs: number; - feed: Readonly; + feed: Readonly; process: { afterCleanup: Readonly; baseline: Readonly; @@ -39,7 +39,7 @@ export interface SseMemoryEvidenceCandidate { } /** Evidence emitted only after every policy assertion succeeds. */ -export interface SseMemoryQualificationEvidence extends SseMemoryEvidenceCandidate { +export interface SseMemoryEvidence extends SseMemoryEvidenceCandidate { memoryEventDelta: Readonly; verdict: "VALIDATED"; } @@ -104,7 +104,7 @@ const cgroupAncestorEvidenceSchema = v.strictObject({ baseline: v.array(cgroupAncestorSnapshotSchema), final: v.array(cgroupAncestorSnapshotSchema), }); -const qualificationEvidenceSchema = v.strictObject({ +const sseMemoryEvidenceSchema = v.strictObject({ cgroup: v.strictObject({ ancestors: cgroupAncestorEvidenceSchema, baseline: cgroupSnapshotSchema, @@ -165,14 +165,14 @@ function freezeProcessMemorySnapshot( /** * Reconstructs deeply immutable evidence after policy validation. - * @param candidate Raw qualification measurements. + * @param candidate Raw evidence measurements. * @param memoryEventDelta Validated monotonic memory-controller differences. * @returns Canonical immutable evidence. */ -export function canonicalizeSseMemoryQualificationEvidence( +export function canonicalizeSseMemoryEvidence( candidate: SseMemoryEvidenceCandidate, memoryEventDelta: Readonly -): Readonly { +): Readonly { const ancestors = Object.freeze({ baseline: freezeAncestorSnapshots(candidate.cgroup.ancestors.baseline), final: freezeAncestorSnapshots(candidate.cgroup.ancestors.final), @@ -212,8 +212,6 @@ export function canonicalizeSseMemoryQualificationEvidence( * @param value Serialized child-process evidence. * @returns Structurally valid, but not yet policy-validated, evidence. */ -export function parseSerializedSseMemoryQualificationEvidence( - value: string -): SseMemoryQualificationEvidence { - return v.parse(qualificationEvidenceSchema, JSON.parse(value) as unknown); +export function parseSerializedSseMemoryEvidence(value: string): SseMemoryEvidence { + return v.parse(sseMemoryEvidenceSchema, JSON.parse(value) as unknown); } diff --git a/qualification/resources/sseMemoryScenario.ts b/greenfield/src/test/integration/resources/sseMemoryScenario.ts similarity index 80% rename from qualification/resources/sseMemoryScenario.ts rename to greenfield/src/test/integration/resources/sseMemoryScenario.ts index 9b9080d34..7a73b5e68 100644 --- a/qualification/resources/sseMemoryScenario.ts +++ b/greenfield/src/test/integration/resources/sseMemoryScenario.ts @@ -1,12 +1,12 @@ import { Effect, Exit, Scope } from "effect"; -import { QualificationEventFeed } from "../realtime/eventFeed.ts"; -import { readRuntimeIdentity } from "../runtimeCandidate.ts"; -import { AsyncCleanupStack } from "../test/asyncCleanupStack.ts"; -import { waitFor } from "../test/waitFor.ts"; -import { startHttpsReverseProxy } from "../topology/httpsReverseProxy.ts"; -import { createTestTlsIdentity } from "../topology/testTlsIdentity.ts"; -import { startQualificationServer } from "../trpc/server.ts"; +import { AsyncCleanupStack } from "../../support/asyncCleanupStack.ts"; +import { waitFor } from "../../support/waitFor.ts"; +import { readRuntimeIdentity } from "../runtime/runtimeCandidate.ts"; +import { IntegrationEventFeed } from "../transport/realtime/eventFeed.ts"; +import { startHttpsReverseProxy } from "../transport/topology/httpsReverseProxy.ts"; +import { createTestTlsIdentity } from "../transport/topology/testTlsIdentity.ts"; +import { startIntegrationServer } from "../transport/trpc/server.ts"; import { readCurrentCgroupV2Snapshot } from "./cgroupV2.ts"; import { type CgroupV2AncestorSnapshot, @@ -22,19 +22,19 @@ import { import { assertCgroupAncestorResourcePolicy, assertCgroupResourcePolicy, - sseMemoryQualificationPolicy, + sseMemoryEvidencePolicy, } from "./resourcePolicy.ts"; import { - type SseMemoryQualificationEvidence, + type SseMemoryEvidence, type SseMemoryRoundEvidence, validateSseMemoryEvidence, } from "./sseMemoryEvidence.ts"; import { - currentQualificationUserId, + currentIntegrationUserId, expectedSseMemoryUnitCgroupPath, } from "./unitIdentity.ts"; -const qualificationCookie = "mira_qualification=slow-consumer"; +const evidenceCookie = "mira_evidence=slow-consumer"; function traceScenario(phase: string, startedAt: number): void { process.stderr.write( @@ -42,7 +42,7 @@ function traceScenario(phase: string, startedAt: number): void { ); } -function qualificationPayload(sequence: number, size: number): string { +function evidencePayload(sequence: number, size: number): string { const prefix = `${sequence.toString(36).padStart(12, "0")}:`; const seed = Array.from({ length: 128 }, (_value, index) => String.fromCodePoint(33 + ((sequence * 31 + index * 17) % 90)) @@ -52,11 +52,11 @@ function qualificationPayload(sequence: number, size: number): string { async function settleMemory(): Promise { Bun.gc(true); - await Bun.sleep(sseMemoryQualificationPolicy.scenario.stabilizationMs); + await Bun.sleep(sseMemoryEvidencePolicy.scenario.stabilizationMs); } async function waitForApplicationDisconnect( - eventFeed: QualificationEventFeed, + eventFeed: IntegrationEventFeed, expectedDrops: number, timeoutMs: number ): Promise { @@ -78,7 +78,7 @@ async function waitForApplicationDisconnect( } async function waitForTransportCleanup( - release: ReturnType, + release: ReturnType, proxy: ReturnType, timeoutMs: number ): Promise { @@ -112,16 +112,16 @@ function remainingRoundTime(deadline: number): number { */ export async function runSseMemoryScenario( unitName: string -): Promise> { +): Promise> { const initialCgroup = await readCurrentCgroupV2Snapshot(); const expectedCgroupPath = expectedSseMemoryUnitCgroupPath( - currentQualificationUserId(), + currentIntegrationUserId(), unitName ); assertCgroupResourcePolicy(initialCgroup, expectedCgroupPath); const startedAt = performance.now(); const cleanup = new AsyncCleanupStack(); - const eventFeed = new QualificationEventFeed(); + const eventFeed = new IntegrationEventFeed(); const roundEvidence: SseMemoryRoundEvidence[] = []; let sampledPeak: ProcessMemorySnapshot | undefined; let baselineProcess: ProcessMemorySnapshot | undefined; @@ -137,22 +137,21 @@ export async function runSseMemoryScenario( traceScenario("starting", startedAt); const tlsIdentity = await createTestTlsIdentity(); cleanup.defer("SSE memory TLS identity", () => tlsIdentity.dispose()); - const release = startQualificationServer({ + const release = startIntegrationServer({ eventFeed, hostname: "127.0.0.1", - maximumStreamDurationMs: - sseMemoryQualificationPolicy.scenario.maximumDurationMs, - releaseId: "sse-memory-qualification", - requiredCookie: qualificationCookie, + maximumStreamDurationMs: sseMemoryEvidencePolicy.scenario.maximumDurationMs, + releaseId: "sse-memory-evidence", + requiredCookie: evidenceCookie, requireSecureProxy: true, }); - cleanup.defer("SSE memory qualification release", () => release.stop(true)); + cleanup.defer("SSE memory evidence release", () => release.stop(true)); const proxy = startHttpsReverseProxy({ certificate: tlsIdentity.certificate, privateKey: tlsIdentity.privateKey, target: new URL(`http://127.0.0.1:${release.port}`), }); - cleanup.defer("SSE memory qualification proxy", () => proxy.stop(true)); + cleanup.defer("SSE memory evidence proxy", () => proxy.stop(true)); await settleMemory(); baselineCgroup = await readCurrentCgroupV2Snapshot(); baselineCgroupAncestors = await readCgroupV2AncestorSnapshots( @@ -163,7 +162,7 @@ export async function runSseMemoryScenario( sampledPeak = baselineProcess; const activeProcessSampler = startProcessMemorySampler( baselineProcess, - sseMemoryQualificationPolicy.scenario.processSampleIntervalMs + sseMemoryEvidencePolicy.scenario.processSampleIntervalMs ); cleanup.defer("SSE memory process sampler", () => { sampledPeak = activeProcessSampler.stop(); @@ -173,14 +172,14 @@ export async function runSseMemoryScenario( for ( let roundIndex = 0; - roundIndex < sseMemoryQualificationPolicy.scenario.rounds; + roundIndex < sseMemoryEvidencePolicy.scenario.rounds; roundIndex += 1 ) { traceScenario(`round-${roundIndex + 1}-starting`, startedAt); const roundStartedAt = performance.now(); const roundDeadline = roundStartedAt + - sseMemoryQualificationPolicy.scenario.roundDisconnectTimeoutMs; + sseMemoryEvidencePolicy.scenario.roundDisconnectTimeoutMs; const roundScope = await Effect.runPromise(Scope.make("parallel")); let roundScopeClosed = false; const closeRoundScope = async (): Promise => { @@ -189,20 +188,20 @@ export async function runSseMemoryScenario( await Effect.runPromise(Scope.close(roundScope, Exit.void)); }; const expectedDrops = - (roundIndex + 1) * sseMemoryQualificationPolicy.scenario.consumerCount; + (roundIndex + 1) * sseMemoryEvidencePolicy.scenario.consumerCount; let roundPublishedEvents = 0; try { for ( let consumerIndex = 0; - consumerIndex < sseMemoryQualificationPolicy.scenario.consumerCount; + consumerIndex < sseMemoryEvidencePolicy.scenario.consumerCount; consumerIndex += 1 ) { const timeoutMs = remainingRoundTime(roundDeadline); const consumerResource = pausedTlsSseClientResource( proxy.url, tlsIdentity.certificate, - qualificationCookie, + evidenceCookie, timeoutMs ); await Effect.runPromise(Scope.provide(roundScope)(consumerResource)); @@ -211,30 +210,30 @@ export async function runSseMemoryScenario( await waitFor( () => eventFeed.activeSubscriberCount === - sseMemoryQualificationPolicy.scenario.consumerCount, + sseMemoryEvidencePolicy.scenario.consumerCount, remainingRoundTime(roundDeadline) ); while ( roundPublishedEvents < - sseMemoryQualificationPolicy.scenario.maximumEventsPerRound && + sseMemoryEvidencePolicy.scenario.maximumEventsPerRound && eventFeed.metricsSnapshot().droppedSlowSubscribers < expectedDrops ) { const remaining = - sseMemoryQualificationPolicy.scenario.maximumEventsPerRound - + sseMemoryEvidencePolicy.scenario.maximumEventsPerRound - roundPublishedEvents; const batchSize = Math.min( remaining, - sseMemoryQualificationPolicy.scenario.publishBatchSize + sseMemoryEvidencePolicy.scenario.publishBatchSize ); for (let index = 0; index < batchSize; index += 1) { publishedEvents += 1; roundPublishedEvents += 1; eventFeed.publish({ - kind: "qualification.changed", - payload: qualificationPayload( + kind: "integration.changed", + payload: evidencePayload( publishedEvents, - sseMemoryQualificationPolicy.scenario.payloadBytes + sseMemoryEvidencePolicy.scenario.payloadBytes ), value: publishedEvents, }); diff --git a/qualification/resources/systemdLauncher.test.ts b/greenfield/src/test/integration/resources/systemdLauncher.test.ts similarity index 98% rename from qualification/resources/systemdLauncher.test.ts rename to greenfield/src/test/integration/resources/systemdLauncher.test.ts index 2c9f79a69..b5fec00ec 100644 --- a/qualification/resources/systemdLauncher.test.ts +++ b/greenfield/src/test/integration/resources/systemdLauncher.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { sseMemoryQualificationPolicy } from "./resourcePolicy.ts"; +import { sseMemoryEvidencePolicy } from "./resourcePolicy.ts"; import { buildSystemdLauncherCommand, buildSystemdRunSubprocessSpecification, @@ -20,7 +20,7 @@ const unitIdentifier = "019fcb3d-6cf6-7000-8000-000000000001"; function launcherOptions(): SystemdLauncherOptions { return { bunExecutable: "/opt/mira/bin/bun", - childEntrypoint: "/opt/mira/qualification/run.ts", + childEntrypoint: "/opt/mira/src/test/integration/run.ts", envExecutable: "/usr/bin/env", environment: { DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1001/bus", @@ -42,7 +42,7 @@ function launcherOptions(): SystemdLauncherOptions { describe("SSE memory systemd launcher", () => { test("builds an argv-only capped service with sanitized environments", () => { const command = buildSystemdLauncherCommand(launcherOptions()); - const policy = sseMemoryQualificationPolicy.cgroup; + const policy = sseMemoryEvidencePolicy.cgroup; expect(command.environment).toEqual({ DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1001/bus", @@ -77,7 +77,7 @@ describe("SSE memory systemd launcher", () => { "PATH=/usr/local/bin:/usr/bin:/bin", "TMPDIR=/tmp", "/opt/mira/bin/bun", - "/opt/mira/qualification/run.ts", + "/opt/mira/src/test/integration/run.ts", "--child", "--result=/tmp/mira-result/result.json", `--unit=${launcherOptions().unitName}`, diff --git a/qualification/resources/systemdLauncher.ts b/greenfield/src/test/integration/resources/systemdLauncher.ts similarity index 91% rename from qualification/resources/systemdLauncher.ts rename to greenfield/src/test/integration/resources/systemdLauncher.ts index ae63eeb02..2fc580c86 100644 --- a/qualification/resources/systemdLauncher.ts +++ b/greenfield/src/test/integration/resources/systemdLauncher.ts @@ -15,7 +15,7 @@ export { type SystemdLauncherDeadlineScheduler, type SystemdLauncherTermination, } from "./systemdLauncherDeadline.ts"; -export { runSystemdQualification } from "./systemdLauncherOrchestration.ts"; +export { runSystemdEvidence } from "./systemdLauncherOrchestration.ts"; export { ensureTransientUnitStopped, formatSystemdLauncherFailure, diff --git a/qualification/resources/systemdLauncherCommand.ts b/greenfield/src/test/integration/resources/systemdLauncherCommand.ts similarity index 95% rename from qualification/resources/systemdLauncherCommand.ts rename to greenfield/src/test/integration/resources/systemdLauncherCommand.ts index 12d6cd1a0..a6d6e2676 100644 --- a/qualification/resources/systemdLauncherCommand.ts +++ b/greenfield/src/test/integration/resources/systemdLauncherCommand.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import { sseMemoryQualificationPolicy } from "./resourcePolicy.ts"; +import { sseMemoryEvidencePolicy } from "./resourcePolicy.ts"; import { assertSseMemoryUnitName } from "./unitIdentity.ts"; const launcherEnvironmentNames = [ @@ -18,7 +18,7 @@ export const systemdLauncherProcessPolicy = Object.freeze({ systemctlTimeoutMs: 2000, }); -/** Absolute executables and paths used by one transient qualification unit. */ +/** Absolute executables and paths used by one transient evidence unit. */ export interface SystemdLauncherOptions { bunExecutable: string; childEntrypoint: string; @@ -68,7 +68,7 @@ function assertAbsolutePath(label: string, value: string): void { function childEnvironment(options: SystemdLauncherOptions): readonly string[] { const home = options.environment.HOME; if (!home) { - throw new Error("HOME is required to launch the SSE memory qualification"); + throw new Error("HOME is required to launch the SSE memory evidence"); } return [ "-i", @@ -108,7 +108,7 @@ export function buildSystemdLauncherCommand( return value === undefined ? [] : [[name, value]]; }) ); - const cgroup = sseMemoryQualificationPolicy.cgroup; + const cgroup = sseMemoryEvidencePolicy.cgroup; const argv = [ options.systemdRunExecutable, "--user", @@ -189,7 +189,7 @@ export function buildSystemdRunSubprocessSpecification( maxBuffer: systemdLauncherProcessPolicy.launcherOutputMaxBytes, stderr: "pipe" as const, stdout: "pipe" as const, - timeout: sseMemoryQualificationPolicy.cgroup.outerDeadlineMs, + timeout: sseMemoryEvidencePolicy.cgroup.outerDeadlineMs, }), }); } diff --git a/qualification/resources/systemdLauncherDeadline.ts b/greenfield/src/test/integration/resources/systemdLauncherDeadline.ts similarity index 97% rename from qualification/resources/systemdLauncherDeadline.ts rename to greenfield/src/test/integration/resources/systemdLauncherDeadline.ts index e85c2c60b..c30f06267 100644 --- a/qualification/resources/systemdLauncherDeadline.ts +++ b/greenfield/src/test/integration/resources/systemdLauncherDeadline.ts @@ -1,6 +1,6 @@ import * as v from "valibot"; -import { positiveSafeIntegerSchema } from "../../src/shared/validation.ts"; +import { positiveSafeIntegerSchema } from "../../../shared/validation.ts"; const systemdLauncherDeadlineSchema = positiveSafeIntegerSchema(); diff --git a/qualification/resources/systemdLauncherOrchestration.ts b/greenfield/src/test/integration/resources/systemdLauncherOrchestration.ts similarity index 84% rename from qualification/resources/systemdLauncherOrchestration.ts rename to greenfield/src/test/integration/resources/systemdLauncherOrchestration.ts index d6a9bc30d..f0a9856fe 100644 --- a/qualification/resources/systemdLauncherOrchestration.ts +++ b/greenfield/src/test/integration/resources/systemdLauncherOrchestration.ts @@ -15,12 +15,12 @@ import { } from "./systemdUnitControl.ts"; /** - * Runs a qualification child inside the reviewed transient cgroup. + * Runs an evidence-producing child inside the reviewed transient cgroup. * @param command Sanitised systemd launcher command. * @returns Captured launcher output and exit status. * @throws {Error} When the launcher exceeds its deadline, is signal-terminated, or cleanup fails. */ -export async function runSystemdQualification( +export async function runSystemdEvidence( command: SystemdLauncherCommand ): Promise { const specification = buildSystemdRunSubprocessSpecification(command); @@ -48,7 +48,7 @@ export async function runSystemdQualification( const diagnostic = await bestEffortSystemdPostMortem(command); throw new Error( formatSystemdLauncherFailure( - `SSE memory qualification launcher exceeded its ${deadlineMs} ms outer deadline and was terminated by ${termination.signalCode}`, + `SSE memory evidence launcher exceeded its ${deadlineMs} ms outer deadline and was terminated by ${termination.signalCode}`, stdoutText, stderrText, diagnostic @@ -59,7 +59,7 @@ export async function runSystemdQualification( const diagnostic = await bestEffortSystemdPostMortem(command); throw new Error( formatSystemdLauncherFailure( - `SSE memory qualification launcher was terminated by ${termination.signalCode} without the launcher-owned deadline signal; the output bound or an external signal may have stopped it`, + `SSE memory evidence launcher was terminated by ${termination.signalCode} without the launcher-owned deadline signal; the output bound or an external signal may have stopped it`, stdoutText, stderrText, diagnostic @@ -100,13 +100,13 @@ export async function runSystemdQualification( if (operationError !== undefined && cleanupError !== undefined) { throw new AggregateError( [operationError, cleanupError], - "SSE memory qualification and transient-unit cleanup failed" + "SSE memory evidence and transient-unit cleanup failed" ); } if (operationError !== undefined) { throw operationError instanceof Error ? operationError - : new Error("SSE memory qualification failed", { cause: operationError }); + : new Error("SSE memory evidence failed", { cause: operationError }); } if (cleanupError !== undefined) { throw cleanupError instanceof Error @@ -114,7 +114,7 @@ export async function runSystemdQualification( : new Error("Transient-unit cleanup failed", { cause: cleanupError }); } if (result === undefined) { - throw new Error("SSE memory qualification returned no launcher result"); + throw new Error("SSE memory evidence returned no launcher result"); } return result; } diff --git a/qualification/resources/systemdUnitControl.ts b/greenfield/src/test/integration/resources/systemdUnitControl.ts similarity index 100% rename from qualification/resources/systemdUnitControl.ts rename to greenfield/src/test/integration/resources/systemdUnitControl.ts diff --git a/qualification/resources/unitIdentity.test.ts b/greenfield/src/test/integration/resources/unitIdentity.test.ts similarity index 100% rename from qualification/resources/unitIdentity.test.ts rename to greenfield/src/test/integration/resources/unitIdentity.test.ts diff --git a/qualification/resources/unitIdentity.ts b/greenfield/src/test/integration/resources/unitIdentity.ts similarity index 74% rename from qualification/resources/unitIdentity.ts rename to greenfield/src/test/integration/resources/unitIdentity.ts index d219f3ef1..189d5ab50 100644 --- a/qualification/resources/unitIdentity.ts +++ b/greenfield/src/test/integration/resources/unitIdentity.ts @@ -2,7 +2,7 @@ import path from "node:path"; import * as v from "valibot"; -import { nonnegativeSafeIntegerSchema } from "../../src/shared/validation.ts"; +import { nonnegativeSafeIntegerSchema } from "../../../shared/validation.ts"; const unitIdentifierPattern = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/iu; const unitNamePattern = @@ -18,18 +18,18 @@ export function createSseMemoryUnitName( identifier: string = crypto.randomUUID() ): string { if (!unitIdentifierPattern.test(identifier)) { - throw new TypeError("SSE memory qualification unit identifier is invalid"); + throw new TypeError("SSE memory evidence unit identifier is invalid"); } return `mira-dashboard-sse-memory-${identifier}`; } /** - * Requires the exact unit-name grammar used by the capped qualification. + * Requires the exact unit-name grammar used by the capped evidence. * @param unitName Candidate transient unit name. */ export function assertSseMemoryUnitName(unitName: string): void { if (!unitNamePattern.test(unitName)) { - throw new TypeError("SSE memory qualification unit name is invalid"); + throw new TypeError("SSE memory evidence unit name is invalid"); } } @@ -44,7 +44,7 @@ export function assertSseMemoryUnitCgroupPath( ): void { if (cgroupPath !== expectedPath) { throw new Error( - `SSE memory qualification expected cgroup ${expectedPath}; observed ${cgroupPath}` + `SSE memory evidence expected cgroup ${expectedPath}; observed ${cgroupPath}` ); } } @@ -61,7 +61,7 @@ export function expectedSseMemoryUnitCgroupPath( ): string { assertSseMemoryUnitName(unitName); if (!v.safeParse(userIdSchema, userId).success) { - throw new TypeError("SSE memory qualification user ID is invalid"); + throw new TypeError("SSE memory evidence user ID is invalid"); } return path.posix.join( "/user.slice", @@ -73,12 +73,12 @@ export function expectedSseMemoryUnitCgroupPath( } /** - * Returns the POSIX user ID required by the Linux cgroup qualification. + * Returns the POSIX user ID required by the Linux cgroup evidence. * @returns Current POSIX user ID. */ -export function currentQualificationUserId(): number { +export function currentIntegrationUserId(): number { if (process.getuid === undefined) { - throw new Error("SSE memory qualification requires a POSIX user ID"); + throw new Error("SSE memory evidence requires a POSIX user ID"); } return process.getuid(); } diff --git a/qualification/runtimeCandidate.test.ts b/greenfield/src/test/integration/runtime/runtimeCandidate.test.ts similarity index 90% rename from qualification/runtimeCandidate.test.ts rename to greenfield/src/test/integration/runtime/runtimeCandidate.test.ts index 3b2ac3f9d..b40f52d85 100644 --- a/qualification/runtimeCandidate.test.ts +++ b/greenfield/src/test/integration/runtime/runtimeCandidate.test.ts @@ -5,7 +5,7 @@ import { bunRuntimePolicy, readRuntimeIdentity } from "./runtimeCandidate.ts"; describe("Bun runtime candidate", () => { test("executes on the repository's Bun 1.4 canary channel", async () => { const selectedChannel = await Bun.file( - new URL("../.bun-version", import.meta.url) + new URL("../../../../.bun-version", import.meta.url) ).text(); expect(selectedChannel.trim()).toBe(bunRuntimePolicy.channel); expect(readRuntimeIdentity()).toMatchObject({ diff --git a/qualification/runtimeCandidate.ts b/greenfield/src/test/integration/runtime/runtimeCandidate.ts similarity index 66% rename from qualification/runtimeCandidate.ts rename to greenfield/src/test/integration/runtime/runtimeCandidate.ts index 4bd640758..ee7003948 100644 --- a/qualification/runtimeCandidate.ts +++ b/greenfield/src/test/integration/runtime/runtimeCandidate.ts @@ -1,6 +1,6 @@ -export { bunRuntimePolicy } from "../src/shared/bunRuntimePolicy.ts"; +export { bunRuntimePolicy } from "../../../shared/bunRuntimePolicy.ts"; -/** Runtime properties relevant to the qualification suite. */ +/** Runtime properties relevant to the integration suite. */ export interface RuntimeIdentity { hasGlobalEventSource: boolean; revision: string; @@ -9,7 +9,7 @@ export interface RuntimeIdentity { /** * Reads identity and browser-API support from the executing Bun process. - * @returns The runtime identity observed by the qualification process. + * @returns The runtime identity observed by the integration process. */ export function readRuntimeIdentity(): RuntimeIdentity { return { diff --git a/qualification/shutdown/completeShutdownQualification.test.ts b/greenfield/src/test/integration/shutdown/completeShutdownScenario.test.ts similarity index 96% rename from qualification/shutdown/completeShutdownQualification.test.ts rename to greenfield/src/test/integration/shutdown/completeShutdownScenario.test.ts index 97e18c931..a00c784f6 100644 --- a/qualification/shutdown/completeShutdownQualification.test.ts +++ b/greenfield/src/test/integration/shutdown/completeShutdownScenario.test.ts @@ -6,13 +6,13 @@ import { TestClock } from "effect/testing"; import { cancelShutdownStreamBeforeDeadline, collectLinuxProcessGroupMembers, - completeShutdownQualification, - interruptedShutdownQualification, + completeShutdownScenario, + interruptedShutdownScenario, linuxProcessStatReadConcurrency, parseLinuxProcessStat, -} from "./completeShutdownQualification.ts"; +} from "./completeShutdownScenario.ts"; -describe("complete process shutdown qualification", () => { +describe("complete process shutdown scenario", () => { test("bounds a non-cooperative stream finalizer and continues older cleanup", async () => { const events: string[] = []; const program = Effect.gen(function* () { @@ -97,7 +97,7 @@ describe("complete process shutdown qualification", () => { }); test("drains readiness before resources and restarts with WAL recovery", async () => { - const report = await Effect.runPromise(completeShutdownQualification); + const report = await Effect.runPromise(completeShutdownScenario); expect(report.database).toEqual({ activeLeaseCount: 0, @@ -167,7 +167,7 @@ describe("complete process shutdown qualification", () => { }, 60_000); test("interrupts the owner scope without leaking its detached process group", async () => { - const report = await Effect.runPromise(interruptedShutdownQualification); + const report = await Effect.runPromise(interruptedShutdownScenario); expect(report.stoppedStatus.grandchildPid).toBeNumber(); expect(report.processGroupMembersWhileReady).toContain(report.stoppedStatus.pid); diff --git a/qualification/shutdown/completeShutdownQualification.ts b/greenfield/src/test/integration/shutdown/completeShutdownScenario.ts similarity index 89% rename from qualification/shutdown/completeShutdownQualification.ts rename to greenfield/src/test/integration/shutdown/completeShutdownScenario.ts index 08e0cd826..54fa81dfe 100644 --- a/qualification/shutdown/completeShutdownQualification.ts +++ b/greenfield/src/test/integration/shutdown/completeShutdownScenario.ts @@ -7,7 +7,7 @@ import { Data, Deferred, Duration, Effect, Fiber, Schedule, Scope } from "effect import * as v from "valibot"; import { - openShutdownQualificationDatabase, + openShutdownIntegrationDatabase, readShutdownDatabaseSnapshot, type ShutdownDatabaseSnapshot, } from "./shutdownDatabase.ts"; @@ -29,7 +29,7 @@ const statusPollingSchedule = Schedule.spaced("5 millis").pipe( Schedule.upTo({ times: 2000 }) ); -type QualificationServiceProcess = Bun.Subprocess<"ignore", "ignore", "ignore">; +type ShutdownScenarioServiceProcess = Bun.Subprocess<"ignore", "ignore", "ignore">; const applicationStateSchema = v.strictObject({ gatewaySocketOpen: v.boolean(), @@ -40,8 +40,8 @@ const applicationStateSchema = v.strictObject({ type ApplicationState = v.InferOutput; -export class CompleteShutdownQualificationError extends Data.TaggedError( - "CompleteShutdownQualificationError" +export class CompleteShutdownScenarioError extends Data.TaggedError( + "CompleteShutdownScenarioError" )<{ readonly cause?: unknown; readonly operation: string; @@ -72,7 +72,7 @@ export interface ShutdownGenerationEvidence { readonly stoppingReadinessStatus: number; } -export interface CompleteShutdownQualificationReport { +export interface CompleteShutdownScenarioReport { readonly database: ShutdownDatabaseSnapshot; readonly generations: readonly [ ShutdownGenerationEvidence, @@ -80,7 +80,7 @@ export interface CompleteShutdownQualificationReport { ]; } -export interface InterruptedShutdownQualificationReport { +export interface InterruptedShutdownScenarioReport { readonly processGroupMembersAfterInterruption: readonly number[]; readonly processGroupMembersWhileReady: readonly number[]; readonly stoppedStatus: ShutdownServiceStatus; @@ -131,11 +131,11 @@ function temporaryWorkspace() { return Effect.acquireRelease( Effect.tryPromise({ catch: (cause) => - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ cause, operation: "create-temporary-workspace", }), - try: () => mkdtemp(path.join(tmpdir(), "mira-shutdown-qualification-")), + try: () => mkdtemp(path.join(tmpdir(), "mira-shutdown-scenario-")), }), (workspacePath) => Effect.tryPromise(() => @@ -146,10 +146,10 @@ function temporaryWorkspace() { function writeMarker( markerPath: string -): Effect.Effect { +): Effect.Effect { return Effect.tryPromise({ catch: (cause) => - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ cause, operation: "write-control-marker", }), @@ -158,16 +158,12 @@ function writeMarker( } function awaitServiceExit( - child: QualificationServiceProcess, + child: ShutdownScenarioServiceProcess, operation: string -): Effect.Effect< - number, - CompleteShutdownDeadlineError | CompleteShutdownQualificationError -> { +): Effect.Effect { return withDeadline( Effect.tryPromise({ - catch: (cause) => - new CompleteShutdownQualificationError({ cause, operation }), + catch: (cause) => new CompleteShutdownScenarioError({ cause, operation }), try: () => child.exited, }), operation @@ -183,7 +179,7 @@ function killProcessGroup(processGroupId: number): void { } function stopServiceProcess( - child: QualificationServiceProcess, + child: ShutdownScenarioServiceProcess, acknowledgePath: string ): Effect.Effect { if (child.exitCode !== null || child.signalCode !== null) { @@ -215,14 +211,14 @@ function serviceProcessResource(options: { readonly generation: number; readonly statusPath: string; }): Effect.Effect< - QualificationServiceProcess, - CompleteShutdownQualificationError, + ShutdownScenarioServiceProcess, + CompleteShutdownScenarioError, Scope.Scope > { return Effect.acquireRelease( Effect.try({ catch: (cause) => - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ cause, operation: "spawn-shutdown-service", }), @@ -260,7 +256,7 @@ function readStatus( const statusFile = Bun.file(statusPath); if (!(await statusFile.exists())) throw new Error("status pending"); if (statusFile.size > statusMaximumBytes) { - throw new Error("status exceeds qualification bound"); + throw new Error("status exceeds scenario bound"); } const value: unknown = JSON.parse(await statusFile.text()); const status = parseShutdownServiceStatus(value); @@ -284,15 +280,14 @@ function fetchResponse( operation: string ): Effect.Effect< Response, - CompleteShutdownDeadlineError | CompleteShutdownQualificationError, + CompleteShutdownDeadlineError | CompleteShutdownScenarioError, Scope.Scope > { return Effect.gen(function* () { const signal = yield* Effect.abortSignal; const response = yield* withDeadline( Effect.tryPromise({ - catch: (cause) => - new CompleteShutdownQualificationError({ cause, operation }), + catch: (cause) => new CompleteShutdownScenarioError({ cause, operation }), try: () => fetch(url, { signal }), }), operation @@ -311,7 +306,7 @@ function readApplicationState( baseUrl: string ): Effect.Effect< ApplicationState, - CompleteShutdownDeadlineError | CompleteShutdownQualificationError, + CompleteShutdownDeadlineError | CompleteShutdownScenarioError, Scope.Scope > { return Effect.gen(function* () { @@ -321,32 +316,40 @@ function readApplicationState( ); if (!response.ok) { return yield* Effect.fail( - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ operation: "application-state-status", }) ); } const value = yield* Effect.tryPromise({ catch: (cause) => - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ cause, operation: "parse-application-state", }), - try: () => response.json() as Promise, + try: () => response.json(), }); return v.parse(applicationStateSchema, value); }); } +interface SseReader { + cancel(): Promise; + read(): Promise<{ + readonly done: boolean; + readonly value?: Uint8Array; + }>; +} + interface SseConnection { - readonly reader: ReadableStreamDefaultReader; + readonly reader: SseReader; } function sseConnectionResource( baseUrl: string ): Effect.Effect< SseConnection, - CompleteShutdownDeadlineError | CompleteShutdownQualificationError, + CompleteShutdownDeadlineError | CompleteShutdownScenarioError, Scope.Scope > { return Effect.acquireRelease( @@ -361,19 +364,19 @@ function sseConnectionResource( response.body === null ) { return yield* Effect.fail( - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ operation: "validate-sse-connection", }) ); } - const reader = response.body.getReader(); + const reader: SseReader = response.body.getReader(); const cancelReader = cancelShutdownStreamBeforeDeadline(() => reader.cancel() ); const first = yield* withDeadline( Effect.tryPromise({ catch: (cause) => - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ cause, operation: "read-sse-opening-event", }), @@ -388,7 +391,7 @@ function sseConnectionResource( ) { yield* cancelReader; return yield* Effect.fail( - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ operation: "validate-sse-opening-event", }) ); @@ -401,14 +404,11 @@ function sseConnectionResource( function awaitSseClosed( connection: SseConnection -): Effect.Effect< - boolean, - CompleteShutdownDeadlineError | CompleteShutdownQualificationError -> { +): Effect.Effect { return withDeadline( Effect.tryPromise({ catch: (cause) => - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ cause, operation: "await-sse-close", }), @@ -455,7 +455,7 @@ export function collectLinuxProcessGroupMembers( candidateProcessIds: readonly number[], processGroupId: number, readProcessStat: (processId: number) => Effect.Effect -): Effect.Effect { +): Effect.Effect { return Effect.forEach( candidateProcessIds, (processId) => @@ -465,7 +465,7 @@ export function collectLinuxProcessGroupMembers( ? Effect.succeed(null) : Effect.try({ catch: (cause) => - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ cause, operation: "parse-linux-process-stat", }), @@ -491,11 +491,11 @@ export function collectLinuxProcessGroupMembers( export function readLinuxProcessGroupMembers( processGroupId: number -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const entries = yield* Effect.tryPromise({ catch: (cause) => - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ cause, operation: "inspect-linux-process-group", }), @@ -510,7 +510,7 @@ export function readLinuxProcessGroupMembers( (processId) => Effect.tryPromise({ catch: (cause) => - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ cause, operation: "read-linux-process-stat", }), @@ -533,7 +533,7 @@ function runGeneration( generation: number ): Effect.Effect< ShutdownGenerationEvidence, - CompleteShutdownDeadlineError | CompleteShutdownQualificationError + CompleteShutdownDeadlineError | CompleteShutdownScenarioError > { const prefix = path.join(workspacePath, `generation-${generation}`); const statusPath = `${prefix}.status.json`; @@ -572,7 +572,7 @@ function runGeneration( ); if (readyReadiness.status !== 200) { return yield* Effect.fail( - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ operation: "ready-readiness-status", }) ); @@ -582,7 +582,7 @@ function runGeneration( yield* idleHttpConnectionResource(baseUrl).pipe( Effect.mapError( (cause) => - new CompleteShutdownQualificationError({ + new CompleteShutdownScenarioError({ cause, operation: "hold-idle-http-connection", }) @@ -636,13 +636,13 @@ function runGeneration( function databaseSnapshotResource(databasePath: string) { return Effect.acquireRelease( - Effect.sync(() => openShutdownQualificationDatabase(databasePath)), + Effect.sync(() => openShutdownIntegrationDatabase(databasePath)), (database) => Effect.sync(() => database.close(true)) ); } /** Runs two production-shaped process generations against one WAL database. */ -export const completeShutdownQualification = Effect.scoped( +export const completeShutdownScenario = Effect.scoped( Effect.gen(function* () { const workspacePath = yield* temporaryWorkspace(); const databasePath = path.join(workspacePath, "shutdown.sqlite"); @@ -658,7 +658,7 @@ export const completeShutdownQualification = Effect.scoped( ); /** Proves that interrupting the owning Effect scope releases the full process tree. */ -export const interruptedShutdownQualification = Effect.scoped( +export const interruptedShutdownScenario = Effect.scoped( Effect.gen(function* () { const workspacePath = yield* temporaryWorkspace(); const databasePath = path.join(workspacePath, "interrupted.sqlite"); diff --git a/greenfield/src/test/integration/shutdown/runCompleteShutdownEvidence.ts b/greenfield/src/test/integration/shutdown/runCompleteShutdownEvidence.ts new file mode 100644 index 000000000..788203d2f --- /dev/null +++ b/greenfield/src/test/integration/shutdown/runCompleteShutdownEvidence.ts @@ -0,0 +1,6 @@ +import { Effect } from "effect"; + +import { completeShutdownScenario } from "./completeShutdownScenario.ts"; + +const report = await Effect.runPromise(completeShutdownScenario); +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/qualification/shutdown/shutdownDatabase.ts b/greenfield/src/test/integration/shutdown/shutdownDatabase.ts similarity index 93% rename from qualification/shutdown/shutdownDatabase.ts rename to greenfield/src/test/integration/shutdown/shutdownDatabase.ts index 384baffa5..ead2a5f6d 100644 --- a/qualification/shutdown/shutdownDatabase.ts +++ b/greenfield/src/test/integration/shutdown/shutdownDatabase.ts @@ -51,7 +51,7 @@ const schemaStatements = [ ) STRICT`, ] as const; -export function openShutdownQualificationDatabase(databasePath: string): Database { +export function openShutdownIntegrationDatabase(databasePath: string): Database { const database = new Database(databasePath, { create: true, readwrite: true, @@ -64,7 +64,7 @@ export function openShutdownQualificationDatabase(databasePath: string): Databas .get()?.journal_mode; if (journalMode?.toLowerCase() !== "wal") { database.close(true); - throw new Error("Shutdown qualification database did not enter WAL mode"); + throw new Error("Shutdown integration database did not enter WAL mode"); } database.run("PRAGMA synchronous = NORMAL"); database.run("PRAGMA wal_autocheckpoint = 0"); @@ -86,7 +86,7 @@ export function startShutdownGeneration( ) .get()?.count; if (activeLeaseCount !== 0) { - throw new Error("A prior shutdown qualification lease remains active"); + throw new Error("A prior shutdown integration lease remains active"); } const recoveredGenerationCount = database .query( @@ -94,7 +94,7 @@ export function startShutdownGeneration( ) .get()?.count; if (recoveredGenerationCount === undefined) { - throw new Error("Shutdown qualification generation count is unavailable"); + throw new Error("Shutdown integration generation count is unavailable"); } database .query( @@ -135,7 +135,7 @@ export function releaseShutdownWorkerLease( ) .run(timestamp, generation); if (result.changes !== 1) { - throw new Error("Shutdown qualification worker lease was not released once"); + throw new Error("Shutdown integration worker lease was not released once"); } } @@ -176,7 +176,7 @@ export function completeShutdownGeneration( checkpoint.busy !== 0 || checkpoint.checkpointed !== checkpoint.log ) { - throw new Error("Shutdown qualification WAL checkpoint did not complete"); + throw new Error("Shutdown integration WAL checkpoint did not complete"); } } diff --git a/qualification/shutdown/shutdownGrandchild.ts b/greenfield/src/test/integration/shutdown/shutdownGrandchild.ts similarity index 100% rename from qualification/shutdown/shutdownGrandchild.ts rename to greenfield/src/test/integration/shutdown/shutdownGrandchild.ts diff --git a/qualification/shutdown/shutdownIdleHttpConnection.ts b/greenfield/src/test/integration/shutdown/shutdownIdleHttpConnection.ts similarity index 99% rename from qualification/shutdown/shutdownIdleHttpConnection.ts rename to greenfield/src/test/integration/shutdown/shutdownIdleHttpConnection.ts index 6ca5dc0c1..652a6e518 100644 --- a/qualification/shutdown/shutdownIdleHttpConnection.ts +++ b/greenfield/src/test/integration/shutdown/shutdownIdleHttpConnection.ts @@ -165,7 +165,7 @@ function acquireIdleHttpConnection( /** * Holds one completed HTTP/1.1 keep-alive connection across listener shutdown. - * @param baseUrl Running qualification listener URL. + * @param baseUrl Running integration listener URL. * @returns A scoped connection that remains open until release or remote shutdown. */ export function idleHttpConnectionResource( diff --git a/qualification/shutdown/shutdownProtocol.ts b/greenfield/src/test/integration/shutdown/shutdownProtocol.ts similarity index 88% rename from qualification/shutdown/shutdownProtocol.ts rename to greenfield/src/test/integration/shutdown/shutdownProtocol.ts index a35de7caf..cbc52140e 100644 --- a/qualification/shutdown/shutdownProtocol.ts +++ b/greenfield/src/test/integration/shutdown/shutdownProtocol.ts @@ -65,11 +65,11 @@ const gatewayConnectAuthSchema = v.strictObject({ token: v.literal("shutdown-fixture-token"), }); const gatewayConnectClientSchema = v.strictObject({ - displayName: v.literal("Mira shutdown qualification"), + displayName: v.literal("Mira shutdown integration"), id: v.literal("gateway-client"), mode: v.literal("cli"), platform: v.literal("linux"), - version: v.literal("qualification"), + version: v.literal("integration"), }); const gatewayConnectParametersSchema = v.strictObject({ auth: gatewayConnectAuthSchema, @@ -81,7 +81,7 @@ const gatewayConnectParametersSchema = v.strictObject({ scopes: gatewayOperatorReadScopesSchema, }); const gatewayConnectRequestSchema = v.strictObject({ - id: v.literal("shutdown-qualification-connect"), + id: v.literal("shutdown-integration-connect"), method: v.literal("connect"), params: gatewayConnectParametersSchema, type: v.literal("req"), @@ -92,7 +92,7 @@ const gatewayHelloAuthSchema = v.strictObject({ scopes: gatewayOperatorReadScopesSchema, }); const gatewayHelloServerSchema = v.strictObject({ - connId: v.literal("shutdown-qualification"), + connId: v.literal("shutdown-integration"), version: v.literal("2026.7.2-beta.7"), }); const gatewayHelloSnapshotSchema = v.strictObject({ @@ -106,7 +106,7 @@ const gatewayHelloPayloadSchema = v.strictObject({ type: v.literal("hello-ok"), }); const gatewayHelloResponseSchema = v.strictObject({ - id: v.literal("shutdown-qualification-connect"), + id: v.literal("shutdown-integration-connect"), ok: v.literal(true), payload: gatewayHelloPayloadSchema, type: v.literal("res"), @@ -114,7 +114,7 @@ const gatewayHelloResponseSchema = v.strictObject({ function parseBoundedJson(text: string, maximumBytes: number): unknown { if (Buffer.byteLength(text, "utf8") > maximumBytes) { - throw new Error("Shutdown qualification Gateway frame exceeded its bound"); + throw new Error("Shutdown integration Gateway frame exceeded its bound"); } return JSON.parse(text) as unknown; } @@ -133,19 +133,19 @@ export function parseGatewayHelloResponse(text: string) { export function createGatewayConnectRequest(nonce: string) { if (nonce.length === 0 || nonce.length > 128) { - throw new Error("Shutdown qualification Gateway nonce is invalid"); + throw new Error("Shutdown integration Gateway nonce is invalid"); } return { - id: "shutdown-qualification-connect" as const, + id: "shutdown-integration-connect" as const, method: "connect" as const, params: { auth: { token: "shutdown-fixture-token" as const }, client: { - displayName: "Mira shutdown qualification" as const, + displayName: "Mira shutdown integration" as const, id: "gateway-client" as const, mode: "cli" as const, platform: "linux" as const, - version: "qualification" as const, + version: "integration" as const, }, maxProtocol: 4 as const, minProtocol: 4 as const, @@ -159,7 +159,7 @@ export function createGatewayConnectRequest(nonce: string) { export function createGatewayHelloResponse() { return { - id: "shutdown-qualification-connect" as const, + id: "shutdown-integration-connect" as const, ok: true as const, payload: { auth: { @@ -168,7 +168,7 @@ export function createGatewayHelloResponse() { }, protocol: 4 as const, server: { - connId: "shutdown-qualification" as const, + connId: "shutdown-integration" as const, version: "2026.7.2-beta.7" as const, }, snapshot: { authMode: "token" as const }, diff --git a/qualification/shutdown/shutdownService.ts b/greenfield/src/test/integration/shutdown/shutdownService.ts similarity index 93% rename from qualification/shutdown/shutdownService.ts rename to greenfield/src/test/integration/shutdown/shutdownService.ts index 00c55716f..6a91e13c3 100644 --- a/qualification/shutdown/shutdownService.ts +++ b/greenfield/src/test/integration/shutdown/shutdownService.ts @@ -6,7 +6,7 @@ import * as v from "valibot"; import { acquireShutdownWorkerLease, completeShutdownGeneration, - openShutdownQualificationDatabase, + openShutdownIntegrationDatabase, releaseShutdownWorkerLease, startShutdownGeneration, } from "./shutdownDatabase.ts"; @@ -21,7 +21,7 @@ import { gatewaySocketResource, grandchildProcessResource, shutdownSignalResource, - ShutdownQualificationResourceError, + ShutdownIntegrationResourceError, writeShutdownStatus, } from "./shutdownServiceResources.ts"; @@ -35,8 +35,8 @@ const serviceCommandSchema = v.strictObject({ type ServiceCommand = v.InferOutput; -class ShutdownQualificationArgumentError extends Data.TaggedError( - "ShutdownQualificationArgumentError" +class ShutdownIntegrationArgumentError extends Data.TaggedError( + "ShutdownIntegrationArgumentError" )<{ readonly message: string; }> {} @@ -58,8 +58,8 @@ function parseCommand(arguments_: readonly string[]): ServiceCommand { statusPath, }); } catch { - throw new ShutdownQualificationArgumentError({ - message: "Invalid shutdown qualification service arguments", + throw new ShutdownIntegrationArgumentError({ + message: "Invalid shutdown integration service arguments", }); } } @@ -68,11 +68,11 @@ function databaseResource(databasePath: string) { return Effect.acquireRelease( Effect.try({ catch: (cause) => - new ShutdownQualificationResourceError({ + new ShutdownIntegrationResourceError({ cause, operation: "open-database", }), - try: () => openShutdownQualificationDatabase(databasePath), + try: () => openShutdownIntegrationDatabase(databasePath), }), (database) => Effect.sync(() => database.close(true)) ); @@ -125,7 +125,7 @@ function preparedStatementResource( const row = statement.get(generation); if (row?.generation !== generation) { statement.finalize(); - throw new Error("Shutdown qualification prepared statement failed"); + throw new Error("Shutdown integration prepared statement failed"); } return statement; }), @@ -190,7 +190,7 @@ function runService(command: ServiceCommand) { const snapshot = (phase: ShutdownServiceStatus["phase"]): ShutdownServiceStatus => { if (application === undefined) { - throw new Error("Shutdown qualification application is unavailable"); + throw new Error("Shutdown integration application is unavailable"); } return statusSnapshot({ application, @@ -309,8 +309,6 @@ try { 0, 16 * 1024 ); - process.stderr.write( - `Complete-shutdown qualification service failed\n${diagnostic}\n` - ); + process.stderr.write(`Complete-shutdown integration service failed\n${diagnostic}\n`); process.exitCode = 1; } diff --git a/qualification/shutdown/shutdownServiceResources.test.ts b/greenfield/src/test/integration/shutdown/shutdownServiceResources.test.ts similarity index 93% rename from qualification/shutdown/shutdownServiceResources.test.ts rename to greenfield/src/test/integration/shutdown/shutdownServiceResources.test.ts index 2c9c92e91..66c3f5387 100644 --- a/qualification/shutdown/shutdownServiceResources.test.ts +++ b/greenfield/src/test/integration/shutdown/shutdownServiceResources.test.ts @@ -8,14 +8,14 @@ import { } from "./shutdownProtocol.ts"; import { applicationServerResource, - ShutdownQualificationDeadlineError, - ShutdownQualificationResourceError, + ShutdownIntegrationDeadlineError, + ShutdownIntegrationResourceError, stopApplicationListener, } from "./shutdownServiceResources.ts"; describe("shutdown application listener policy", () => { test("binds the Gateway connect request to its challenge nonce", () => { - const nonce = "qualification-challenge"; + const nonce = "integration-challenge"; const request = parseGatewayConnectRequest( JSON.stringify(createGatewayConnectRequest(nonce)) ); @@ -76,7 +76,7 @@ describe("shutdown application listener policy", () => { (error: unknown) => error ); - expect(failure).toBeInstanceOf(ShutdownQualificationDeadlineError); + expect(failure).toBeInstanceOf(ShutdownIntegrationDeadlineError); expect(stopCalls).toEqual([false, true]); }); @@ -96,10 +96,8 @@ describe("shutdown application listener policy", () => { (error: unknown) => error ); - expect(failure).toBeInstanceOf(ShutdownQualificationResourceError); - expect((failure as ShutdownQualificationResourceError).cause).toBe( - gracefulFailure - ); + expect(failure).toBeInstanceOf(ShutdownIntegrationResourceError); + expect((failure as ShutdownIntegrationResourceError).cause).toBe(gracefulFailure); expect(stopCalls).toEqual([false, true]); }); diff --git a/qualification/shutdown/shutdownServiceResources.ts b/greenfield/src/test/integration/shutdown/shutdownServiceResources.ts similarity index 87% rename from qualification/shutdown/shutdownServiceResources.ts rename to greenfield/src/test/integration/shutdown/shutdownServiceResources.ts index 4c68efe23..44109af8d 100644 --- a/qualification/shutdown/shutdownServiceResources.ts +++ b/greenfield/src/test/integration/shutdown/shutdownServiceResources.ts @@ -34,17 +34,17 @@ const grandchildModulePath = path.join( "shutdownGrandchild.ts" ); -type QualificationChildProcess = Bun.Subprocess<"ignore", "ignore", "ignore">; +type ShutdownIntegrationChildProcess = Bun.Subprocess<"ignore", "ignore", "ignore">; -export class ShutdownQualificationResourceError extends Data.TaggedError( - "ShutdownQualificationResourceError" +export class ShutdownIntegrationResourceError extends Data.TaggedError( + "ShutdownIntegrationResourceError" )<{ readonly cause?: unknown; readonly operation: string; }> {} -export class ShutdownQualificationDeadlineError extends Data.TaggedError( - "ShutdownQualificationDeadlineError" +export class ShutdownIntegrationDeadlineError extends Data.TaggedError( + "ShutdownIntegrationDeadlineError" )<{ readonly operation: string; }> {} @@ -54,13 +54,13 @@ class ShutdownMarkerPendingError extends Data.TaggedError("ShutdownMarkerPending }> {} function deadlineFailure(operation: string) { - return new ShutdownQualificationDeadlineError({ operation }); + return new ShutdownIntegrationDeadlineError({ operation }); } function withDeadline( effect: Effect.Effect, operation: string -): Effect.Effect { +): Effect.Effect { return effect.pipe( Effect.timeoutOrElse({ duration: operationDeadline, @@ -72,7 +72,7 @@ function withDeadline( export function awaitMarkerFile( markerPath: string, operation: string -): Effect.Effect { +): Effect.Effect { const attempt = Effect.tryPromise({ catch: () => new ShutdownMarkerPendingError({ operation }), try: async () => { @@ -95,11 +95,11 @@ export function awaitMarkerFile( export function writeShutdownStatus( statusPath: string, status: ShutdownServiceStatus -): Effect.Effect { +): Effect.Effect { const temporaryPath = `${statusPath}.${process.pid}.tmp`; return Effect.tryPromise({ catch: (cause) => - new ShutdownQualificationResourceError({ cause, operation: "write-status" }), + new ShutdownIntegrationResourceError({ cause, operation: "write-status" }), try: async () => { await Bun.write(temporaryPath, `${JSON.stringify(status)}\n`); await rename(temporaryPath, statusPath); @@ -167,12 +167,12 @@ export function stopApplicationListener( policy: ApplicationListenerStopPolicy = defaultApplicationListenerStopPolicy ): Effect.Effect< "forced" | "graceful", - ShutdownQualificationDeadlineError | ShutdownQualificationResourceError + ShutdownIntegrationDeadlineError | ShutdownIntegrationResourceError > { const stopServer = (force: boolean) => Effect.tryPromise({ catch: (cause) => - new ShutdownQualificationResourceError({ + new ShutdownIntegrationResourceError({ cause, operation: force ? "force-stop-application-listener" @@ -219,7 +219,7 @@ export interface ShutdownApplicationServer { readonly sseConnectionCount: number; close(): Effect.Effect< "forced" | "graceful", - ShutdownQualificationDeadlineError | ShutdownQualificationResourceError + ShutdownIntegrationDeadlineError | ShutdownIntegrationResourceError >; } @@ -227,14 +227,14 @@ export function applicationServerResource( state: ShutdownApplicationState ): Effect.Effect< ShutdownApplicationServer, - ShutdownQualificationResourceError, + ShutdownIntegrationResourceError, Scope.Scope > { return Effect.acquireRelease( Effect.gen(function* () { const listener = yield* Effect.try({ catch: (cause) => - new ShutdownQualificationResourceError({ + new ShutdownIntegrationResourceError({ cause, operation: "start-application-listener", }), @@ -297,7 +297,7 @@ export function applicationServerResource( if (server.port === undefined) { void server.stop(true); throw new Error( - "Shutdown qualification listener has no bound port" + "Shutdown integration listener has no bound port" ); } return { controllers, port: server.port, server }; @@ -328,23 +328,23 @@ export function applicationServerResource( } interface GatewayFixtureSocketData { - readonly qualification: true; + readonly integration: true; } export interface GatewayFixtureServer { readonly url: string; - close(): Effect.Effect; + close(): Effect.Effect; } export function gatewayFixtureResource(): Effect.Effect< GatewayFixtureServer, - ShutdownQualificationResourceError, + ShutdownIntegrationResourceError, Scope.Scope > { return Effect.acquireRelease( Effect.try({ catch: (cause) => - new ShutdownQualificationResourceError({ + new ShutdownIntegrationResourceError({ cause, operation: "start-gateway-fixture", }), @@ -353,7 +353,7 @@ export function gatewayFixtureResource(): Effect.Effect< const server = Bun.serve({ fetch(request, bunServer) { return bunServer.upgrade(request, { - data: { qualification: true }, + data: { integration: true }, }) ? undefined : new Response("WebSocket upgrade required", { @@ -371,8 +371,7 @@ export function gatewayFixtureResource(): Effect.Effect< : message.toString("utf8") ); if ( - request.params.nonce !== - "shutdown-qualification-nonce" + request.params.nonce !== "shutdown-integration-nonce" ) { throw new Error( "Gateway connect request did not echo its challenge" @@ -388,7 +387,7 @@ export function gatewayFixtureResource(): Effect.Effect< JSON.stringify({ event: "connect.challenge", payload: { - nonce: "shutdown-qualification-nonce", + nonce: "shutdown-integration-nonce", ts: 1_786_000_000_000, }, type: "event", @@ -404,7 +403,7 @@ export function gatewayFixtureResource(): Effect.Effect< closePromise ??= server.stop(true); return Effect.tryPromise({ catch: (cause) => - new ShutdownQualificationResourceError({ + new ShutdownIntegrationResourceError({ cause, operation: "stop-gateway-fixture", }), @@ -423,9 +422,9 @@ function openGatewaySocket( url: string ): Effect.Effect< WebSocket, - ShutdownQualificationDeadlineError | ShutdownQualificationResourceError + ShutdownIntegrationDeadlineError | ShutdownIntegrationResourceError > { - const connection = Effect.callback( + const connection = Effect.callback( (resume) => { const socket = new WebSocket(url); let connectSent = false; @@ -447,10 +446,10 @@ function openGatewaySocket( if (settled) return; settled = true; removeListeners(); - closeSocket("qualification handshake failed"); + closeSocket("integration handshake failed"); resume( Effect.fail( - new ShutdownQualificationResourceError({ cause, operation }) + new ShutdownIntegrationResourceError({ cause, operation }) ) ); }; @@ -485,7 +484,7 @@ function openGatewaySocket( socket.addEventListener("message", onMessage); return Effect.sync(() => { removeListeners(); - closeSocket("qualification interrupted"); + closeSocket("integration interrupted"); }); } ); @@ -504,7 +503,7 @@ function closeGatewaySocket(socket: WebSocket): Effect.Effect { socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN ) { - socket.close(1000, "qualification shutdown"); + socket.close(1000, "integration shutdown"); } return Effect.sync(() => socket.removeEventListener("close", onClose)); }); @@ -513,7 +512,7 @@ function closeGatewaySocket(socket: WebSocket): Effect.Effect { duration: "2 seconds", orElse: () => Effect.die( - new Error("Shutdown qualification Gateway socket did not close") + new Error("Shutdown integration Gateway socket did not close") ), }) ); @@ -523,30 +522,29 @@ export function gatewaySocketResource( url: string ): Effect.Effect< WebSocket, - ShutdownQualificationDeadlineError | ShutdownQualificationResourceError, + ShutdownIntegrationDeadlineError | ShutdownIntegrationResourceError, Scope.Scope > { return Effect.acquireRelease(openGatewaySocket(url), closeGatewaySocket); } function awaitChildExit( - child: QualificationChildProcess, + child: ShutdownIntegrationChildProcess, operation: string ): Effect.Effect< number, - ShutdownQualificationDeadlineError | ShutdownQualificationResourceError + ShutdownIntegrationDeadlineError | ShutdownIntegrationResourceError > { return withDeadline( Effect.tryPromise({ - catch: (cause) => - new ShutdownQualificationResourceError({ cause, operation }), + catch: (cause) => new ShutdownIntegrationResourceError({ cause, operation }), try: () => child.exited, }), operation ); } -function stopGrandchild(child: QualificationChildProcess): Effect.Effect { +function stopGrandchild(child: ShutdownIntegrationChildProcess): Effect.Effect { if (child.exitCode !== null || child.signalCode !== null) return Effect.void; return Effect.sync(() => child.kill("SIGTERM")).pipe( Effect.andThen(awaitChildExit(child, "stop-grandchild")), @@ -563,14 +561,14 @@ function stopGrandchild(child: QualificationChildProcess): Effect.Effect { } export function grandchildProcessResource(): Effect.Effect< - QualificationChildProcess, - ShutdownQualificationResourceError, + ShutdownIntegrationChildProcess, + ShutdownIntegrationResourceError, Scope.Scope > { return Effect.acquireRelease( Effect.try({ catch: (cause) => - new ShutdownQualificationResourceError({ + new ShutdownIntegrationResourceError({ cause, operation: "start-grandchild", }), diff --git a/qualification/realtime/eventFeed.test.ts b/greenfield/src/test/integration/transport/realtime/eventFeed.test.ts similarity index 75% rename from qualification/realtime/eventFeed.test.ts rename to greenfield/src/test/integration/transport/realtime/eventFeed.test.ts index d8457f6bc..de6a344fe 100644 --- a/qualification/realtime/eventFeed.test.ts +++ b/greenfield/src/test/integration/transport/realtime/eventFeed.test.ts @@ -1,31 +1,31 @@ import { describe, expect, test } from "bun:test"; -import { waitFor } from "../test/waitFor.ts"; -import { QualificationEventFeed, qualificationEventLimits } from "./eventFeed.ts"; +import { waitFor } from "../../../support/waitFor.ts"; +import { IntegrationEventFeed, integrationEventLimits } from "./eventFeed.ts"; -describe("qualification event feed", () => { +describe("integration event feed", () => { test("enforces the exact UTF-8 payload budget before advancing the feed", () => { - const eventFeed = new QualificationEventFeed(); + const eventFeed = new IntegrationEventFeed(); const maximumMultibytePayload = "é".repeat( - qualificationEventLimits.maximumPayloadBytes / 2 + integrationEventLimits.maximumPayloadBytes / 2 ); const acceptedEvent = eventFeed.publish({ - kind: "qualification.changed", + kind: "integration.changed", payload: maximumMultibytePayload, value: 1, }); - expect(Object.isFrozen(qualificationEventLimits)).toBeTrue(); + expect(Object.isFrozen(integrationEventLimits)).toBeTrue(); expect(acceptedEvent.data.payload).toBe(maximumMultibytePayload); expect(acceptedEvent.id).toBe("1"); expect(() => eventFeed.publish({ - kind: "qualification.changed", + kind: "integration.changed", payload: `${maximumMultibytePayload}a`, value: 2, }) ).toThrow( - `Qualification event payload exceeds ${qualificationEventLimits.maximumPayloadBytes} UTF-8 bytes` + `Integration event payload exceeds ${integrationEventLimits.maximumPayloadBytes} UTF-8 bytes` ); expect(eventFeed.metricsSnapshot()).toEqual({ activeSubscribers: 0, @@ -38,10 +38,10 @@ describe("qualification event feed", () => { }); test("joins replay and live delivery without a gap", async () => { - const eventFeed = new QualificationEventFeed(); + const eventFeed = new IntegrationEventFeed(); const abortController = new AbortController(); - eventFeed.publish({ kind: "qualification.changed", value: 1 }); - eventFeed.publish({ kind: "qualification.changed", value: 2 }); + eventFeed.publish({ kind: "integration.changed", value: 1 }); + eventFeed.publish({ kind: "integration.changed", value: 2 }); const subscription = eventFeed.subscribe({ afterId: "1", @@ -54,7 +54,7 @@ describe("qualification event feed", () => { expect(replayEvent.value.id).toBe("2"); const liveEvent = subscription.next(); - eventFeed.publish({ kind: "qualification.changed", value: 3 }); + eventFeed.publish({ kind: "integration.changed", value: 3 }); const deliveredLiveEvent = await liveEvent; if (deliveredLiveEvent.done) { throw new Error("Live subscription ended before returning an event"); @@ -68,12 +68,12 @@ describe("qualification event feed", () => { }); test("keeps the replay snapshot stable when retention advances", async () => { - const eventFeed = new QualificationEventFeed(); + const eventFeed = new IntegrationEventFeed(); const abortController = new AbortController(); - const retainedEventCount = qualificationEventLimits.maximumRetainedEvents; + const retainedEventCount = integrationEventLimits.maximumRetainedEvents; for (let value = 1; value <= retainedEventCount; value += 1) { - eventFeed.publish({ kind: "qualification.changed", value }); + eventFeed.publish({ kind: "integration.changed", value }); } const subscription = eventFeed.subscribe({ signal: abortController.signal }); @@ -84,12 +84,12 @@ describe("qualification event feed", () => { expect(firstReplayEvent.value.id).toBe("1"); eventFeed.publish({ - kind: "qualification.changed", + kind: "integration.changed", value: retainedEventCount + 1, }); expect(eventFeed.metricsSnapshot()).toMatchObject({ latestSequence: retainedEventCount + 1, - retainedEvents: qualificationEventLimits.maximumRetainedEvents, + retainedEvents: integrationEventLimits.maximumRetainedEvents, }); const secondReplayEvent = await subscription.next(); @@ -107,7 +107,7 @@ describe("qualification event feed", () => { }); test("rejects a resume cursor ahead of the feed tail", async () => { - const eventFeed = new QualificationEventFeed(); + const eventFeed = new IntegrationEventFeed(); const subscription = eventFeed.subscribe({ afterId: "100", signal: new AbortController().signal, @@ -121,14 +121,14 @@ describe("qualification event feed", () => { } expect(resumeError).toBeInstanceOf(Error); expect((resumeError as Error).message).toBe( - "Qualification event resume cursor is ahead of feed tail" + "Integration event resume cursor is ahead of feed tail" ); expect(eventFeed.activeSubscriberCount).toBe(0); }); test("rejects a malformed or oversized resume cursor", async () => { for (const afterId of ["01", "-1", "9".repeat(10_000)]) { - const eventFeed = new QualificationEventFeed(); + const eventFeed = new IntegrationEventFeed(); const subscription = eventFeed.subscribe({ afterId, signal: new AbortController().signal, @@ -137,22 +137,22 @@ describe("qualification event feed", () => { expect( await subscription.next().catch((error: unknown) => error) ).toMatchObject({ - message: "Qualification event resume cursor is invalid", + message: "Integration event resume cursor is invalid", }); expect(eventFeed.activeSubscriberCount).toBe(0); } }); test("accepts a resume cursor at the feed tail", async () => { - const eventFeed = new QualificationEventFeed(); + const eventFeed = new IntegrationEventFeed(); const abortController = new AbortController(); - eventFeed.publish({ kind: "qualification.changed", value: 1 }); + eventFeed.publish({ kind: "integration.changed", value: 1 }); const subscription = eventFeed.subscribe({ afterId: "1", signal: abortController.signal, }); const nextEvent = subscription.next(); - eventFeed.publish({ kind: "qualification.changed", value: 2 }); + eventFeed.publish({ kind: "integration.changed", value: 2 }); const deliveredEvent = await nextEvent; if (deliveredEvent.done) { @@ -167,25 +167,25 @@ describe("qualification event feed", () => { }); test("fails and detaches a subscriber that exceeds its queue budget", async () => { - const eventFeed = new QualificationEventFeed(); + const eventFeed = new IntegrationEventFeed(); const abortController = new AbortController(); const subscription = eventFeed.subscribe({ signal: abortController.signal }); const firstEvent = subscription.next(); - const maximumPayload = "a".repeat(qualificationEventLimits.maximumPayloadBytes); + const maximumPayload = "a".repeat(integrationEventLimits.maximumPayloadBytes); const overflowEventCount = - qualificationEventLimits.maximumSubscriberQueueEvents + 2; + integrationEventLimits.maximumSubscriberQueueEvents + 2; await waitFor(() => eventFeed.activeSubscriberCount === 1); for (let value = 1; value <= overflowEventCount; value += 1) { eventFeed.publish({ - kind: "qualification.changed", + kind: "integration.changed", payload: maximumPayload, value, }); } await waitFor(() => eventFeed.activeSubscriberCount === 0); eventFeed.publish({ - kind: "qualification.changed", + kind: "integration.changed", payload: maximumPayload, value: overflowEventCount + 1, }); @@ -204,16 +204,16 @@ describe("qualification event feed", () => { } expect(overflowError).toBeInstanceOf(Error); expect((overflowError as Error).message).toBe( - "Qualification event subscriber exceeded its queue budget" + "Integration event subscriber exceeded its queue budget" ); expect(eventFeed.activeSubscriberCount).toBe(0); const metrics = eventFeed.metricsSnapshot(); expect(metrics.droppedSlowSubscribers).toBe(1); expect(metrics.maximumObservedQueueDepth).toBe( - qualificationEventLimits.maximumSubscriberQueueEvents + integrationEventLimits.maximumSubscriberQueueEvents ); expect(metrics.maximumObservedQueuedPayloadBytes).toBe( - qualificationEventLimits.maximumSubscriberQueuedPayloadBytes + integrationEventLimits.maximumSubscriberQueuedPayloadBytes ); }); }); diff --git a/qualification/realtime/eventFeed.ts b/greenfield/src/test/integration/transport/realtime/eventFeed.ts similarity index 66% rename from qualification/realtime/eventFeed.ts rename to greenfield/src/test/integration/transport/realtime/eventFeed.ts index e74b482f5..daaba76d4 100644 --- a/qualification/realtime/eventFeed.ts +++ b/greenfield/src/test/integration/transport/realtime/eventFeed.ts @@ -1,36 +1,36 @@ import * as v from "valibot"; -import { BoundedAsyncQueue } from "../../src/server/platform/realtime/boundedAsyncQueue.ts"; -import { utf8ByteLength } from "../../src/shared/encoding.ts"; -import { nonnegativeDecimalSafeIntegerStringSchema } from "../../src/shared/validation.ts"; +import { BoundedAsyncQueue } from "../../../../server/platform/realtime/boundedAsyncQueue.ts"; +import { utf8ByteLength } from "../../../../shared/encoding.ts"; +import { nonnegativeDecimalSafeIntegerStringSchema } from "../../../../shared/validation.ts"; -/** Data carried by the qualification event stream. */ -export interface QualificationEventData { - readonly kind: "qualification.changed"; +/** Data carried by the integration event stream. */ +export interface IntegrationEventData { + readonly kind: "integration.changed"; readonly payload?: string; readonly value: number; } /** A durable-style event record with a monotonically increasing string ID. */ -export interface QualificationEventRecord { - readonly data: QualificationEventData; +export interface IntegrationEventRecord { + readonly data: IntegrationEventData; readonly id: string; } -const maximumQualificationPayloadBytes = 8 * 1024; -const maximumQualificationSubscriberQueueEvents = 16; +const maximumIntegrationPayloadBytes = 8 * 1024; +const maximumIntegrationSubscriberQueueEvents = 16; -/** Fixed event and subscriber budgets used by the qualification feed. */ -export const qualificationEventLimits = Object.freeze({ - maximumPayloadBytes: maximumQualificationPayloadBytes, +/** Fixed event and subscriber budgets used by the integration feed. */ +export const integrationEventLimits = Object.freeze({ + maximumPayloadBytes: maximumIntegrationPayloadBytes, maximumRetainedEvents: 128, - maximumSubscriberQueueEvents: maximumQualificationSubscriberQueueEvents, + maximumSubscriberQueueEvents: maximumIntegrationSubscriberQueueEvents, maximumSubscriberQueuedPayloadBytes: - maximumQualificationSubscriberQueueEvents * maximumQualificationPayloadBytes, + maximumIntegrationSubscriberQueueEvents * maximumIntegrationPayloadBytes, }); -/** Point-in-time operational measurements for a qualification event feed. */ -export interface QualificationEventFeedMetrics { +/** Point-in-time operational measurements for an integration event feed. */ +export interface IntegrationEventFeedMetrics { readonly activeSubscribers: number; readonly droppedSlowSubscribers: number; readonly latestSequence: number; @@ -44,28 +44,27 @@ interface EventSubscriptionOptions { signal: AbortSignal; } -interface StoredQualificationEvent { +interface StoredIntegrationEvent { readonly payloadBytes: number; - readonly record: QualificationEventRecord; + readonly record: IntegrationEventRecord; } -const queueBudgetErrorMessage = - "Qualification event subscriber exceeded its queue budget"; -const payloadBudgetErrorMessage = `Qualification event payload exceeds ${qualificationEventLimits.maximumPayloadBytes} UTF-8 bytes`; +const queueBudgetErrorMessage = "Integration event subscriber exceeded its queue budget"; +const payloadBudgetErrorMessage = `Integration event payload exceeds ${integrationEventLimits.maximumPayloadBytes} UTF-8 bytes`; /** * Checks an event payload against the shared UTF-8 byte budget. * @param payload Payload text to measure. * @returns Whether the encoded payload fits within the event budget. */ -export function isQualificationEventPayloadWithinLimit(payload: string): boolean { - return utf8ByteLength(payload) <= qualificationEventLimits.maximumPayloadBytes; +export function isIntegrationEventPayloadWithinLimit(payload: string): boolean { + return utf8ByteLength(payload) <= integrationEventLimits.maximumPayloadBytes; } -/** In-memory qualification model for tracked replay and bounded live delivery. */ -export class QualificationEventFeed { - readonly #events: StoredQualificationEvent[] = []; - readonly #subscribers = new Set<(event: StoredQualificationEvent) => void>(); +/** In-memory integration model for tracked replay and bounded live delivery. */ +export class IntegrationEventFeed { + readonly #events: StoredIntegrationEvent[] = []; + readonly #subscribers = new Set<(event: StoredIntegrationEvent) => void>(); readonly observedResumeIds: Array = []; #droppedSlowSubscribers = 0; #maximumObservedQueueDepth = 0; @@ -84,7 +83,7 @@ export class QualificationEventFeed { * Captures current counters and bounded-queue high-water marks. * @returns An immutable metrics snapshot. */ - metricsSnapshot(): Readonly { + metricsSnapshot(): Readonly { return Object.freeze({ activeSubscribers: this.#subscribers.size, droppedSlowSubscribers: this.#droppedSlowSubscribers, @@ -100,9 +99,9 @@ export class QualificationEventFeed { * @param data Event payload. * @returns The appended event record. */ - publish(data: QualificationEventData): QualificationEventRecord { + publish(data: IntegrationEventData): IntegrationEventRecord { const payloadBytes = utf8ByteLength(data.payload ?? ""); - if (payloadBytes > qualificationEventLimits.maximumPayloadBytes) { + if (payloadBytes > integrationEventLimits.maximumPayloadBytes) { throw new RangeError(payloadBudgetErrorMessage); } @@ -110,10 +109,10 @@ export class QualificationEventFeed { const record = Object.freeze({ data: eventData, id: String(++this.#sequence), - } satisfies QualificationEventRecord); + } satisfies IntegrationEventRecord); const event = Object.freeze({ payloadBytes, record }); this.#events.push(event); - if (this.#events.length > qualificationEventLimits.maximumRetainedEvents) { + if (this.#events.length > integrationEventLimits.maximumRetainedEvents) { this.#events.shift(); } for (const subscriber of this.#subscribers) { @@ -125,35 +124,35 @@ export class QualificationEventFeed { /** * Replays records after a cursor and then follows the live stream without a race gap. * @param options Resume cursor and request cancellation signal. - * @yields {QualificationEventRecord} Ordered qualification events after the supplied + * @yields {IntegrationEventRecord} Ordered integration events after the supplied * cursor. */ async *subscribe( options: EventSubscriptionOptions - ): AsyncGenerator { + ): AsyncGenerator { const afterSequence = parseResumeSequence(options.afterId); this.observedResumeIds.push(options.afterId); const replayBoundary = this.#sequence; if (afterSequence > replayBoundary) { - throw new Error("Qualification event resume cursor is ahead of feed tail"); + throw new Error("Integration event resume cursor is ahead of feed tail"); } const firstRetainedSequence = Number( this.#events.at(0)?.record.id ?? this.#sequence + 1 ); if (afterSequence > 0 && afterSequence < firstRetainedSequence - 1) { - throw new Error("Qualification event resume cursor is outside retention"); + throw new Error("Integration event resume cursor is outside retention"); } const replayEvents = [...this.#events]; - const queue = new BoundedAsyncQueue({ - maximumEvents: qualificationEventLimits.maximumSubscriberQueueEvents, + const queue = new BoundedAsyncQueue({ + maximumEvents: integrationEventLimits.maximumSubscriberQueueEvents, maximumPayloadBytes: - qualificationEventLimits.maximumSubscriberQueuedPayloadBytes, + integrationEventLimits.maximumSubscriberQueuedPayloadBytes, overflowErrorMessage: queueBudgetErrorMessage, }); - const subscriber = (event: StoredQualificationEvent): void => { + const subscriber = (event: StoredIntegrationEvent): void => { if (Number(event.record.id) <= replayBoundary) { return; } @@ -208,7 +207,7 @@ export class QualificationEventFeed { } const resumeSequenceSchema = nonnegativeDecimalSafeIntegerStringSchema( - "Qualification event resume cursor is invalid" + "Integration event resume cursor is invalid" ); function parseResumeSequence(resumeId: string | undefined): number { @@ -218,7 +217,7 @@ function parseResumeSequence(resumeId: string | undefined): number { const result = v.safeParse(resumeSequenceSchema, resumeId, { abortEarly: true }); if (!result.success) { - throw new Error("Qualification event resume cursor is invalid"); + throw new Error("Integration event resume cursor is invalid"); } return result.output; } diff --git a/qualification/topology/httpsReverseProxy.test.ts b/greenfield/src/test/integration/transport/topology/httpsReverseProxy.test.ts similarity index 93% rename from qualification/topology/httpsReverseProxy.test.ts rename to greenfield/src/test/integration/transport/topology/httpsReverseProxy.test.ts index 3110a2f3e..9f8cf3af4 100644 --- a/qualification/topology/httpsReverseProxy.test.ts +++ b/greenfield/src/test/integration/transport/topology/httpsReverseProxy.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { AsyncCleanupStack } from "../test/asyncCleanupStack.ts"; -import { waitFor } from "../test/waitFor.ts"; +import { AsyncCleanupStack } from "../../../support/asyncCleanupStack.ts"; +import { waitFor } from "../../../support/waitFor.ts"; import { startHttpsReverseProxy } from "./httpsReverseProxy.ts"; import { createTestTlsIdentity } from "./testTlsIdentity.ts"; import { createTrustedFetch } from "./trustedFetch.ts"; @@ -13,19 +13,19 @@ async function startProxyHarness( upstreamFetch: UpstreamFetch ) { const tlsIdentity = await createTestTlsIdentity(); - cleanup.defer("qualification TLS identity", () => tlsIdentity.dispose()); + cleanup.defer("integration TLS identity", () => tlsIdentity.dispose()); const upstream = Bun.serve({ fetch: upstreamFetch, hostname: "127.0.0.1", port: 0, }); - cleanup.defer("qualification proxy upstream", () => upstream.stop(true)); + cleanup.defer("integration proxy upstream", () => upstream.stop(true)); const proxy = startHttpsReverseProxy({ certificate: tlsIdentity.certificate, privateKey: tlsIdentity.privateKey, target: new URL(`http://127.0.0.1:${upstream.port}`), }); - cleanup.defer("qualification HTTPS proxy", () => proxy.stop(true)); + cleanup.defer("integration HTTPS proxy", () => proxy.stop(true)); return { proxy, @@ -35,7 +35,7 @@ async function startProxyHarness( }; } -describe("qualification HTTPS reverse proxy", () => { +describe("integration HTTPS reverse proxy", () => { test("cancels an upstream request before response headers arrive", async () => { const cleanup = new AsyncCleanupStack(); let upstreamRequestAborted = false; @@ -70,7 +70,7 @@ describe("qualification HTTPS reverse proxy", () => { }).catch(() => null); await waitFor(() => upstreamRequestStarted); - abortController.abort(new Error("Qualification client disconnected")); + abortController.abort(new Error("Integration client disconnected")); await waitFor(() => upstreamRequestAborted); await pendingRequest; diff --git a/qualification/topology/httpsReverseProxy.ts b/greenfield/src/test/integration/transport/topology/httpsReverseProxy.ts similarity index 87% rename from qualification/topology/httpsReverseProxy.ts rename to greenfield/src/test/integration/transport/topology/httpsReverseProxy.ts index 4f07bf9f1..92b270c69 100644 --- a/qualification/topology/httpsReverseProxy.ts +++ b/greenfield/src/test/integration/transport/topology/httpsReverseProxy.ts @@ -1,14 +1,17 @@ import { createProxyResponseBody, + type ProxyDownstreamStreamErrorMode, type ProxyResponseBodyChunkBoundary, stripHopByHopHeaders, } from "./proxyTransport.ts"; -/** TLS and loopback target for one qualification reverse proxy. */ +/** TLS and loopback target for one integration reverse proxy. */ export interface HttpsReverseProxyOptions { certificate: string; + /** Optional opaque downstream failure mode for intentional reconnect scenarios. */ + downstreamStreamErrorMode?: ProxyDownstreamStreamErrorMode; privateKey: string; - /** Optional qualification-test synchronization after forwarding each response chunk. */ + /** Optional scenario synchronization after forwarding each response chunk. */ responseBodyChunkBoundary?: ProxyResponseBodyChunkBoundary; target: URL; } @@ -100,7 +103,11 @@ export function startHttpsReverseProxy(options: HttpsReverseProxyOptions) { upstream.body, upstreamController, detachRequestAbort, - options.responseBodyChunkBoundary + { + chunkBoundary: options.responseBodyChunkBoundary, + downstreamStreamErrorMode: + options.downstreamStreamErrorMode, + } ), { headers: downstreamHeaders, @@ -115,7 +122,7 @@ export function startHttpsReverseProxy(options: HttpsReverseProxyOptions) { return new Response(null, { status: 499 }); } upstreamUnavailableCount += 1; - return new Response("Qualification upstream unavailable", { + return new Response("Integration upstream unavailable", { status: 503, }); } diff --git a/qualification/topology/proxyTransport.test.ts b/greenfield/src/test/integration/transport/topology/proxyTransport.test.ts similarity index 61% rename from qualification/topology/proxyTransport.test.ts rename to greenfield/src/test/integration/transport/topology/proxyTransport.test.ts index 50a99da0f..575c8e145 100644 --- a/qualification/topology/proxyTransport.test.ts +++ b/greenfield/src/test/integration/transport/topology/proxyTransport.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createProxyResponseBody, stripHopByHopHeaders } from "./proxyTransport.ts"; -describe("qualification proxy transport", () => { +describe("integration proxy transport", () => { test("strips fixed and Connection-nominated headers", () => { const source = new Headers({ connection: " X-Hop,\tx-second, , X-HOP, close ", @@ -46,15 +46,15 @@ describe("qualification proxy transport", () => { ); }); - test("propagates an upstream body failure without normal EOF", async () => { - const expectedError = new Error("Qualification upstream body failed"); + test("propagates the exact upstream body failure by default without normal EOF", async () => { + const expectedError = new Error("Integration upstream body failed"); let upstreamBody: | ReadableStreamDefaultController> | undefined; const upstream = new ReadableStream>({ start(controller) { upstreamBody = controller; - controller.enqueue(new TextEncoder().encode("qualification chunk")); + controller.enqueue(new TextEncoder().encode("integration chunk")); }, }); const upstreamController = new AbortController(); @@ -66,11 +66,11 @@ describe("qualification proxy transport", () => { const first = await reader.read(); if (first.done) { - throw new Error("Qualification proxy stream ended before first chunk"); + throw new Error("Integration proxy stream ended before first chunk"); } - expect(new TextDecoder().decode(first.value)).toBe("qualification chunk"); + expect(new TextDecoder().decode(first.value)).toBe("integration chunk"); if (upstreamBody === undefined) { - throw new Error("Qualification upstream body controller was missing"); + throw new Error("Integration upstream body controller was missing"); } upstreamBody.error(expectedError); @@ -87,6 +87,52 @@ describe("qualification proxy transport", () => { expect(detachCount).toBe(1); }); + test("can hide an upstream failure reason from the downstream stream", async () => { + const expectedError = new Error("Integration upstream body failed opaquely"); + let upstreamBody: + | ReadableStreamDefaultController> + | undefined; + const upstream = new ReadableStream>({ + start(controller) { + upstreamBody = controller; + controller.enqueue(new TextEncoder().encode("integration chunk")); + }, + }); + const upstreamController = new AbortController(); + let detachCount = 0; + const downstream = createProxyResponseBody( + upstream, + upstreamController, + () => { + detachCount += 1; + }, + { downstreamStreamErrorMode: "opaque" } + ); + const reader = downstream.getReader(); + + const first = await reader.read(); + if (first.done) { + throw new Error("Integration proxy stream ended before first chunk"); + } + if (upstreamBody === undefined) { + throw new Error("Integration upstream body controller was missing"); + } + + upstreamBody.error(expectedError); + const downstreamOutcome = await reader.read().then( + (value) => ({ status: "fulfilled" as const, value }), + (error: unknown) => ({ reason: error, status: "rejected" as const }) + ); + + expect(downstreamOutcome).toEqual({ + reason: undefined, + status: "rejected", + }); + expect(upstreamController.signal.aborted).toBeTrue(); + expect(upstreamController.signal.reason).toBe(expectedError); + expect(detachCount).toBe(1); + }); + test("holds the next upstream read at an explicit forwarded-chunk boundary", async () => { const encoder = new TextEncoder(); const upstream = new ReadableStream({ @@ -103,12 +149,14 @@ describe("qualification proxy transport", () => { upstream, new AbortController(), () => {}, - async () => { - boundaryCalls += 1; - if (boundaryCalls === 1) { - boundaryReached.resolve(); - await releaseBoundary.promise; - } + { + async chunkBoundary() { + boundaryCalls += 1; + if (boundaryCalls === 1) { + boundaryReached.resolve(); + await releaseBoundary.promise; + } + }, } ); const reader = downstream.getReader(); diff --git a/qualification/topology/proxyTransport.ts b/greenfield/src/test/integration/transport/topology/proxyTransport.ts similarity index 77% rename from qualification/topology/proxyTransport.ts rename to greenfield/src/test/integration/transport/topology/proxyTransport.ts index f0b278740..ade5bfe53 100644 --- a/qualification/topology/proxyTransport.ts +++ b/greenfield/src/test/integration/transport/topology/proxyTransport.ts @@ -15,6 +15,15 @@ const httpTokenPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; /** Optional synchronization boundary after one upstream response chunk is forwarded. */ export type ProxyResponseBodyChunkBoundary = (chunk: Uint8Array) => Promise | void; +/** Controls whether downstream consumers receive the exact upstream stream error. */ +export type ProxyDownstreamStreamErrorMode = "exact" | "opaque"; + +/** Optional behavior for one proxied response body. */ +export interface ProxyResponseBodyOptions { + chunkBoundary?: ProxyResponseBodyChunkBoundary; + downstreamStreamErrorMode?: ProxyDownstreamStreamErrorMode; +} + function trimOptionalWhitespace(value: string): string { return value.replaceAll(/^[\t ]+|[\t ]+$/g, ""); } @@ -52,14 +61,14 @@ export function stripHopByHopHeaders(source: Headers): Headers { * @param body Upstream response body. * @param upstreamController Controller for the upstream request. * @param detachRequestAbort Removes the downstream abort listener. - * @param chunkBoundary Optional synchronization boundary after forwarding each chunk. + * @param options Optional synchronization and downstream error behavior. * @returns A downstream response stream. */ export function createProxyResponseBody( body: ReadableStream, upstreamController: AbortController, detachRequestAbort: () => void, - chunkBoundary?: ProxyResponseBodyChunkBoundary + options: ProxyResponseBodyOptions = {} ): ReadableStream { const reader = body.getReader(); @@ -78,8 +87,8 @@ export function createProxyResponseBody( return; } controller.enqueue(next.value); - if (chunkBoundary !== undefined) { - await chunkBoundary(next.value); + if (options.chunkBoundary !== undefined) { + await options.chunkBoundary(next.value); } } catch (error) { detachRequestAbort(); @@ -87,7 +96,11 @@ export function createProxyResponseBody( return; } upstreamController.abort(error); - controller.error(error); + if (options.downstreamStreamErrorMode === "opaque") { + controller.error(); + } else { + controller.error(error); + } } }, }); diff --git a/qualification/topology/releaseReadiness.ts b/greenfield/src/test/integration/transport/topology/releaseReadiness.ts similarity index 66% rename from qualification/topology/releaseReadiness.ts rename to greenfield/src/test/integration/transport/topology/releaseReadiness.ts index 80031149a..e89a1d5c8 100644 --- a/qualification/topology/releaseReadiness.ts +++ b/greenfield/src/test/integration/transport/topology/releaseReadiness.ts @@ -1,17 +1,17 @@ -/** Public readiness state for one qualification release. */ -export interface QualificationReadinessSnapshot { +/** Public readiness state for one integration release. */ +export interface IntegrationReadinessSnapshot { releaseId: string; status: "not-ready" | "ready"; } -/** Explicit readiness lifecycle used by rolling-release qualification servers. */ -export class QualificationReleaseReadiness { +/** Explicit readiness lifecycle used by rolling-release integration servers. */ +export class IntegrationReleaseReadiness { readonly #releaseId: string; #phase: "ready" | "starting" | "stopping" = "starting"; constructor(releaseId: string) { if (releaseId.length === 0) { - throw new Error("Qualification release ID must not be empty"); + throw new Error("Integration release ID must not be empty"); } this.#releaseId = releaseId; } @@ -22,10 +22,10 @@ export class QualificationReleaseReadiness { */ markReady(expectedReleaseId: string): void { if (expectedReleaseId !== this.#releaseId) { - throw new Error("Cannot mark an unexpected qualification release ready"); + throw new Error("Cannot mark an unexpected integration release ready"); } if (this.#phase === "stopping") { - throw new Error("Cannot mark a stopping qualification release ready"); + throw new Error("Cannot mark a stopping integration release ready"); } this.#phase = "ready"; } @@ -39,7 +39,7 @@ export class QualificationReleaseReadiness { * Returns the public readiness projection for this release. * @returns Current public readiness state and release identity. */ - snapshot(): QualificationReadinessSnapshot { + snapshot(): IntegrationReadinessSnapshot { return { releaseId: this.#releaseId, status: this.#phase === "ready" ? "ready" : "not-ready", diff --git a/qualification/topology/rollingReleaseSse.test.ts b/greenfield/src/test/integration/transport/topology/rollingReleaseSse.test.ts similarity index 79% rename from qualification/topology/rollingReleaseSse.test.ts rename to greenfield/src/test/integration/transport/topology/rollingReleaseSse.test.ts index c3bd2bc39..740e44572 100644 --- a/qualification/topology/rollingReleaseSse.test.ts +++ b/greenfield/src/test/integration/transport/topology/rollingReleaseSse.test.ts @@ -1,22 +1,22 @@ import { describe, expect, test } from "bun:test"; -import { QualificationEventFeed } from "../realtime/eventFeed.ts"; -import { AsyncCleanupStack } from "../test/asyncCleanupStack.ts"; -import { waitFor } from "../test/waitFor.ts"; -import { createQualificationClient } from "../trpc/client.ts"; -import { startQualificationServer } from "../trpc/server.ts"; +import { AsyncCleanupStack } from "../../../support/asyncCleanupStack.ts"; +import { waitFor } from "../../../support/waitFor.ts"; +import { IntegrationEventFeed } from "../realtime/eventFeed.ts"; +import { createIntegrationClient } from "../trpc/client.ts"; +import { startIntegrationServer } from "../trpc/server.ts"; import { startHttpsReverseProxy } from "./httpsReverseProxy.ts"; import { createTestTlsIdentity } from "./testTlsIdentity.ts"; import { createTrustedFetch } from "./trustedFetch.ts"; -const qualificationCookie = "mira_qualification=trusted-session"; +const scenarioCookie = "mira_scenario=trusted-session"; function createProxiedClient(url: URL, certificateAuthority: string) { const trustedFetch = createTrustedFetch({ certificateAuthority, - cookie: qualificationCookie, + cookie: scenarioCookie, }); - return createQualificationClient({ + return createIntegrationClient({ eventSourceOptions: { fetch: trustedFetch, withCredentials: true, @@ -34,23 +34,26 @@ describe("production-shaped HTTPS and rolling-release SSE topology", () => { try { const tlsIdentity = await createTestTlsIdentity(); - cleanup.defer("qualification TLS identity", () => tlsIdentity.dispose()); - const eventFeed = new QualificationEventFeed(); - const releaseA = startQualificationServer({ + cleanup.defer("rolling-release scenario TLS identity", () => + tlsIdentity.dispose() + ); + const eventFeed = new IntegrationEventFeed(); + const releaseA = startIntegrationServer({ eventFeed, hostname: "127.0.0.1", releaseId: "release-a", - requiredCookie: qualificationCookie, + requiredCookie: scenarioCookie, requireSecureProxy: true, }); - cleanup.defer("qualification release A", () => releaseA.stop(true)); + cleanup.defer("rolling-release scenario A", () => releaseA.stop(true)); const releasePort = releaseA.port; const proxy = startHttpsReverseProxy({ certificate: tlsIdentity.certificate, + downstreamStreamErrorMode: "opaque", privateKey: tlsIdentity.privateKey, target: new URL(`http://127.0.0.1:${releasePort}`), }); - cleanup.defer("qualification proxy", () => proxy.stop(true)); + cleanup.defer("rolling-release scenario proxy", () => proxy.stop(true)); const publicFetch = createTrustedFetch({ certificateAuthority: tlsIdentity.certificate, }); @@ -81,7 +84,7 @@ describe("production-shaped HTTPS and rolling-release SSE topology", () => { expect(headUnavailable.status).toBe(503); expect(await headUnavailable.text()).toBe(""); expect(() => releaseA.readiness.markReady("release-b")).toThrow( - "Cannot mark an unexpected qualification release ready" + "Cannot mark an unexpected integration release ready" ); releaseA.readiness.markReady("release-a"); @@ -98,7 +101,7 @@ describe("production-shaped HTTPS and rolling-release SSE topology", () => { expect(unauthorized.status).toBe(401); const bypassAttempt = await fetch( new URL("/trpc/runtime.identity", releaseA.url), - { headers: { cookie: qualificationCookie } } + { headers: { cookie: scenarioCookie } } ); expect(bypassAttempt.status).toBe(400); const releaseAIdentity = await client.runtime.identity.query(); @@ -118,12 +121,12 @@ describe("production-shaped HTTPS and rolling-release SSE topology", () => { }, } ); - cleanup.defer("qualification subscription", () => { + cleanup.defer("rolling-release scenario subscription", () => { subscription?.unsubscribe(); }); await waitFor(() => startedCount === 1); await client.events.publish.mutate({ - kind: "qualification.changed", + kind: "integration.changed", value: 1, }); await waitFor(() => receivedIds.length === 1); @@ -136,18 +139,18 @@ describe("production-shaped HTTPS and rolling-release SSE topology", () => { }); await waitFor(() => eventFeed.activeSubscriberCount === 0); - eventFeed.publish({ kind: "qualification.changed", value: 2 }); + eventFeed.publish({ kind: "integration.changed", value: 2 }); await waitFor(() => proxy.upstreamUnavailableCount >= 1, 5000); - const releaseB = startQualificationServer({ + const releaseB = startIntegrationServer({ eventFeed, hostname: "127.0.0.1", port: releasePort, releaseId: "release-b", - requiredCookie: qualificationCookie, + requiredCookie: scenarioCookie, requireSecureProxy: true, }); - cleanup.defer("qualification release B", () => releaseB.stop(true)); + cleanup.defer("rolling-release scenario B", () => releaseB.stop(true)); releaseB.readiness.markReady("release-b"); const releaseBIdentity = await client.runtime.identity.query(); expect(releaseBIdentity.releaseId).toBe("release-b"); @@ -156,7 +159,7 @@ describe("production-shaped HTTPS and rolling-release SSE topology", () => { await waitFor(() => eventFeed.activeSubscriberCount === 1); await waitFor(() => receivedIds.length === 2); await client.events.publish.mutate({ - kind: "qualification.changed", + kind: "integration.changed", value: 3, }); await waitFor(() => receivedIds.length === 3); diff --git a/qualification/topology/testTlsIdentity.ts b/greenfield/src/test/integration/transport/topology/testTlsIdentity.ts similarity index 94% rename from qualification/topology/testTlsIdentity.ts rename to greenfield/src/test/integration/transport/topology/testTlsIdentity.ts index 8dde4d4f9..e5e16f6e7 100644 --- a/qualification/topology/testTlsIdentity.ts +++ b/greenfield/src/test/integration/transport/topology/testTlsIdentity.ts @@ -2,9 +2,9 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -const tlsIdentityFailurePrefix = "Could not generate qualification TLS identity"; +const tlsIdentityFailurePrefix = "Could not generate integration TLS identity"; -/** Ephemeral certificate material trusted only by one qualification test. */ +/** Ephemeral certificate material trusted only by one integration scenario. */ export interface TestTlsIdentity { certificate: string; dispose(): Promise; diff --git a/qualification/topology/trustedFetch.ts b/greenfield/src/test/integration/transport/topology/trustedFetch.ts similarity index 83% rename from qualification/topology/trustedFetch.ts rename to greenfield/src/test/integration/transport/topology/trustedFetch.ts index c52b52dc1..a1206e0ac 100644 --- a/qualification/topology/trustedFetch.ts +++ b/greenfield/src/test/integration/transport/topology/trustedFetch.ts @@ -1,6 +1,6 @@ -import type { QualificationFetch } from "../trpc/client.ts"; +import type { IntegrationFetch } from "../trpc/client.ts"; -/** Options for a qualification client that trusts one ephemeral TLS identity. */ +/** Options for an integration client that trusts one ephemeral TLS identity. */ export interface TrustedFetchOptions { certificateAuthority: string; cookie?: string; @@ -11,7 +11,7 @@ export interface TrustedFetchOptions { * @param options TLS trust and credential options. * @returns A Fetch-compatible function for tRPC and EventSource. */ -export function createTrustedFetch(options: TrustedFetchOptions): QualificationFetch { +export function createTrustedFetch(options: TrustedFetchOptions): IntegrationFetch { return async (input, init) => { const inputHeaders = input instanceof Request ? input.headers : undefined; const headers = new Headers(init?.headers ?? inputHeaders); diff --git a/greenfield/src/test/integration/transport/trpc/client.ts b/greenfield/src/test/integration/transport/trpc/client.ts new file mode 100644 index 000000000..f028e9bf5 --- /dev/null +++ b/greenfield/src/test/integration/transport/trpc/client.ts @@ -0,0 +1,81 @@ +import { + createTRPCClient, + httpBatchLink, + httpSubscriptionLink, + type HTTPBatchLinkOptions, + retryLink, + splitLink, +} from "@trpc/client"; +import { EventSource, type EventSourceInit } from "eventsource"; + +import type { IntegrationRouter } from "./router.ts"; + +/** Native Bun fetch surface shared by the integration transports. */ +export type IntegrationFetch = ( + ...arguments_: Parameters +) => ReturnType; + +type IntegrationTrpcFetch = NonNullable< + HTTPBatchLinkOptions["fetch"] +>; + +function adaptIntegrationTrpcFetch( + fetchImplementation: IntegrationFetch | undefined +): IntegrationTrpcFetch | undefined { + if (fetchImplementation === undefined) return undefined; + + return async (input, init) => { + if (typeof input !== "string") { + throw new TypeError("The integration tRPC adapter requires a string URL"); + } + const response = await fetchImplementation(input, { + body: init?.body, + headers: init?.headers, + method: init?.method, + signal: init?.signal, + }); + return { + json: () => response.json(), + ok: response.ok, + }; + }; +} + +/** Transport options for one integration client. */ +export interface IntegrationClientOptions { + eventSourceOptions?: EventSourceInit; + fetch?: IntegrationFetch; + retrySubscriptions?: boolean; + url: URL; +} + +/** + * Creates the shared query, mutation, and SSE integration client. + * @param options Stable endpoint and optional TLS/retry transport behavior. + * @returns A typed tRPC client. + */ +export function createIntegrationClient(options: IntegrationClientOptions) { + const url = new URL("/trpc", options.url).toString(); + const trpcFetch = adaptIntegrationTrpcFetch(options.fetch); + + return createTRPCClient({ + links: [ + retryLink({ + retry: ({ attempts, op }) => + options.retrySubscriptions === true && + op.type === "subscription" && + attempts <= 20, + retryDelayMs: () => 100, + }), + splitLink({ + condition: (operation) => operation.type === "subscription", + false: httpBatchLink({ fetch: trpcFetch, url }), + true: httpSubscriptionLink({ + EventSource, + eventSourceOptions: options.eventSourceOptions, + url, + }), + }), + ], + }); +} diff --git a/qualification/trpc/router.ts b/greenfield/src/test/integration/transport/trpc/router.ts similarity index 70% rename from qualification/trpc/router.ts rename to greenfield/src/test/integration/transport/trpc/router.ts index cc1df6558..04c26096d 100644 --- a/qualification/trpc/router.ts +++ b/greenfield/src/test/integration/transport/trpc/router.ts @@ -1,23 +1,23 @@ import { initTRPC, tracked } from "@trpc/server"; import * as v from "valibot"; +import { readRuntimeIdentity } from "../../runtime/runtimeCandidate.ts"; import { - isQualificationEventPayloadWithinLimit, - qualificationEventLimits, - type QualificationEventFeed, + isIntegrationEventPayloadWithinLimit, + integrationEventLimits, + type IntegrationEventFeed, } from "../realtime/eventFeed.ts"; -import { readRuntimeIdentity } from "../runtimeCandidate.ts"; const eventPayloadSchema = v.pipe( v.string(), v.check( - isQualificationEventPayloadWithinLimit, - `Qualification event payload must not exceed ${qualificationEventLimits.maximumPayloadBytes} UTF-8 bytes` + isIntegrationEventPayloadWithinLimit, + `Integration event payload must not exceed ${integrationEventLimits.maximumPayloadBytes} UTF-8 bytes` ) ); const eventDataSchema = v.strictObject({ - kind: v.literal("qualification.changed"), + kind: v.literal("integration.changed"), payload: v.optional(eventPayloadSchema), value: v.number(), }); @@ -38,24 +38,24 @@ const runtimeIdentitySchema = v.strictObject({ version: v.string(), }); -/** Dependencies available to qualification procedures. */ -export interface QualificationContext { - eventFeed: QualificationEventFeed; +/** Dependencies available to integration procedures. */ +export interface IntegrationContext { + eventFeed: IntegrationEventFeed; releaseId: string; } -/** Stream timing used by one qualification router instance. */ -export interface QualificationRouterOptions { +/** Stream timing used by one integration router instance. */ +export interface IntegrationRouterOptions { maximumStreamDurationMs?: number; } /** * Creates a tRPC router with either forced or production-style stream duration. - * @param options Stream timing used by the qualification case. + * @param options Stream timing used by the bounded integration scenario. * @returns A router with a stable client contract. */ -export function createQualificationRouter(options: QualificationRouterOptions) { - const trpc = initTRPC.context().create({ +export function createIntegrationRouter(options: IntegrationRouterOptions) { + const trpc = initTRPC.context().create({ sse: { ...(options.maximumStreamDurationMs === undefined ? {} @@ -99,5 +99,5 @@ export function createQualificationRouter(options: QualificationRouterOptions) { }); } -/** Type-only API contract consumed by the qualification client. */ -export type QualificationRouter = ReturnType; +/** Type-only API contract consumed by the integration client. */ +export type IntegrationRouter = ReturnType; diff --git a/qualification/trpc/server.ts b/greenfield/src/test/integration/transport/trpc/server.ts similarity index 76% rename from qualification/trpc/server.ts rename to greenfield/src/test/integration/transport/trpc/server.ts index 87b7759a0..209192d70 100644 --- a/qualification/trpc/server.ts +++ b/greenfield/src/test/integration/transport/trpc/server.ts @@ -1,17 +1,17 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; -import type { QualificationEventFeed } from "../realtime/eventFeed.ts"; -import { QualificationReleaseReadiness } from "../topology/releaseReadiness.ts"; -import { createQualificationRouter } from "./router.ts"; +import type { IntegrationEventFeed } from "../realtime/eventFeed.ts"; +import { IntegrationReleaseReadiness } from "../topology/releaseReadiness.ts"; +import { createIntegrationRouter } from "./router.ts"; const trpcEndpoint = "/trpc"; -/** Transport ceiling covering the escaped 8 KiB qualification payload contract. */ -export const qualificationRequestBodyMaximumBytes = 64 * 1024; +/** Transport ceiling covering the escaped 8 KiB integration payload contract. */ +export const integrationRequestBodyMaximumBytes = 64 * 1024; -/** Runtime options for one ephemeral qualification release. */ -export interface QualificationServerOptions { - eventFeed: QualificationEventFeed; +/** Runtime options for one ephemeral integration release. */ +export interface IntegrationServerOptions { + eventFeed: IntegrationEventFeed; hostname?: string; maximumStreamDurationMs?: number; port?: number; @@ -31,7 +31,7 @@ function healthResponse( }); } -function rejectedProxyRequest(request: Request, options: QualificationServerOptions) { +function rejectedProxyRequest(request: Request, options: IntegrationServerOptions) { if (!options.requireSecureProxy) { return null; } @@ -42,7 +42,7 @@ function rejectedProxyRequest(request: Request, options: QualificationServerOpti options.requiredCookie !== undefined && request.headers.get("cookie") !== options.requiredCookie ) { - return new Response("Qualification credential required", { status: 401 }); + return new Response("Integration credential required", { status: 401 }); } return null; } @@ -50,11 +50,11 @@ function rejectedProxyRequest(request: Request, options: QualificationServerOpti /** * Starts an ephemeral Bun HTTP server around the tRPC Fetch adapter. * @param options Release identity, event source, and listener options. - * @returns A controlled qualification release. + * @returns A controlled integration release. */ -export function startQualificationServer(options: QualificationServerOptions) { - const readiness = new QualificationReleaseReadiness(options.releaseId); - const router = createQualificationRouter({ +export function startIntegrationServer(options: IntegrationServerOptions) { + const readiness = new IntegrationReleaseReadiness(options.releaseId); + const router = createIntegrationRouter({ maximumStreamDurationMs: options.maximumStreamDurationMs, }); const server = Bun.serve({ @@ -95,7 +95,7 @@ export function startQualificationServer(options: QualificationServerOptions) { return new Response("Not found", { status: 404 }); }, hostname: options.hostname, - maxRequestBodySize: qualificationRequestBodyMaximumBytes, + maxRequestBodySize: integrationRequestBodyMaximumBytes, port: options.port ?? 0, }); let stopPromise: Promise | undefined; diff --git a/qualification/trpc/trpcFetchSse.test.ts b/greenfield/src/test/integration/transport/trpc/trpcFetchSse.test.ts similarity index 71% rename from qualification/trpc/trpcFetchSse.test.ts rename to greenfield/src/test/integration/transport/trpc/trpcFetchSse.test.ts index 70bd384f2..1947181c5 100644 --- a/qualification/trpc/trpcFetchSse.test.ts +++ b/greenfield/src/test/integration/transport/trpc/trpcFetchSse.test.ts @@ -1,17 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { - QualificationEventFeed, - qualificationEventLimits, -} from "../realtime/eventFeed.ts"; -import { waitFor } from "../test/waitFor.ts"; -import { createQualificationClient } from "./client.ts"; -import { - qualificationRequestBodyMaximumBytes, - startQualificationServer, -} from "./server.ts"; +import { waitFor } from "../../../support/waitFor.ts"; +import { IntegrationEventFeed, integrationEventLimits } from "../realtime/eventFeed.ts"; +import { createIntegrationClient } from "./client.ts"; +import { integrationRequestBodyMaximumBytes, startIntegrationServer } from "./server.ts"; -const servers: Array> = []; +const servers: Array> = []; afterEach(async () => { for (const server of servers.splice(0)) { @@ -21,15 +15,15 @@ afterEach(async () => { describe("tRPC Fetch and tracked SSE on Bun", () => { test("serves validated queries and mutations through Bun.serve", async () => { - const eventFeed = new QualificationEventFeed(); - const server = startQualificationServer({ + const eventFeed = new IntegrationEventFeed(); + const server = startIntegrationServer({ eventFeed, hostname: "127.0.0.1", maximumStreamDurationMs: 300, releaseId: "direct-release", }); servers.push(server); - const client = createQualificationClient({ url: server.url }); + const client = createIntegrationClient({ url: server.url }); expect(await client.runtime.identity.query()).toEqual({ hasGlobalEventSource: false, @@ -39,12 +33,12 @@ describe("tRPC Fetch and tracked SSE on Bun", () => { }); expect( await client.events.publish.mutate({ - kind: "qualification.changed", + kind: "integration.changed", value: 1, }) ).toEqual({ data: { - kind: "qualification.changed", + kind: "integration.changed", value: 1, }, id: "1", @@ -52,10 +46,8 @@ describe("tRPC Fetch and tracked SSE on Bun", () => { let payloadError: unknown; try { await client.events.publish.mutate({ - kind: "qualification.changed", - payload: `${"é".repeat( - qualificationEventLimits.maximumPayloadBytes / 2 - )}a`, + kind: "integration.changed", + payload: `${"é".repeat(integrationEventLimits.maximumPayloadBytes / 2)}a`, value: 2, }); } catch (error) { @@ -63,12 +55,12 @@ describe("tRPC Fetch and tracked SSE on Bun", () => { } expect(payloadError).toBeInstanceOf(Error); expect((payloadError as Error).message).toContain( - "Qualification event payload must not exceed" + "Integration event payload must not exceed" ); let unknownInputError: unknown; try { await client.events.publish.mutate({ - kind: "qualification.changed", + kind: "integration.changed", unexpected: true, value: 2, } as never); @@ -79,16 +71,16 @@ describe("tRPC Fetch and tracked SSE on Bun", () => { expect(eventFeed.metricsSnapshot().latestSequence).toBe(1); }); - test("rejects request bodies above the qualification transport budget", async () => { - const server = startQualificationServer({ - eventFeed: new QualificationEventFeed(), + test("rejects request bodies above the integration transport budget", async () => { + const server = startIntegrationServer({ + eventFeed: new IntegrationEventFeed(), hostname: "127.0.0.1", releaseId: "direct-release", }); servers.push(server); const response = await fetch(new URL("/trpc/events.publish", server.url), { - body: "x".repeat(qualificationRequestBodyMaximumBytes + 1), + body: "x".repeat(integrationRequestBodyMaximumBytes + 1), headers: { "content-type": "application/json" }, method: "POST", }); @@ -97,15 +89,15 @@ describe("tRPC Fetch and tracked SSE on Bun", () => { }); test("resumes tracked events after a forced SSE reconnect without duplicates", async () => { - const eventFeed = new QualificationEventFeed(); - const server = startQualificationServer({ + const eventFeed = new IntegrationEventFeed(); + const server = startIntegrationServer({ eventFeed, hostname: "127.0.0.1", maximumStreamDurationMs: 300, releaseId: "direct-release", }); servers.push(server); - const client = createQualificationClient({ url: server.url }); + const client = createIntegrationClient({ url: server.url }); const receivedIds: string[] = []; const subscriptionErrors: Error[] = []; let startedCount = 0; @@ -128,7 +120,7 @@ describe("tRPC Fetch and tracked SSE on Bun", () => { try { await waitFor(() => startedCount >= 1); await client.events.publish.mutate({ - kind: "qualification.changed", + kind: "integration.changed", value: 1, }); await waitFor(() => receivedIds.length === 1); @@ -136,7 +128,7 @@ describe("tRPC Fetch and tracked SSE on Bun", () => { expect(startedCount).toBe(1); await client.events.publish.mutate({ - kind: "qualification.changed", + kind: "integration.changed", value: 2, }); expect(receivedIds).toEqual(["1"]); diff --git a/qualification/websocket/nativeWebSocketQualification.test.ts b/greenfield/src/test/integration/websocket/nativeWebSocketTransport.test.ts similarity index 97% rename from qualification/websocket/nativeWebSocketQualification.test.ts rename to greenfield/src/test/integration/websocket/nativeWebSocketTransport.test.ts index 3ae1d0161..4d93f1e19 100644 --- a/qualification/websocket/nativeWebSocketQualification.test.ts +++ b/greenfield/src/test/integration/websocket/nativeWebSocketTransport.test.ts @@ -11,13 +11,13 @@ import { observeNativeWebSocket, withNativeWebSocketDeadline, type NativeWebSocketObservationError, -} from "./nativeWebSocketQualification.ts"; +} from "./nativeWebSocketTransport.ts"; import { rawWebSocketFixtureResource } from "./rawWebSocketFixture.ts"; import { createFragmentedUtf8Evidence, fragmentedUtf8Message, maximumRawWebSocketFixtureOutboundBytes, - oversizedQualificationMessageBytes, + oversizedScenarioMessageBytes, type RawWebSocketScenario, } from "./rawWebSocketProtocol.ts"; @@ -127,7 +127,7 @@ async function runRejectedScenario( ); } -describe("Bun native WebSocket RFC 6455 qualification", () => { +describe("Bun native WebSocket RFC 6455 integration", () => { test("reassembles continuation frames with a UTF-8 code point split across payloads", async () => { const split = createFragmentedUtf8Evidence(); expect(Buffer.concat(split.fragments).equals(split.completeBytes)).toBe(true); @@ -235,7 +235,7 @@ describe("Bun native WebSocket RFC 6455 qualification", () => { expect(evidence.error).toBeInstanceOf(NativeWebSocketMessageLimitError); if (!(evidence.error instanceof NativeWebSocketMessageLimitError)) return; expect(evidence.error).toMatchObject({ - actualBytes: oversizedQualificationMessageBytes, + actualBytes: oversizedScenarioMessageBytes, eventCounts: { closes: 0, errors: 0, @@ -342,10 +342,10 @@ describe("Bun native WebSocket RFC 6455 qualification", () => { }); }); - test("fails qualification when scoped native close does not cooperate", async () => { + test("reports a scenario failure when scoped native close does not cooperate", async () => { const fixture = createNonCooperatingCloseFixture(); const exit = await Effect.runPromiseExit( - observeNativeWebSocket("ws://127.0.0.1/qualification", fixture.factory) + observeNativeWebSocket("ws://127.0.0.1/integration", fixture.factory) ); expect(Exit.isFailure(exit)).toBeTrue(); diff --git a/qualification/websocket/nativeWebSocketQualification.ts b/greenfield/src/test/integration/websocket/nativeWebSocketTransport.ts similarity index 98% rename from qualification/websocket/nativeWebSocketQualification.ts rename to greenfield/src/test/integration/websocket/nativeWebSocketTransport.ts index 649032b37..4703dd145 100644 --- a/qualification/websocket/nativeWebSocketQualification.ts +++ b/greenfield/src/test/integration/websocket/nativeWebSocketTransport.ts @@ -105,8 +105,8 @@ function terminateNativeWebSocket(socket: WebSocket): void { } /** - * Applies the shared Effect deadline policy used by this qualification slice. - * @param effect Operation governed by the qualification deadline. + * Applies the shared Effect deadline policy used by this integration slice. + * @param effect Operation governed by the integration deadline. * @param operation Redacted operation label for a typed timeout. * @returns The original result or a tagged deadline failure. */ @@ -133,7 +133,7 @@ function closeObserver( ) { try { observer.closeState.requested = true; - observer.socket.close(1000, "qualification scope closed"); + observer.socket.close(1000, "integration scope closed"); } catch { // A simultaneous native transport close still completes the close event. } @@ -329,7 +329,7 @@ export function closedLoopbackWebSocketUrl(): Effect.Effect< }, }), }), - (listener) => Effect.succeed(`ws://127.0.0.1:${listener.port}/qualification`), + (listener) => Effect.succeed(`ws://127.0.0.1:${listener.port}/integration`), (listener) => Effect.sync(() => listener.stop(true)) ); } diff --git a/qualification/websocket/rawWebSocketFixture.ts b/greenfield/src/test/integration/websocket/rawWebSocketFixture.ts similarity index 99% rename from qualification/websocket/rawWebSocketFixture.ts rename to greenfield/src/test/integration/websocket/rawWebSocketFixture.ts index 550485a64..e90b842ab 100644 --- a/qualification/websocket/rawWebSocketFixture.ts +++ b/greenfield/src/test/integration/websocket/rawWebSocketFixture.ts @@ -278,7 +278,7 @@ export function rawWebSocketFixtureResource( awaitClosed: Deferred.await(closed), awaitPeerPong: Deferred.await(peerPong), awaitUpgraded: Deferred.await(upgraded), - url: `ws://127.0.0.1:${listener.port}/qualification`, + url: `ws://127.0.0.1:${listener.port}/integration`, get acceptedConnections() { return shared.acceptedConnections; }, diff --git a/qualification/websocket/rawWebSocketProtocol.ts b/greenfield/src/test/integration/websocket/rawWebSocketProtocol.ts similarity index 97% rename from qualification/websocket/rawWebSocketProtocol.ts rename to greenfield/src/test/integration/websocket/rawWebSocketProtocol.ts index 8065e6e95..d09406b83 100644 --- a/qualification/websocket/rawWebSocketProtocol.ts +++ b/greenfield/src/test/integration/websocket/rawWebSocketProtocol.ts @@ -8,7 +8,7 @@ export const maximumRawWebSocketHandshakeBytes = 16 * 1024; export const maximumRawWebSocketPeerBytes = 128 * 1024; export const fragmentedUtf8Message = "Mira says: blåbær 🦀 ferdig"; -export const oversizedQualificationMessageBytes = 64 * 1024 + 1; +export const oversizedScenarioMessageBytes = 64 * 1024 + 1; export type RawWebSocketScenario = | "close-before-message" @@ -113,7 +113,7 @@ export function encodeServerCloseFrame(code: number, reason = ""): Buffer { function parseHeaderLines(headerBytes: Buffer): Map { const lines = headerBytes.toString("latin1").split("\r\n"); - if (lines.shift() !== "GET /qualification HTTP/1.1") { + if (lines.shift() !== "GET /integration HTTP/1.1") { throw new Error("WebSocket fixture received an unexpected request target"); } const headers = new Map(); @@ -263,7 +263,7 @@ export function createFragmentedUtf8Evidence(): FragmentedUtf8Evidence { const codePointBytes = Buffer.from("🦀", "utf8"); const codePointOffset = completeBytes.indexOf(codePointBytes); if (codePointOffset === -1) { - throw new Error("WebSocket qualification message lost its split code point"); + throw new Error("WebSocket scenario message lost its split code point"); } const fragments = [ completeBytes.subarray(0, codePointOffset + 2), @@ -287,7 +287,7 @@ export function createFragmentedUtf8Evidence(): FragmentedUtf8Evidence { /** * Creates the raw post-upgrade bytes for one native WebSocket scenario. - * @param scenario Scenario selected by a focused qualification test. + * @param scenario Scenario selected by a focused integration check. * @returns Bounded RFC 6455 bytes sent by the raw TCP fixture. */ export function createScenarioBytes(scenario: RawWebSocketScenario): Buffer { @@ -327,7 +327,7 @@ export function createScenarioBytes(scenario: RawWebSocketScenario): Buffer { frames = [ encodeServerFrame( 0x01, - Buffer.alloc(oversizedQualificationMessageBytes, 0x61) + Buffer.alloc(oversizedScenarioMessageBytes, 0x61) ), ]; break; diff --git a/qualification/parity/fixtures/frontend-routes.json b/greenfield/src/test/parity/fixtures/frontend-routes.json similarity index 100% rename from qualification/parity/fixtures/frontend-routes.json rename to greenfield/src/test/parity/fixtures/frontend-routes.json diff --git a/qualification/parity/fixtures/greenfield-contracts.json b/greenfield/src/test/parity/fixtures/greenfield-contracts.json similarity index 100% rename from qualification/parity/fixtures/greenfield-contracts.json rename to greenfield/src/test/parity/fixtures/greenfield-contracts.json diff --git a/qualification/parity/fixtures/legacy-endpoints.json b/greenfield/src/test/parity/fixtures/legacy-endpoints.json similarity index 100% rename from qualification/parity/fixtures/legacy-endpoints.json rename to greenfield/src/test/parity/fixtures/legacy-endpoints.json diff --git a/qualification/parity/parityFixtureCandidate.ts b/greenfield/src/test/parity/parityFixtureCandidate.ts similarity index 54% rename from qualification/parity/parityFixtureCandidate.ts rename to greenfield/src/test/parity/parityFixtureCandidate.ts index eec050364..a2d88b235 100644 --- a/qualification/parity/parityFixtureCandidate.ts +++ b/greenfield/src/test/parity/parityFixtureCandidate.ts @@ -1,18 +1,7 @@ import { - parseFrontendParityFixture, parseGreenfieldContractParityFixture, - parseLegacyEndpointParityFixture, - type FrontendParityFixture, type GreenfieldContractParityFixture, - type LegacyEndpointParityFixture, } from "./parityInventorySchemas.ts"; -import type { ReviewedParityInventory } from "./reviewedParityInventory.ts"; -import type { SourceParityInventory } from "./sourceParityInventory.ts"; - -export interface ParityFixtureCandidate { - frontend: FrontendParityFixture; - legacyEndpoints: LegacyEndpointParityFixture; -} export interface ProcedureContractCandidate { kind: "mutation" | "query" | "subscription"; @@ -90,56 +79,3 @@ export function buildGreenfieldContractFixtureCandidate( source: "src/contracts/contractRegistry.ts", }); } - -/** - * Builds a source-refreshed candidate while preserving only explicitly reviewed target mappings. - * New route paths or endpoint ids fail instead of receiving an inferred target. - * @param observed Current semantic source inventory. - * @param reviewed Committed reviewed inventory and target mappings. - * @returns Source-refreshed fixture candidate. - */ -export function buildParityFixtureCandidate( - observed: SourceParityInventory, - reviewed: ReviewedParityInventory -): ParityFixtureCandidate { - const reviewedRoutes = new Map( - reviewed.frontend.routes.map((route) => [route.path, route] as const) - ); - const reviewedEndpoints = new Map( - reviewed.legacyEndpoints.endpoints.map( - (endpoint) => [endpoint.id, endpoint] as const - ) - ); - const frontend = parseFrontendParityFixture({ - ...reviewed.frontend, - routes: observed.routes.map((route) => { - const target = reviewedRoutes.get(route.path); - if (!target) { - throw new Error( - `Frontend route ${route.path} needs an explicit parity target review` - ); - } - return { - ...route, - featureOwner: target.featureOwner, - target: target.target, - }; - }), - }); - const legacyEndpoints = parseLegacyEndpointParityFixture({ - ...reviewed.legacyEndpoints, - endpoints: observed.endpoints.map((endpoint) => { - const target = reviewedEndpoints.get(endpoint.id)?.target; - if (!target) { - throw new Error( - `Legacy endpoint ${endpoint.id} needs an explicit parity target review` - ); - } - return { ...endpoint, target }; - }), - }); - return { - frontend, - legacyEndpoints, - }; -} diff --git a/greenfield/src/test/parity/parityInventory.test.ts b/greenfield/src/test/parity/parityInventory.test.ts new file mode 100644 index 000000000..0708ad4ed --- /dev/null +++ b/greenfield/src/test/parity/parityInventory.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "bun:test"; + +import { + procedureContracts, + rawHttpContracts, +} from "../../contracts/contractRegistry.ts"; +import { buildGreenfieldContractFixtureCandidate } from "./parityFixtureCandidate.ts"; +import { + parseFrontendParityFixture, + reviewedLegacyEndpointRowCount, + type FrontendRouteInventory, + type LegacyEndpointInventory, +} from "./parityInventorySchemas.ts"; +import { + assertGreenfieldRegistryMatchesReviewed, + assertGreenfieldTargetAccounting, + loadReviewedParityInventory, +} from "./reviewedParityInventory.ts"; + +function countByPhase( + values: readonly ( + | Pick + | Pick + )[] +): Record { + const counts: Record = {}; + for (const value of values) { + if ("kind" in value.target && value.target.kind === "reviewed-removal") continue; + counts[value.target.phase] = (counts[value.target.phase] ?? 0) + 1; + } + return counts; +} + +describe("reviewed pre-cutover parity inventory", () => { + test("keeps the immutable browser-route design input complete", async () => { + const { frontend } = await loadReviewedParityInventory(); + + expect(frontend.routes).toHaveLength(16); + expect( + frontend.routes.filter((route) => route.navigationPosition !== null) + ).toHaveLength(15); + expect( + frontend.routes + .filter((route) => route.navigationPosition !== null) + .toSorted( + (left, right) => left.navigationPosition! - right.navigationPosition! + ) + .map((route) => route.navigationPosition) + ).toEqual(Array.from({ length: 15 }, (_, index) => index)); + expect( + frontend.routes.every((route) => route.target.delivery === "planned") + ).toBeTrue(); + expect(countByPhase(frontend.routes)).toEqual({ + "phase-2": 1, + "phase-3": 5, + "phase-4": 2, + "phase-5": 8, + }); + }); + + test("validates strict reviewed fixture objects without reading the old app", async () => { + const { frontend, legacyEndpoints } = await loadReviewedParityInventory(); + + expect(() => + parseFrontendParityFixture({ ...frontend, unreviewedField: true }) + ).toThrow(); + expect(legacyEndpoints.endpoints).toHaveLength(reviewedLegacyEndpointRowCount); + expect(new Set(legacyEndpoints.endpoints.map(({ id }) => id)).size).toBe( + reviewedLegacyEndpointRowCount + ); + expect(countByPhase(legacyEndpoints.endpoints)).toEqual({ + "phase-1": 7, + "phase-2": 28, + "phase-3": 45, + "phase-4": 7, + "phase-5": 70, + }); + }); + + test("checks implemented mappings against only the greenfield registries", async () => { + const reviewed = await loadReviewedParityInventory(); + + expect(() => + assertGreenfieldRegistryMatchesReviewed( + reviewed, + procedureContracts, + rawHttpContracts + ) + ).not.toThrow(); + expect( + buildGreenfieldContractFixtureCandidate(procedureContracts, rawHttpContracts) + ).toEqual(reviewed.greenfieldContracts); + expect(() => + assertGreenfieldTargetAccounting( + reviewed, + procedureContracts, + rawHttpContracts + ) + ).not.toThrow(); + + const missingContract = structuredClone(reviewed); + const implementedProcedure = missingContract.legacyEndpoints.endpoints.find( + ({ target }) => + target.kind === "procedure" && target.delivery === "implemented" + ); + if (implementedProcedure?.target.kind !== "procedure") { + throw new Error("Reviewed fixture has no implemented procedure target"); + } + implementedProcedure.target.names = ["missing.procedure"]; + expect(() => + assertGreenfieldTargetAccounting( + missingContract, + procedureContracts, + rawHttpContracts + ) + ).toThrow("is not registered"); + }); + + test("keeps unresolved Phase 2 behavior explicit", async () => { + const reviewed = await loadReviewedParityInventory(); + + expect( + reviewed.legacyEndpoints.endpoints + .filter( + ({ target }) => + target.kind !== "reviewed-removal" && + target.phase === "phase-2" && + target.delivery === "planned" + ) + .map(({ id }) => id) + ).toEqual([ + "GET /api/audit-events", + "POST /api/account/security/sessions/revoke-all", + "POST /api/account/security/sessions/revoke-others", + ]); + }); +}); diff --git a/qualification/parity/parityInventorySchemas.ts b/greenfield/src/test/parity/parityInventorySchemas.ts similarity index 98% rename from qualification/parity/parityInventorySchemas.ts rename to greenfield/src/test/parity/parityInventorySchemas.ts index cf5dbbc18..8a71c3ca7 100644 --- a/qualification/parity/parityInventorySchemas.ts +++ b/greenfield/src/test/parity/parityInventorySchemas.ts @@ -1,7 +1,5 @@ import * as v from "valibot"; -/* oxlint-disable unicorn/max-nested-calls -- Strict Valibot schemas are intentionally declarative. */ - const schemaVersionSchema = v.literal(1); const boundedTextSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(512)); const procedureNameSchema = v.pipe( diff --git a/qualification/parity/reviewedParityInventory.ts b/greenfield/src/test/parity/reviewedParityInventory.ts similarity index 77% rename from qualification/parity/reviewedParityInventory.ts rename to greenfield/src/test/parity/reviewedParityInventory.ts index d288c62d3..72c736962 100644 --- a/qualification/parity/reviewedParityInventory.ts +++ b/greenfield/src/test/parity/reviewedParityInventory.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import { readBoundedUtf8RegularFile } from "../files/boundedFile.ts"; +import { readBoundedUtf8RegularFile } from "../../../scripts/files/boundedFile.ts"; import { projectGreenfieldContractIdentities, type ProcedureContractIdentity, @@ -15,7 +15,6 @@ import { type GreenfieldContractParityFixture, type LegacyEndpointParityFixture, } from "./parityInventorySchemas.ts"; -import type { SourceParityInventory } from "./sourceParityInventory.ts"; const fixtureDirectory = path.join( path.dirname(fileURLToPath(import.meta.url)), @@ -84,55 +83,6 @@ export async function loadReviewedParityInventory(): Promise ({ - id, - method, - path: endpointPath, - purpose, - section, - }) - ), - routes: reviewed.frontend.routes.map( - ({ - access, - moduleKey, - navigationLabel, - navigationPosition, - pageModule, - path: routePath, - searchNormalizer, - sourceRouteName, - }) => ({ - access, - moduleKey, - navigationLabel, - navigationPosition, - pageModule, - path: routePath, - searchNormalizer, - sourceRouteName, - }) - ), - }; -} - -/** Fails when current route, navigation, module, or endpoint sources drift from review. */ -export function assertSourcesMatchReviewedParity( - observed: SourceParityInventory, - reviewed: ReviewedParityInventory -): void { - if (canonicalJson(observed) !== canonicalJson(reviewedSourceProjection(reviewed))) { - throw new Error( - "Current-production parity sources differ from reviewed fixtures" - ); - } -} - function contractKey(method: string, routePath: string): string { return `${method} ${routePath}`; } diff --git a/qualification/test/asyncCleanupStack.test.ts b/greenfield/src/test/support/asyncCleanupStack.test.ts similarity index 98% rename from qualification/test/asyncCleanupStack.test.ts rename to greenfield/src/test/support/asyncCleanupStack.test.ts index 3fb2ba9ea..b8d3c818f 100644 --- a/qualification/test/asyncCleanupStack.test.ts +++ b/greenfield/src/test/support/asyncCleanupStack.test.ts @@ -9,7 +9,7 @@ import { AsyncCleanupStack, } from "./asyncCleanupStack.ts"; -describe("asynchronous qualification cleanup stack", () => { +describe("asynchronous integration cleanup stack", () => { test("runs every cleanup in LIFO order and tags operational failures", async () => { const cleanup = new AsyncCleanupStack(); const order: string[] = []; diff --git a/qualification/test/asyncCleanupStack.ts b/greenfield/src/test/support/asyncCleanupStack.ts similarity index 91% rename from qualification/test/asyncCleanupStack.ts rename to greenfield/src/test/support/asyncCleanupStack.ts index 930050f03..b9f7e8599 100644 --- a/qualification/test/asyncCleanupStack.ts +++ b/greenfield/src/test/support/asyncCleanupStack.ts @@ -1,6 +1,6 @@ import { Data, Effect } from "effect"; -/** One asynchronous cleanup registered by a qualification test. */ +/** One asynchronous cleanup registered by an integration test. */ interface AsyncCleanupOperation { label: string; operation: (signal: AbortSignal) => Promise | void; @@ -72,14 +72,14 @@ function drainCleanupOperations( if (failures.length > 0) { return yield* Effect.fail( - new AggregateError(failures, "Qualification resource cleanup failed") + new AggregateError(failures, "Integration resource cleanup failed") ); } }) ); } -/** Failure-safe last-in-first-out cleanup for qualification resources. */ +/** Failure-safe last-in-first-out cleanup for integration resources. */ export class AsyncCleanupStack { readonly #operations: AsyncCleanupOperation[] = []; @@ -93,7 +93,7 @@ export class AsyncCleanupStack { } /** - * Creates an Effect-native disposal for scoped qualification orchestration. + * Creates an Effect-native disposal for scoped integration orchestration. * @param timeoutMs Per-resource cleanup deadline in milliseconds. * @returns Uninterruptible LIFO drain with individually bounded operations. */ diff --git a/qualification/test/waitFor.ts b/greenfield/src/test/support/waitFor.ts similarity index 100% rename from qualification/test/waitFor.ts rename to greenfield/src/test/support/waitFor.ts diff --git a/greenfield/src/test/types/bunCanaryMatchers.d.ts b/greenfield/src/test/types/bunCanaryMatchers.d.ts new file mode 100644 index 000000000..b7dcbb300 --- /dev/null +++ b/greenfield/src/test/types/bunCanaryMatchers.d.ts @@ -0,0 +1,38 @@ +declare module "bun:test" { + interface Matchers { + toContainEqual(expected: unknown): void; + toEqual(expected: unknown): void; + } + + /** + * Bun Canary currently types asymmetric matchers as `any`. Narrowing the + * public return type keeps strict linting useful without changing runtime. + */ + interface AsymmetricMatchersBuiltin { + any( + constructor: + | ((...arguments_: never[]) => unknown) + | (new (...arguments_: never[]) => unknown) + ): unknown; + anything(): unknown; + arrayContaining(items: readonly E[]): unknown; + closeTo(value: number, precision?: number): unknown; + objectContaining(value: object): unknown; + stringContaining(value: string): unknown; + stringMatching(value: RegExp | string): unknown; + } + + interface Expect { + any( + constructor: + | ((...arguments_: never[]) => unknown) + | (new (...arguments_: never[]) => unknown) + ): unknown; + anything(): unknown; + arrayContaining(items: readonly E[]): unknown; + closeTo(value: number, precision?: number): unknown; + objectContaining(value: object): unknown; + stringContaining(value: string): unknown; + stringMatching(value: RegExp | string): unknown; + } +} diff --git a/greenfield/tailwind.config.ts b/greenfield/tailwind.config.ts new file mode 100644 index 000000000..45f797d35 --- /dev/null +++ b/greenfield/tailwind.config.ts @@ -0,0 +1,39 @@ +import typography from "@tailwindcss/typography"; +import type { Config } from "tailwindcss"; + +export default { + content: ["./src/browser/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + colors: { + primary: { + 50: "#E7E9EE", + 100: "#D4D8DF", + 200: "#BFC5CF", + 300: "#A7ADB8", + 400: "#8A929E", + 500: "#686F7B", + 600: "#4A505A", + 700: "#2A2D33", + 800: "#1A1C20", + 900: "#121316", + 950: "#0B0B0C", + }, + accent: { + 50: "#EEF3FF", + 100: "#DCE7FF", + 200: "#B7CDFF", + 300: "#8EAEFF", + 400: "#6E96FF", + 500: "#5B8CFF", + 600: "#4D76E0", + 700: "#3E5FB8", + 800: "#2F4891", + 900: "#22366E", + 950: "#17244A", + }, + }, + }, + }, + plugins: [typography], +} satisfies Config; diff --git a/greenfield/tsconfig.browser.json b/greenfield/tsconfig.browser.json new file mode 100644 index 000000000..bc9c26541 --- /dev/null +++ b/greenfield/tsconfig.browser.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "useDefineForClassFields": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [], + "jsx": "react-jsx" + }, + "files": [ + "node_modules/bun-types/test.d.ts", + "src/test/types/bunCanaryMatchers.d.ts" + ], + "include": ["src/browser/**/*.ts", "src/browser/**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/tsconfig.scripts.json b/greenfield/tsconfig.bun.json similarity index 63% rename from tsconfig.scripts.json rename to greenfield/tsconfig.bun.json index 9417abac4..890b598c0 100644 --- a/tsconfig.scripts.json +++ b/greenfield/tsconfig.bun.json @@ -4,5 +4,6 @@ "lib": ["ESNext"], "types": ["bun-types", "node"] }, - "include": ["drizzle.config.ts", "scripts/**/*.ts", "tailwind.config.ts"] + "include": ["**/*"], + "exclude": ["node_modules", "src/browser/**/*"] } diff --git a/greenfield/tsconfig.json b/greenfield/tsconfig.json new file mode 100644 index 000000000..58ad9006f --- /dev/null +++ b/greenfield/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "Preserve", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUncheckedSideEffectImports": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "erasableSyntaxOnly": true + }, + "files": [], + "references": [ + { "path": "./tsconfig.browser.json" }, + { "path": "./tsconfig.bun.json" } + ] +} diff --git a/package.json b/package.json index 3ef271811..774c44fc2 100644 --- a/package.json +++ b/package.json @@ -14,19 +14,13 @@ "build": "bun run build:frontend && bun run build:backend", "build:frontend": "bun node_modules/typescript/bin/tsc -p tsconfig.app.json --noEmit && bun scripts/buildFrontend.ts", "build:backend": "bun node_modules/typescript/bin/tsc -p tsconfig.node.json --noEmit && bun scripts/buildBackend.ts", - "check:boundaries": "bun scripts/checkSourceBoundaries.ts", "deploy:bootstrap": "bash scripts/bootstrapProduction.sh", "deploy:prepare": "bun run build:frontend && bun run deploy:prepare:backend && bun run release:manifest", "deploy:prepare:backend": "bun run build:backend && bun --cwd backend dist/databasePreflight.js", - "db:check": "bun scripts/checkDatabaseSchema.ts", - "db:generate": "drizzle-kit generate --config drizzle.config.ts --output json", - "docs:check": "bun scripts/generateDocs.ts --check", - "docs:generate": "bun scripts/generateDocs.ts", "auth:reset-password": "NODE_ENV=production doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT -- bun --cwd backend dist/resetDashboardPassword.js", "start:backend": "NODE_ENV=production doppler run --config prd --project rajohan -- bun --cwd backend dist/serverStart.js", "start:worker": "NODE_ENV=production doppler run --config prd --project rajohan -- bun --cwd backend dist/workerStart.js", "release:manifest": "bun scripts/writeReleaseManifest.ts", - "qualify:resources:sse": "bun qualification/resources/runSseMemoryQualification.ts", "lint": "oxlint .", "lint:fix": "oxlint . --fix", "format": "oxfmt --write .", @@ -39,18 +33,7 @@ "test:frontend:coverage": "bun scripts/runCoverage.ts frontend 85 frontend/src/", "test:backend": "bun test --cwd backend --config ../bunfig.toml --preload ./test/setup.ts test", "test:backend:changed": "bun test --cwd backend --config ../bunfig.toml --preload ./test/setup.ts --changed test", - "test:backend:coverage": "bun scripts/runCoverage.ts backend 85 src/", - "test:boundaries": "bun test scripts/sourceBoundaries", - "test:server": "bun test src/app src/server src/shared src/contracts", - "test:server:docs": "bun test scripts/documentation", - "test:server:tooling": "bun test scripts/checkDatabaseSchema.test.ts", - "test:qualification": "bun test qualification", - "typecheck:browser": "bun node_modules/typescript/bin/tsc -p tsconfig.browser.json --noEmit", - "typecheck:contracts": "bun node_modules/typescript/bin/tsc -p tsconfig.contracts.json --noEmit", - "typecheck:qualification": "bun node_modules/typescript/bin/tsc -p tsconfig.qualification.json --noEmit", - "typecheck:scripts": "bun node_modules/typescript/bin/tsc -p tsconfig.scripts.json --noEmit", - "typecheck:server": "bun node_modules/typescript/bin/tsc -p tsconfig.server.json --noEmit", - "typecheck:worker": "bun node_modules/typescript/bin/tsc -p tsconfig.worker.json --noEmit" + "test:backend:coverage": "bun scripts/runCoverage.ts backend 85 src/" }, "dependencies": { "@daypicker/react": "10.0.1", @@ -58,27 +41,21 @@ "@dnd-kit/react": "^0.5.0", "@dnd-kit/sortable": "^10.0.0", "@headlessui/react": "^2.2.10", - "@microlink/react-json-view": "^1.31.26", + "@microlink/react-json-view": "^1.31.25", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.2", "@tailwindcss/typography": "^0.5.20", - "@tanstack/db": "0.6.17", "@tanstack/query-core": "5.101.4", - "@tanstack/query-db-collection": "1.2.1", - "@tanstack/react-db": "0.1.95", + "@tanstack/query-db-collection": "^1.2.1", + "@tanstack/react-db": "^0.1.95", "@tanstack/react-form": "^1.33.3", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.18", "@tanstack/react-store": "^0.11.0", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.14.9", - "@trpc/client": "11.18.0", - "@trpc/server": "11.18.0", - "@trpc/tanstack-react-query": "11.18.0", "clsx": "^2.1.1", "date-fns": "^4.4.0", - "drizzle-orm": "1.0.0-rc.4", - "effect": "4.0.0-beta.103", "json5": "^2.2.3", "lucide-react": "^1.28.0", "otplib": "13.4.1", @@ -93,7 +70,6 @@ "rehype-sanitize": "^6.0.0", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", - "superjson": "2.2.6", "tailwind-merge": "^3.6.0", "valibot": "^1.4.2" }, @@ -113,17 +89,13 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", - "@valibot/to-json-schema": "1.7.1", "babel-plugin-react-compiler": "^1.0.0", "bun-plugin-tailwind": "^0.1.2", - "bun-types": "1.4.0-canary.20260519T150915", - "drizzle-kit": "1.0.0-rc.4", - "eventsource": "4.1.0", + "bun-types": "canary", "happy-dom": "^20.11.1", - "jsonc-parser": "3.3.1", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", - "oxlint-config-presets": "^0.1.18", + "oxlint-config-presets": "^0.1.17", "oxlint-tailwindcss": "^1.6.0", "oxlint-tsgolint": "^7.0.2001", "tailwindcss": "^4.3.3", diff --git a/qualification/browser/queryCollectionAdapter.test.ts b/qualification/browser/queryCollectionAdapter.test.ts deleted file mode 100644 index 5bc3d2b59..000000000 --- a/qualification/browser/queryCollectionAdapter.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { QueryClient } from "@tanstack/query-core"; - -import { - createQualificationQueryCollection, - QualificationCollectionConflictError, - type QualificationPersistedUpdate, -} from "./queryCollectionAdapter"; - -interface QualificationItem { - id: string; - label: string; - version: number; -} - -const collectionKey = ["qualification", "items"] as const; - -describe("TanStack DB Query Collection adapter qualification", () => { - test("replaces snapshots and synchronizes direct batches with Query cache", async () => { - const queryClient = createQueryClient(); - let authoritative: QualificationItem[] = [ - { id: "a", label: "server-a", version: 1 }, - { id: "b", label: "server-b", version: 1 }, - ]; - const adapter = createQualificationQueryCollection({ - id: "qualification-items", - queryClient, - queryKey: collectionKey, - fetchSnapshot: () => Promise.resolve(structuredClone(authoritative)), - }); - - try { - await adapter.preload(); - expect(project(adapter.rows())).toEqual(authoritative); - expect(cachedItems(queryClient)).toEqual(authoritative); - - adapter.applyBatch([ - { - type: "upsert", - value: { id: "a", label: "delta-a", version: 2 }, - }, - { type: "delete", id: "b" }, - { - type: "upsert", - value: { id: "c", label: "delta-c", version: 1 }, - }, - ]); - const deltaRows = [ - { id: "a", label: "delta-a", version: 2 }, - { id: "c", label: "delta-c", version: 1 }, - ]; - expect(project(adapter.rows())).toEqual(deltaRows); - expect(cachedItems(queryClient)).toEqual(deltaRows); - - authoritative = [{ id: "a", label: "server-wins", version: 3 }]; - await adapter.refetchAuthoritative(); - expect(project(adapter.rows())).toEqual(authoritative); - expect(cachedItems(queryClient)).toEqual(authoritative); - } finally { - await adapter.dispose(); - } - }); - - test("lets the authoritative refetch win an optimistic version conflict", async () => { - const queryClient = createQueryClient(); - const persistence = Promise.withResolvers(); - const persistedUpdates: QualificationPersistedUpdate[][] = []; - const authoritative = [ - { id: "a", label: "authoritative", version: 2 }, - ] satisfies QualificationItem[]; - const adapter = createQualificationQueryCollection({ - id: "qualification-optimistic-items", - queryClient, - queryKey: collectionKey, - fetchSnapshot: () => Promise.resolve(structuredClone(authoritative)), - persistUpdates: async (updates) => { - persistedUpdates.push([...updates]); - await persistence.promise; - }, - }); - - try { - await adapter.preload(); - const update = adapter.updateOptimistically("a", 2, { - label: "speculative", - version: 3, - }); - expect(project(adapter.rows())).toEqual([ - { id: "a", label: "speculative", version: 3 }, - ]); - - persistence.resolve(); - await update; - expect(persistedUpdates).toEqual([ - [ - { - modified: { id: "a", label: "speculative", version: 3 }, - original: { id: "a", label: "authoritative", version: 2 }, - }, - ], - ]); - expect(project(adapter.rows())).toEqual(authoritative); - - let conflict: unknown; - try { - await adapter.updateOptimistically("a", 1, { - label: "stale", - version: 2, - }); - } catch (error) { - conflict = error; - } - expect(conflict).toBeInstanceOf(QualificationCollectionConflictError); - expect(project(adapter.rows())).toEqual(authoritative); - } finally { - await adapter.dispose(); - } - }); - - test("forwards AbortSignal and removes an in-flight query on teardown", async () => { - const queryClient = createQueryClient(); - const fetchStarted = Promise.withResolvers(); - const firstAdapter = createQualificationQueryCollection({ - id: "qualification-route-items", - queryClient, - queryKey: collectionKey, - fetchSnapshot: (signal) => { - fetchStarted.resolve(signal); - return new Promise((_resolve, reject) => { - signal.addEventListener( - "abort", - () => reject(new DOMException("Aborted", "AbortError")), - { once: true } - ); - }); - }, - }); - - const preload = firstAdapter.preload(); - const signal = await fetchStarted.promise; - expect(signal.aborted).toBeFalse(); - await firstAdapter.dispose(); - await preload; - expect(signal.aborted).toBeTrue(); - expect(firstAdapter.isDisposed).toBeTrue(); - expect(cachedItems(queryClient)).toBeUndefined(); - }); - - test("tears down route subscriptions without duplicate rows or listeners", async () => { - const queryClient = createQueryClient(); - const adapter = createQualificationQueryCollection({ - id: "qualification-route-items", - queryClient, - queryKey: collectionKey, - fetchSnapshot: () => - Promise.resolve([ - { id: "a", label: "first-route", version: 1 }, - ] satisfies QualificationItem[]), - }); - try { - await adapter.preload(); - let firstRouteNotifications = 0; - const unsubscribeFirstRoute = adapter.subscribe(() => { - firstRouteNotifications += 1; - }); - adapter.applyBatch([ - { - type: "upsert", - value: { id: "a", label: "first-update", version: 2 }, - }, - ]); - const notificationsAtTeardown = firstRouteNotifications; - unsubscribeFirstRoute(); - - let replacementRouteNotifications = 0; - const unsubscribeReplacementRoute = adapter.subscribe(() => { - replacementRouteNotifications += 1; - }); - adapter.applyBatch([ - { - type: "upsert", - value: { id: "a", label: "single-row", version: 3 }, - }, - ]); - unsubscribeReplacementRoute(); - - expect(notificationsAtTeardown).toBe(2); - expect(firstRouteNotifications).toBe(2); - expect(replacementRouteNotifications).toBe(2); - expect(project(adapter.rows())).toEqual([ - { id: "a", label: "single-row", version: 3 }, - ]); - expect(cachedItems(queryClient)).toEqual([ - { id: "a", label: "single-row", version: 3 }, - ]); - } finally { - await adapter.dispose(); - } - expect(cachedItems(queryClient)).toBeUndefined(); - }); - - test("runs against the exact installed TanStack dependency set", async () => { - expect(await readInstalledVersions()).toEqual({ - "@tanstack/db": "0.6.17", - "@tanstack/query-core": "5.101.4", - "@tanstack/query-db-collection": "1.2.1", - "@tanstack/react-db": "0.1.95", - }); - }); -}); - -function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); -} - -function project(items: readonly QualificationItem[]): QualificationItem[] { - return items.map(({ id, label, version }) => ({ id, label, version })); -} - -function cachedItems(queryClient: QueryClient): QualificationItem[] | undefined { - return queryClient.getQueryData(collectionKey); -} - -async function readInstalledVersions(): Promise> { - const packageNames = [ - "@tanstack/db", - "@tanstack/query-core", - "@tanstack/query-db-collection", - "@tanstack/react-db", - ] as const; - const versions: Record = {}; - for (const packageName of packageNames) { - const packageJsonPath = Bun.resolveSync( - `${packageName}/package.json`, - import.meta.dir - ); - const parsed: unknown = JSON.parse(await Bun.file(packageJsonPath).text()); - if ( - typeof parsed !== "object" || - parsed === null || - !("version" in parsed) || - typeof parsed.version !== "string" - ) { - throw new Error(`${packageName} has no package version`); - } - versions[packageName] = parsed.version; - } - return versions; -} diff --git a/qualification/browser/queryCollectionAdapter.ts b/qualification/browser/queryCollectionAdapter.ts deleted file mode 100644 index cf6481cfe..000000000 --- a/qualification/browser/queryCollectionAdapter.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { QueryClient, type QueryKey } from "@tanstack/query-core"; -import { queryCollectionOptions } from "@tanstack/query-db-collection"; -import { createCollection, type UpdateMutationFnParams } from "@tanstack/react-db"; - -export interface QualificationVersionedEntity { - id: string; - version: number; -} - -export type QualificationCollectionDelta = - | { type: "delete"; id: string } - | { type: "upsert"; value: T }; - -export interface QualificationPersistedUpdate { - modified: T; - original: T; -} - -export interface QualificationQueryCollectionOptions< - T extends QualificationVersionedEntity, -> { - fetchSnapshot: (signal: AbortSignal) => Promise; - id: string; - persistUpdates?: ( - updates: readonly QualificationPersistedUpdate[] - ) => Promise; - queryClient: QueryClient; - queryKey: QueryKey; -} - -export class QualificationCollectionConflictError extends Error { - readonly _tag = "QualificationCollectionConflictError"; -} - -/** - * Qualification-only seam around the current pre-1.0 Query Collection API. - * It exercises the intended browser ownership without creating production code. - * @param options Query client, snapshot, and persistence dependencies. - * @returns Qualification collection adapter. - */ -export function createQualificationQueryCollection< - T extends QualificationVersionedEntity, ->(options: QualificationQueryCollectionOptions) { - const stableQueryKey = [...options.queryKey]; - let disposed = false; - const onUpdate = options.persistUpdates - ? async ({ transaction }: UpdateMutationFnParams) => { - await options.persistUpdates?.( - transaction.mutations.map(({ modified, original }) => ({ - modified, - original, - })) - ); - } - : undefined; - - const collection = createCollection( - queryCollectionOptions({ - id: options.id, - queryClient: options.queryClient, - queryFn: async ({ signal }) => [...(await options.fetchSnapshot(signal))], - queryKey: stableQueryKey, - getKey: (entity: T) => entity.id, - ...(onUpdate ? { onUpdate } : {}), - }) - ); - - function assertActive(): void { - if (disposed) { - throw new Error(`Qualification collection ${options.id} is disposed`); - } - } - - return { - applyBatch(deltas: readonly QualificationCollectionDelta[]): void { - assertActive(); - collection.utils.writeBatch(() => { - for (const delta of deltas) { - if (delta.type === "delete") { - collection.utils.writeDelete(delta.id); - } else { - collection.utils.writeUpsert(delta.value); - } - } - }); - }, - async dispose(): Promise { - if (disposed) return; - disposed = true; - await collection.cleanup(); - options.queryClient.removeQueries({ - exact: true, - queryKey: stableQueryKey, - }); - }, - get(id: string): T | undefined { - assertActive(); - return collection.get(id); - }, - get isDisposed(): boolean { - return disposed; - }, - preload(): Promise { - assertActive(); - return collection.preload(); - }, - async refetchAuthoritative(): Promise { - assertActive(); - await collection.utils.refetch({ throwOnError: true }); - }, - rows(): readonly T[] { - assertActive(); - return collection.toArray; - }, - subscribe(listener: (rows: readonly T[]) => void): () => void { - assertActive(); - const subscription = collection.subscribeChanges( - () => listener(collection.toArray), - { includeInitialState: true } - ); - return () => subscription.unsubscribe(); - }, - async updateOptimistically( - id: string, - expectedVersion: number, - changes: Partial - ): Promise { - assertActive(); - if (!options.persistUpdates) { - throw new Error( - `Qualification collection ${options.id} has no mutation persistence` - ); - } - const current = collection.get(id); - if (!current || current.version !== expectedVersion) { - throw new QualificationCollectionConflictError( - `Expected ${id} at version ${expectedVersion}` - ); - } - if (changes.id !== undefined && changes.id !== id) { - throw new QualificationCollectionConflictError( - "An optimistic update cannot change its entity id" - ); - } - - const transaction = collection.update(id, (draft) => { - Object.assign(draft, changes); - }); - await transaction.isPersisted.promise; - }, - }; -} diff --git a/qualification/build/runFrontendBuildQualification.ts b/qualification/build/runFrontendBuildQualification.ts deleted file mode 100644 index 1690dbb4c..000000000 --- a/qualification/build/runFrontendBuildQualification.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { buildFrontend } from "../../scripts/frontendBuild"; -import { assertSelfHostedFrontendHtml } from "./frontendBuildQualification"; - -const hashedAssetPattern = /^assets\/.+-[a-z\d]{8}\.(?:css|js)$/u; - -export interface ActualFrontendBuildEvidence { - compressedSidecarCount: number; - formatVersion: 1; - hashedAssetCount: number; - outputFileCount: number; - sourceMapsIncluded: false; -} - -/** - * Runs the selected existing production build in a separate qualification process. - * @param outdir Disposable build output directory. - * @returns Actual frontend build evidence. - */ -export async function runActualFrontendBuildQualification( - outdir: string -): Promise { - await buildFrontend({ mode: "production", outdir }); - const files = await listRelativeFiles(outdir); - const hashedAssetCount = files.filter((file) => hashedAssetPattern.test(file)).length; - const compressedSidecarCount = files.filter( - (file) => file.endsWith(".br") || file.endsWith(".gz") - ).length; - const sourceMapsIncluded = files.some((file) => file.endsWith(".map")); - const metrics = await readFile( - path.join(outdir, "frontend-bundle-metrics.json"), - "utf8" - ); - let parsedMetrics: unknown; - try { - parsedMetrics = JSON.parse(metrics) as unknown; - } catch { - parsedMetrics = undefined; - } - - if (hashedAssetCount < 10) { - throw new Error("Existing frontend build did not emit hashed route assets"); - } - if (compressedSidecarCount === 0) { - throw new Error("Existing frontend build did not emit compressed sidecars"); - } - if (sourceMapsIncluded) { - throw new Error("Production frontend build emitted source maps"); - } - if ( - typeof parsedMetrics !== "object" || - parsedMetrics === null || - !("formatVersion" in parsedMetrics) || - parsedMetrics.formatVersion !== 1 - ) { - throw new Error("Existing frontend build emitted an unknown metrics format"); - } - await assertSelfHostedFrontendHtml(path.join(outdir, "index.html")); - - return { - compressedSidecarCount, - formatVersion: 1, - hashedAssetCount, - outputFileCount: files.length, - sourceMapsIncluded: false, - }; -} - -async function listRelativeFiles(directory: string): Promise { - const files: string[] = []; - const pending = [directory]; - while (pending.length > 0) { - const current = pending.pop(); - if (!current) continue; - for (const entry of await readdir(current, { withFileTypes: true })) { - const entryPath = path.join(current, entry.name); - if (entry.isDirectory()) { - pending.push(entryPath); - } else if (entry.isFile()) { - files.push(path.relative(directory, entryPath).replaceAll("\\", "/")); - } - } - } - return files.toSorted(); -} - -if (import.meta.main) { - const outdir = await mkdtemp(path.join(tmpdir(), "mira-frontend-build-evidence-")); - try { - // eslint-disable-next-line no-console -- The manual runner emits its evidence artifact. - console.log( - JSON.stringify( - await runActualFrontendBuildQualification(outdir), - undefined, - 2 - ) - ); - } finally { - await rm(outdir, { force: true, recursive: true }); - } -} diff --git a/qualification/chat/chatBatching.test.ts b/qualification/chat/chatBatching.test.ts deleted file mode 100644 index 6ba6a095e..000000000 --- a/qualification/chat/chatBatching.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { loadReviewedOpenClawFixtures } from "../openclaw/reviewedFixtures.ts"; -import { simulateChatBatching } from "./chatBatchingModel.ts"; -import { - buildChatBatchingTrace, - chatBatchingCandidateIntervalsMs, - chatBatchingConcurrencyLevels, - qualifyChatBatching, -} from "./chatBatchingQualification.ts"; - -describe("current OpenClaw chat batching qualification", () => { - test("selects the smallest bounded interval from every reviewed candidate", async () => { - const { audit } = await loadReviewedOpenClawFixtures(); - const evidence = qualifyChatBatching(audit.chat); - - expect(evidence).toMatchObject({ - maximumAdditionalVisualDelayMs: 150, - maximumCrashWindowMs: 150, - maximumScheduledTransactionsPerSecond: 7, - selectedIntervalMs: 150, - sourceDeltaThrottleMs: 150, - }); - expect([ - ...new Set(evidence.candidates.map(({ metrics }) => metrics.intervalMs)), - ]).toEqual([...chatBatchingCandidateIntervalsMs]); - expect([ - ...new Set(evidence.candidates.map(({ concurrency }) => concurrency)), - ]).toEqual([...chatBatchingConcurrencyLevels]); - expect( - chatBatchingCandidateIntervalsMs - .filter((intervalMs) => intervalMs < 150) - .every((intervalMs) => - evidence.candidates - .filter(({ metrics }) => metrics.intervalMs === intervalMs) - .some(({ accepted }) => !accepted) - ) - ).toBeTrue(); - expect( - evidence.candidates - .filter(({ metrics }) => metrics.intervalMs === 150) - .every(({ accepted }) => accepted) - ).toBeTrue(); - expect( - evidence.candidates - .filter(({ metrics }) => metrics.intervalMs > 150) - .every(({ accepted }) => !accepted) - ).toBeTrue(); - }); - - test("flushes tool and terminal boundaries without losing ordered events", async () => { - const { audit } = await loadReviewedOpenClawFixtures(); - const trace = buildChatBatchingTrace(audit.chat, 8); - const metrics = simulateChatBatching(trace, 150); - - expect(metrics.committedEvents).toBe(trace.length); - expect(metrics.boundaryMaximumCommitDelayMs).toBe(0); - expect(metrics.terminalMaximumCommitDelayMs).toBe(0); - expect(metrics.maximumCommitDelayMs).toBeLessThanOrEqual(150); - expect(metrics.transactions).toBeLessThan(trace.length); - expect(metrics.scheduledTransactions).toBeLessThan(trace.length); - expect(metrics.maximumPendingBytes).toBeGreaterThan(0); - expect(metrics.boundaryTransactions).toBeGreaterThan(0); - }); - - test("is deterministic and rejects malformed sequence or interval input", async () => { - const { audit } = await loadReviewedOpenClawFixtures(); - const trace = buildChatBatchingTrace(audit.chat, 4); - expect(qualifyChatBatching(audit.chat)).toEqual(qualifyChatBatching(audit.chat)); - expect(() => simulateChatBatching(trace, 0)).toThrow(); - expect(() => - simulateChatBatching( - trace.map((event, index) => - index === 0 ? { ...event, sequence: 2 } : event - ), - 150 - ) - ).toThrow("sequence is not contiguous"); - }); -}); diff --git a/qualification/chat/chatBatchingModel.ts b/qualification/chat/chatBatchingModel.ts deleted file mode 100644 index 38b681f09..000000000 --- a/qualification/chat/chatBatchingModel.ts +++ /dev/null @@ -1,238 +0,0 @@ -export type ChatBatchTraceEventKind = "boundary" | "delta" | "terminal"; - -export interface ChatBatchTraceEvent { - readonly arrivedAtMs: number; - readonly kind: ChatBatchTraceEventKind; - readonly payloadBytes: number; - readonly runId: string; - readonly sequence: number; - readonly stream?: "assistant" | "thinking"; -} - -export interface ChatBatchingBatch { - readonly commitAtMs: number; - readonly durableBytes: number; - readonly durableRows: number; - readonly eventCount: number; - readonly reason: "boundary" | "interval"; -} - -export interface ChatBatchingMetrics { - readonly batches: readonly ChatBatchingBatch[]; - readonly boundaryMaximumCommitDelayMs: number; - readonly boundaryTransactions: number; - readonly committedEvents: number; - readonly durableBytes: number; - readonly durableRows: number; - readonly inputBytes: number; - readonly inputEvents: number; - readonly intervalMs: number; - readonly maximumCommitDelayMs: number; - readonly maximumPendingBytes: number; - readonly p95CommitDelayMs: number; - readonly peakScheduledTransactionsPerSecond: number; - readonly scheduledTransactions: number; - readonly terminalMaximumCommitDelayMs: number; - readonly transactions: number; -} - -interface DurableRecord { - eventCount: number; - firstSequence: number; - kind: ChatBatchTraceEventKind; - lastSequence: number; - payloadBytes: number; - runId: string; - stream?: "assistant" | "thinking"; -} - -const textEncoder = new TextEncoder(); - -function compareTraceEvents( - left: ChatBatchTraceEvent, - right: ChatBatchTraceEvent -): number { - if (left.arrivedAtMs !== right.arrivedAtMs) { - return left.arrivedAtMs - right.arrivedAtMs; - } - if (left.runId !== right.runId) return left.runId < right.runId ? -1 : 1; - return left.sequence - right.sequence; -} - -function assertTrace(events: readonly ChatBatchTraceEvent[]): void { - const nextSequenceByRun = new Map(); - for (const event of events.toSorted(compareTraceEvents)) { - if ( - !Number.isSafeInteger(event.arrivedAtMs) || - event.arrivedAtMs < 0 || - !Number.isSafeInteger(event.payloadBytes) || - event.payloadBytes < 1 - ) { - throw new RangeError("Chat batching trace contains invalid bounds"); - } - const expectedSequence = nextSequenceByRun.get(event.runId) ?? 1; - if (event.sequence !== expectedSequence) { - throw new Error("Chat batching trace sequence is not contiguous"); - } - if (event.kind === "delta" && event.stream === undefined) { - throw new Error("Chat batching delta is missing its stream"); - } - if (event.kind !== "delta" && event.stream !== undefined) { - throw new Error("Chat batching boundary unexpectedly declares a stream"); - } - nextSequenceByRun.set(event.runId, expectedSequence + 1); - } -} - -function coalesceDurableRecords( - events: readonly ChatBatchTraceEvent[] -): readonly DurableRecord[] { - const records: DurableRecord[] = []; - const latestRecordByRun = new Map(); - for (const event of events) { - const latest = latestRecordByRun.get(event.runId); - if ( - event.kind === "delta" && - latest?.kind === "delta" && - latest.stream === event.stream - ) { - latest.eventCount += 1; - latest.lastSequence = event.sequence; - latest.payloadBytes += event.payloadBytes; - continue; - } - const record: DurableRecord = { - eventCount: 1, - firstSequence: event.sequence, - kind: event.kind, - lastSequence: event.sequence, - payloadBytes: event.payloadBytes, - runId: event.runId, - ...(event.stream === undefined ? {} : { stream: event.stream }), - }; - records.push(record); - latestRecordByRun.set(event.runId, record); - } - return records; -} - -function serializedBytes(value: unknown): number { - return textEncoder.encode(JSON.stringify(value)).byteLength; -} - -function percentile95(values: readonly number[]): number { - if (values.length === 0) return 0; - const sorted = values.toSorted((left, right) => left - right); - return sorted[Math.ceil((sorted.length * 95) / 100) - 1] ?? 0; -} - -function peakTransactionsPerSecond(commitTimes: readonly number[]): number { - let peak = 0; - let start = 0; - for (let end = 0; end < commitTimes.length; end += 1) { - while (commitTimes[end]! - commitTimes[start]! >= 1000) start += 1; - peak = Math.max(peak, end - start + 1); - } - return peak; -} - -/** - * Simulates one process-wide fixed-window journal batcher without wall-clock timing. - * Semantic boundaries and terminal states always flush immediately; only deltas wait. - * - * @param inputEvents Ordered source-shaped chat events to persist. - * @param intervalMs Fixed batching interval under evaluation. - * @returns Deterministic persistence and latency metrics for the trace. - */ -export function simulateChatBatching( - inputEvents: readonly ChatBatchTraceEvent[], - intervalMs: number -): ChatBatchingMetrics { - if (!Number.isSafeInteger(intervalMs) || intervalMs < 1) { - throw new RangeError("Chat batching interval must be a positive safe integer"); - } - assertTrace(inputEvents); - const events = inputEvents.toSorted(compareTraceEvents); - const batches: ChatBatchingBatch[] = []; - const commitDelays: number[] = []; - const scheduledCommitTimes: number[] = []; - let maximumPendingBytes = 0; - let pending: ChatBatchTraceEvent[] = []; - let pendingBytes = 0; - let pendingDeadlineMs: number | undefined; - let boundaryMaximumCommitDelayMs = 0; - let terminalMaximumCommitDelayMs = 0; - - const flush = (commitAtMs: number, reason: ChatBatchingBatch["reason"]): void => { - if (pending.length === 0) return; - const records = coalesceDurableRecords(pending); - for (const event of pending) { - const delay = commitAtMs - event.arrivedAtMs; - if (delay < 0) throw new Error("Chat batching committed before arrival"); - commitDelays.push(delay); - if (event.kind === "terminal") { - terminalMaximumCommitDelayMs = Math.max( - terminalMaximumCommitDelayMs, - delay - ); - } - if (event.kind === "boundary") { - boundaryMaximumCommitDelayMs = Math.max( - boundaryMaximumCommitDelayMs, - delay - ); - } - } - batches.push({ - commitAtMs, - durableBytes: serializedBytes(records), - durableRows: records.length, - eventCount: pending.length, - reason, - }); - if (reason === "interval") scheduledCommitTimes.push(commitAtMs); - pending = []; - pendingBytes = 0; - pendingDeadlineMs = undefined; - }; - - for (const event of events) { - if (pendingDeadlineMs !== undefined && pendingDeadlineMs <= event.arrivedAtMs) { - flush(pendingDeadlineMs, "interval"); - } - pending.push(event); - pendingBytes += event.payloadBytes; - maximumPendingBytes = Math.max(maximumPendingBytes, pendingBytes); - if (event.kind === "delta") { - pendingDeadlineMs ??= event.arrivedAtMs + intervalMs; - } else { - flush(event.arrivedAtMs, "boundary"); - } - } - if (pendingDeadlineMs !== undefined) flush(pendingDeadlineMs, "interval"); - let maximumCommitDelayMs = 0; - for (const delay of commitDelays) { - maximumCommitDelayMs = Math.max(maximumCommitDelayMs, delay); - } - - return Object.freeze({ - batches: Object.freeze(batches), - boundaryMaximumCommitDelayMs, - boundaryTransactions: batches.filter(({ reason }) => reason === "boundary") - .length, - committedEvents: batches.reduce((total, batch) => total + batch.eventCount, 0), - durableBytes: batches.reduce((total, batch) => total + batch.durableBytes, 0), - durableRows: batches.reduce((total, batch) => total + batch.durableRows, 0), - inputBytes: events.reduce((total, event) => total + event.payloadBytes, 0), - inputEvents: events.length, - intervalMs, - maximumCommitDelayMs, - maximumPendingBytes, - p95CommitDelayMs: percentile95(commitDelays), - peakScheduledTransactionsPerSecond: - peakTransactionsPerSecond(scheduledCommitTimes), - scheduledTransactions: scheduledCommitTimes.length, - terminalMaximumCommitDelayMs, - transactions: batches.length, - }); -} diff --git a/qualification/chat/chatBatchingQualification.ts b/qualification/chat/chatBatchingQualification.ts deleted file mode 100644 index 88dfd2f10..000000000 --- a/qualification/chat/chatBatchingQualification.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { ChatFixture } from "../openclaw/sourceAuditSchemas.ts"; -import { - simulateChatBatching, - type ChatBatchingMetrics, - type ChatBatchTraceEvent, -} from "./chatBatchingModel.ts"; - -export const chatBatchingCandidateIntervalsMs = [50, 100, 150, 200, 250, 500] as const; -export const chatBatchingConcurrencyLevels = [1, 4, 8] as const; - -export interface ChatBatchingCandidateEvidence { - readonly accepted: boolean; - readonly concurrency: number; - readonly metrics: ChatBatchingMetrics; - readonly rejectionReasons: readonly string[]; -} - -export interface ChatBatchingQualificationEvidence { - readonly candidates: readonly ChatBatchingCandidateEvidence[]; - readonly maximumAdditionalVisualDelayMs: number; - readonly maximumCrashWindowMs: number; - readonly maximumScheduledTransactionsPerSecond: number; - readonly selectedIntervalMs: number; - readonly sourceDeltaThrottleMs: number; -} - -type SyntheticChatEvent = ChatFixture["syntheticScenarios"][number]["events"][number]; - -const textEncoder = new TextEncoder(); - -function fixtureEvent( - fixture: ChatFixture, - scenarioId: string, - kind: SyntheticChatEvent["kind"] -): SyntheticChatEvent { - const scenario = fixture.syntheticScenarios.find(({ id }) => id === scenarioId); - const event = scenario?.events.find((candidate) => candidate.kind === kind); - if (event === undefined) { - throw new Error(`Reviewed chat fixture lacks ${scenarioId}/${kind}`); - } - return event; -} - -function payloadBytes( - runId: string, - sequence: number, - event: SyntheticChatEvent -): number { - return textEncoder.encode(JSON.stringify({ ...event, runId, seq: sequence })) - .byteLength; -} - -/** - * Builds a deterministic, source-shaped streaming load without host runtime data. - * - * @param fixture Reviewed, version-pinned OpenClaw chat fixture. - * @param concurrency Number of interleaved synthetic runs. - * @returns A bounded deterministic trace for the batching simulator. - */ -export function buildChatBatchingTrace( - fixture: ChatFixture, - concurrency: number -): readonly ChatBatchTraceEvent[] { - if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 32) { - throw new RangeError("Chat batching concurrency is outside qualification bounds"); - } - const throttleMs = fixture.streamingPolicy.deltaThrottleMs; - const agentDeltaForStream = (stream: "assistant" | "thinking") => - fixture.syntheticScenarios - .flatMap(({ events }) => events) - .find( - (event): event is Extract => - event.kind === "agent-delta" && event.stream === stream - ); - const thinking = agentDeltaForStream("thinking"); - const assistant = agentDeltaForStream("assistant"); - if (thinking === undefined || assistant === undefined) { - throw new Error("Reviewed chat fixture lacks both coalesced agent streams"); - } - const toolStart = fixtureEvent(fixture, "completed-tool-run", "tool-start"); - const toolResult = fixtureEvent(fixture, "completed-tool-run", "tool-result"); - const chatDelta = fixtureEvent(fixture, "completed-tool-run", "chat-delta"); - const final = fixtureEvent(fixture, "completed-tool-run", "chat-terminal"); - const aborted = fixtureEvent(fixture, "cancelled-run", "chat-terminal"); - const events: ChatBatchTraceEvent[] = []; - - for (let runIndex = 0; runIndex < concurrency; runIndex += 1) { - const runId = `qualification-run-${runIndex + 1}`; - const offsetMs = Math.floor((throttleMs * runIndex) / concurrency); - let sequence = 0; - const append = ( - arrivedAtMs: number, - kind: ChatBatchTraceEvent["kind"], - template: SyntheticChatEvent, - stream?: "assistant" | "thinking" - ): void => { - sequence += 1; - events.push({ - arrivedAtMs, - kind, - payloadBytes: payloadBytes(runId, sequence, template), - runId, - sequence, - ...(stream === undefined ? {} : { stream }), - }); - }; - - for (let deltaIndex = 0; deltaIndex < 48; deltaIndex += 1) { - const arrivedAtMs = offsetMs + deltaIndex * throttleMs; - const stream = deltaIndex < 12 ? "thinking" : "assistant"; - append( - arrivedAtMs, - "delta", - stream === "thinking" ? thinking : assistant, - stream - ); - if (deltaIndex === 12) { - append(arrivedAtMs + Math.floor(throttleMs / 3), "boundary", toolStart); - append( - arrivedAtMs + Math.floor((throttleMs * 2) / 3), - "boundary", - toolResult - ); - } - } - const finalDeltaAtMs = offsetMs + 48 * throttleMs; - append(finalDeltaAtMs, "delta", chatDelta, "assistant"); - append( - finalDeltaAtMs + Math.floor(throttleMs / 2), - "terminal", - runIndex % 2 === 0 ? final : aborted - ); - } - return Object.freeze(events); -} - -function candidateRejectionReasons( - metrics: ChatBatchingMetrics, - fixture: ChatFixture -): readonly string[] { - const throttleMs = fixture.streamingPolicy.deltaThrottleMs; - const maximumAdditionalVisualDelayMs = throttleMs; - const maximumCrashWindowMs = throttleMs; - const maximumScheduledTransactionsPerSecond = Math.ceil(1000 / throttleMs); - return Object.freeze([ - ...(metrics.maximumCommitDelayMs > maximumAdditionalVisualDelayMs - ? ["visual-delay-exceeds-one-source-tick"] - : []), - ...(metrics.maximumCommitDelayMs > maximumCrashWindowMs - ? ["crash-window-exceeds-one-source-tick"] - : []), - ...(metrics.peakScheduledTransactionsPerSecond > - maximumScheduledTransactionsPerSecond - ? ["scheduled-write-rate-exceeds-source-cadence"] - : []), - ...(metrics.terminalMaximumCommitDelayMs === 0 - ? [] - : ["terminal-event-was-not-flushed-immediately"]), - ...(metrics.boundaryMaximumCommitDelayMs === 0 - ? [] - : ["semantic-boundary-was-not-flushed-immediately"]), - ...(metrics.committedEvents === metrics.inputEvents - ? [] - : ["event-count-mismatch"]), - ]); -} - -/** - * Evaluates every reviewed interval at one, four, and eight concurrent runs. - * - * @param fixture Reviewed, version-pinned OpenClaw chat fixture. - * @returns Candidate evidence and the smallest interval satisfying every bound. - */ -export function qualifyChatBatching( - fixture: ChatFixture -): ChatBatchingQualificationEvidence { - const candidates = chatBatchingCandidateIntervalsMs.flatMap((intervalMs) => - chatBatchingConcurrencyLevels.map((concurrency) => { - const metrics = simulateChatBatching( - buildChatBatchingTrace(fixture, concurrency), - intervalMs - ); - const rejectionReasons = candidateRejectionReasons(metrics, fixture); - return Object.freeze({ - accepted: rejectionReasons.length === 0, - concurrency, - metrics, - rejectionReasons, - }); - }) - ); - const selectedIntervalMs = chatBatchingCandidateIntervalsMs.find((intervalMs) => - candidates - .filter((candidate) => candidate.metrics.intervalMs === intervalMs) - .every(({ accepted }) => accepted) - ); - if (selectedIntervalMs === undefined) { - throw new Error("No chat batching candidate satisfies the reviewed policy"); - } - const sourceDeltaThrottleMs = fixture.streamingPolicy.deltaThrottleMs; - return Object.freeze({ - candidates: Object.freeze(candidates), - maximumAdditionalVisualDelayMs: sourceDeltaThrottleMs, - maximumCrashWindowMs: sourceDeltaThrottleMs, - maximumScheduledTransactionsPerSecond: Math.ceil(1000 / sourceDeltaThrottleMs), - selectedIntervalMs, - sourceDeltaThrottleMs, - }); -} diff --git a/qualification/chat/runChatBatchingQualification.ts b/qualification/chat/runChatBatchingQualification.ts deleted file mode 100644 index 0cd5d6543..000000000 --- a/qualification/chat/runChatBatchingQualification.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { loadReviewedOpenClawFixtures } from "../openclaw/reviewedFixtures.ts"; -import { qualifyChatBatching } from "./chatBatchingQualification.ts"; - -/** Prints deterministic reviewed evidence without reading host runtime state. */ -export async function runChatBatchingQualification(): Promise { - const { audit, manifest } = await loadReviewedOpenClawFixtures(); - const evidence = qualifyChatBatching(audit.chat); - process.stdout.write( - `${JSON.stringify( - { - candidates: evidence.candidates.map( - ({ accepted, concurrency, metrics, rejectionReasons }) => ({ - accepted, - boundaryMaximumCommitDelayMs: - metrics.boundaryMaximumCommitDelayMs, - concurrency, - durableBytes: metrics.durableBytes, - durableRows: metrics.durableRows, - inputBytes: metrics.inputBytes, - inputEvents: metrics.inputEvents, - intervalMs: metrics.intervalMs, - maximumCommitDelayMs: metrics.maximumCommitDelayMs, - maximumPendingBytes: metrics.maximumPendingBytes, - p95CommitDelayMs: metrics.p95CommitDelayMs, - peakScheduledTransactionsPerSecond: - metrics.peakScheduledTransactionsPerSecond, - rejectionReasons, - scheduledTransactions: metrics.scheduledTransactions, - terminalMaximumCommitDelayMs: - metrics.terminalMaximumCommitDelayMs, - transactions: metrics.transactions, - }) - ), - maximumAdditionalVisualDelayMs: evidence.maximumAdditionalVisualDelayMs, - maximumCrashWindowMs: evidence.maximumCrashWindowMs, - maximumScheduledTransactionsPerSecond: - evidence.maximumScheduledTransactionsPerSecond, - openClawCommit: manifest.source.commit, - openClawVersion: manifest.source.version, - selectedIntervalMs: evidence.selectedIntervalMs, - sourceDeltaThrottleMs: evidence.sourceDeltaThrottleMs, - }, - null, - 2 - )}\n` - ); -} - -if (import.meta.main) await runChatBatchingQualification(); diff --git a/qualification/database/database.test.ts b/qualification/database/database.test.ts deleted file mode 100644 index b574f2047..000000000 --- a/qualification/database/database.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { parseISO } from "date-fns"; -import { eq, sql } from "drizzle-orm"; -import * as v from "valibot"; - -import { createQualificationDatabase } from "./database.ts"; -import { - qualificationEvents, - qualificationIncidentInsertSchema, - qualificationIncidents, - qualificationIncidentSelectSchema, -} from "./schema.ts"; - -interface QueryPlanRow { - detail: string; -} - -describe("Drizzle on Bun SQLite", () => { - test("keeps typed queries, raw SQL, constraints, and native access", () => { - const database = createQualificationDatabase(); - const openedAt = parseISO("2026-08-03T20:00:00.000Z"); - const incidentKey = "system:filesystem:root-pressure"; - - try { - const insert = v.parse(qualificationIncidentInsertSchema, { - incidentKey, - lastSeenAt: openedAt, - status: "open", - }); - - const incidentId = database.orm.transaction((transaction) => { - const incident = transaction - .insert(qualificationIncidents) - .values(insert) - .returning({ id: qualificationIncidents.id }) - .get(); - - transaction - .insert(qualificationEvents) - .values({ - aggregateId: incident.id, - createdAt: openedAt, - payload: JSON.stringify({ incidentKey }), - topic: "incident.opened", - }) - .run(); - - return incident.id; - }); - - const preparedIncident = database.orm - .select() - .from(qualificationIncidents) - .where(eq(qualificationIncidents.id, sql.placeholder("incidentId"))) - .prepare(); - const selected = preparedIncident.get({ incidentId }); - - expect(v.parse(qualificationIncidentSelectSchema, selected)).toEqual({ - id: incidentId, - incidentKey, - lastSeenAt: openedAt, - resolvedAt: null, - status: "open", - }); - expect(database.orm.$client).toBe(database.sqlite); - - expect(() => - database.orm - .insert(qualificationIncidents) - .values({ - incidentKey, - lastSeenAt: openedAt, - status: "open", - }) - .run() - ).toThrow(); - - const rawRows = database.orm.all<{ incidentKey: string }>(sql` - SELECT incident_key AS incidentKey - FROM qualification_incidents - WHERE incident_key = ${incidentKey} - `); - expect(rawRows).toEqual([{ incidentKey }]); - - const plan = database.sqlite - .query(` - EXPLAIN QUERY PLAN - SELECT id - FROM qualification_incidents - WHERE status = ? - ORDER BY last_seen_at DESC - `) - .all("open"); - expect(plan.some((row) => row.detail.includes("status_seen_idx"))).toBeTrue(); - - expect( - database.sqlite - .query<{ eventCount: number }, []>( - "SELECT count(*) AS eventCount FROM qualification_events" - ) - .get() - ).toEqual({ eventCount: 1 }); - } finally { - database.sqlite.close(true); - } - }); - - test("rolls back synchronous transactions atomically", () => { - const database = createQualificationDatabase(); - - try { - expect(() => - database.orm.transaction((transaction) => { - transaction - .insert(qualificationIncidents) - .values({ - incidentKey: "system:memory:pressure", - lastSeenAt: parseISO("2026-08-03T20:05:00.000Z"), - status: "open", - }) - .run(); - throw new Error("qualification rollback"); - }) - ).toThrow("qualification rollback"); - - expect(database.orm.select().from(qualificationIncidents).all()).toEqual([]); - } finally { - database.sqlite.close(true); - } - }); -}); diff --git a/qualification/database/database.ts b/qualification/database/database.ts deleted file mode 100644 index 90ce746ee..000000000 --- a/qualification/database/database.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Database } from "bun:sqlite"; - -import { drizzle } from "drizzle-orm/bun-sqlite"; - -const qualificationDatabaseStatements = [ - "PRAGMA foreign_keys = ON", - `CREATE TABLE qualification_incidents ( - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - incident_key TEXT NOT NULL, - last_seen_at INTEGER NOT NULL, - resolved_at INTEGER, - status TEXT NOT NULL, - CONSTRAINT qualification_incidents_resolution_check CHECK ( - (status = 'open' AND resolved_at IS NULL) - OR (status = 'resolved' AND resolved_at IS NOT NULL) - ) - ) STRICT`, - `CREATE UNIQUE INDEX qualification_incidents_active_key_unique - ON qualification_incidents (incident_key) - WHERE resolved_at IS NULL`, - `CREATE INDEX qualification_incidents_status_seen_idx - ON qualification_incidents (status, last_seen_at)`, - `CREATE TABLE qualification_events ( - aggregate_id INTEGER NOT NULL REFERENCES qualification_incidents(id) ON DELETE CASCADE, - created_at INTEGER NOT NULL, - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - payload TEXT NOT NULL, - topic TEXT NOT NULL - ) STRICT`, - `CREATE INDEX qualification_events_topic_id_idx - ON qualification_events (topic, id)`, -] as const; - -/** - * Opens a strict in-memory SQLite database through both Bun and Drizzle. - * @returns Paired native and typed database clients. - */ -export function createQualificationDatabase() { - const sqlite = new Database(":memory:", { strict: true }); - for (const statement of qualificationDatabaseStatements) { - sqlite.run(statement); - } - - const orm = drizzle({ client: sqlite }); - - return { orm, sqlite }; -} diff --git a/qualification/database/schema.ts b/qualification/database/schema.ts deleted file mode 100644 index bf784d044..000000000 --- a/qualification/database/schema.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { sql } from "drizzle-orm"; -import { - check, - index, - integer, - sqliteTable, - text, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; -import { createInsertSchema, createSelectSchema } from "drizzle-orm/valibot"; - -const incidentStatuses = ["open", "resolved"] as const; - -/** Minimal incident table used to qualify Drizzle's SQLite feature set. */ -export const qualificationIncidents = sqliteTable( - "qualification_incidents", - { - id: integer("id").primaryKey({ autoIncrement: true }), - incidentKey: text("incident_key").notNull(), - lastSeenAt: integer("last_seen_at", { mode: "timestamp_ms" }).notNull(), - resolvedAt: integer("resolved_at", { mode: "timestamp_ms" }), - status: text("status", { enum: incidentStatuses }).notNull(), - }, - (table) => [ - check( - "qualification_incidents_resolution_check", - sql`(${table.status} = 'open' AND ${table.resolvedAt} IS NULL) OR (${table.status} = 'resolved' AND ${table.resolvedAt} IS NOT NULL)` - ), - uniqueIndex("qualification_incidents_active_key_unique") - .on(table.incidentKey) - .where(sql`${table.resolvedAt} IS NULL`), - index("qualification_incidents_status_seen_idx").on( - table.status, - table.lastSeenAt - ), - ] -); - -/** Minimal transactional outbox table used by the database qualification. */ -export const qualificationEvents = sqliteTable( - "qualification_events", - { - aggregateId: integer("aggregate_id") - .notNull() - .references(() => qualificationIncidents.id, { onDelete: "cascade" }), - createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), - id: integer("id").primaryKey({ autoIncrement: true }), - payload: text("payload").notNull(), - topic: text("topic").notNull(), - }, - (table) => [index("qualification_events_topic_id_idx").on(table.topic, table.id)] -); - -/** Valibot schema generated from the Drizzle incident select model. */ -export const qualificationIncidentSelectSchema = - createSelectSchema(qualificationIncidents); - -/** Valibot schema generated from the Drizzle incident insert model. */ -export const qualificationIncidentInsertSchema = - createInsertSchema(qualificationIncidents); - -/** Drizzle tables supplied to the qualified database client. */ -export const qualificationSchema = { - qualificationEvents, - qualificationIncidents, -}; diff --git a/qualification/parity/legacyBackendRouteInventory.ts b/qualification/parity/legacyBackendRouteInventory.ts deleted file mode 100644 index 390eed510..000000000 --- a/qualification/parity/legacyBackendRouteInventory.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import * as v from "valibot"; - -import { readBoundedUtf8RegularFile } from "../files/boundedFile.ts"; - -const maximumServerSourceBytes = 256 * 1024; -const maximumProbeOutputBytes = 64 * 1024; -const importedRepositoryRoot = path.resolve(import.meta.dir, "../.."); -const probeEntrypoint = path.join( - importedRepositoryRoot, - "scripts/qualification/legacyBackendRouteProbe.ts" -); -const httpMethodSchema = v.picklist(["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"]); -const httpRouteIdentitySchema = v.strictObject({ - id: v.pipe(v.string(), v.minLength(6), v.maxLength(256)), - method: httpMethodSchema, - path: v.pipe(v.string(), v.startsWith("/api/"), v.maxLength(192)), -}); -const httpRouteIdentitiesSchema = v.pipe( - v.array(httpRouteIdentitySchema), - v.minLength(1), - v.maxLength(256) -); - -export interface LegacyBackendRouteIdentity { - readonly id: string; - readonly method: "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT" | "WebSocket"; - readonly path: string; -} - -function compareStrings(left: string, right: string): number { - if (left < right) return -1; - if (left > right) return 1; - return 0; -} - -function assertSingleSourceMatch( - source: string, - pattern: RegExp, - description: string -): void { - const matches = [...source.matchAll(pattern)]; - if (matches.length !== 1) { - throw new Error( - `Legacy server must contain exactly one reviewed ${description}; found ${matches.length}` - ); - } -} - -async function assertWebSocketRouteSource(repositoryRoot: string): Promise { - const sourcePath = path.join(repositoryRoot, "backend/src/server/app.ts"); - const source = await readBoundedUtf8RegularFile( - sourcePath, - repositoryRoot, - maximumServerSourceBytes, - "Legacy WebSocket server source has invalid file state", - "Legacy WebSocket server source is not valid UTF-8" - ); - assertSingleSourceMatch( - source.text, - /if \(url\.pathname === "\/ws"\) \{/gu, - "WebSocket route branch" - ); - assertSingleSourceMatch( - source.text, - /server\.upgrade\(request, \{/gu, - "WebSocket upgrade call" - ); -} - -async function httpRouteIdentities(): Promise { - const temporaryDirectory = await mkdtemp(path.join(tmpdir(), "mira-route-probe-")); - try { - const environment = { - CI: "1", - HOME: temporaryDirectory, - LANG: "C.UTF-8", - MIRA_DASHBOARD_DB_PATH: path.join(temporaryDirectory, "route-probe.sqlite"), - MIRA_DASHBOARD_PROJECT_ROOT: path.join( - temporaryDirectory, - "dashboard-project" - ), - NODE_ENV: "test", - OPENCLAW_HOME: path.join(temporaryDirectory, "openclaw"), - PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", - TMPDIR: temporaryDirectory, - XDG_CACHE_HOME: path.join(temporaryDirectory, "cache"), - XDG_CONFIG_HOME: path.join(temporaryDirectory, "config"), - XDG_DATA_HOME: path.join(temporaryDirectory, "data"), - XDG_STATE_HOME: path.join(temporaryDirectory, "state"), - }; - const result = Bun.spawnSync({ - cmd: [process.execPath, probeEntrypoint], - cwd: temporaryDirectory, - env: environment, - killSignal: "SIGKILL", - maxBuffer: maximumProbeOutputBytes, - stderr: "pipe", - stdin: "ignore", - stdout: "pipe", - timeout: 5000, - }); - if (!result.success) { - throw new Error("Legacy route registry probe failed"); - } - let candidate: unknown; - try { - candidate = JSON.parse(new TextDecoder().decode(result.stdout)) as unknown; - } catch { - throw new Error("Legacy route registry probe returned invalid JSON"); - } - const identities = v.parse(httpRouteIdentitiesSchema, candidate); - for (const identity of identities) { - if (identity.id !== `${identity.method} ${identity.path}`) { - throw new Error( - `Legacy route probe returned an invalid id ${identity.id}` - ); - } - } - return identities; - } finally { - await rm(temporaryDirectory, { force: true, recursive: true }); - } -} - -/** - * Reads the executable legacy HTTP registry and verifies the source-owned WebSocket route. - * Documentation supplies descriptions, but these identities are the parity authority. - * @param repositoryRoot Absolute repository root containing the imported registry. - * @returns Sorted, unique current-production route identities. - */ -export async function loadLegacyBackendRouteIdentities( - repositoryRoot: string -): Promise { - const resolvedRepositoryRoot = path.resolve(repositoryRoot); - if (resolvedRepositoryRoot !== importedRepositoryRoot) { - throw new Error( - "Legacy route inventory must inspect its imported repository root" - ); - } - await assertWebSocketRouteSource(resolvedRepositoryRoot); - const identities = [ - ...(await httpRouteIdentities()), - { - id: "WebSocket /ws", - method: "WebSocket" as const, - path: "/ws", - }, - ].toSorted((left, right) => compareStrings(left.id, right.id)); - for (const [index, identity] of identities.entries()) { - if (index > 0 && identities[index - 1]!.id === identity.id) { - throw new Error(`Duplicate legacy route identity ${identity.id}`); - } - } - return identities; -} diff --git a/qualification/parity/parityInventory.test.ts b/qualification/parity/parityInventory.test.ts deleted file mode 100644 index 3500c8d4e..000000000 --- a/qualification/parity/parityInventory.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - procedureContracts, - rawHttpContracts, -} from "../../src/contracts/contractRegistry.ts"; -import { loadLegacyBackendRouteIdentities } from "./legacyBackendRouteInventory.ts"; -import { - buildGreenfieldContractFixtureCandidate, - buildParityFixtureCandidate, -} from "./parityFixtureCandidate.ts"; -import { - parseFrontendParityFixture, - reviewedLegacyEndpointRowCount, - type FrontendRouteInventory, - type LegacyEndpointInventory, -} from "./parityInventorySchemas.ts"; -import { - assertGreenfieldRegistryMatchesReviewed, - assertGreenfieldTargetAccounting, - assertSourcesMatchReviewedParity, - loadReviewedParityInventory, -} from "./reviewedParityInventory.ts"; -import { - loadSourceParityInventory, - type SourceParityInventory, -} from "./sourceParityInventory.ts"; - -const repositoryRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "../.." -); - -function countByPhase( - values: readonly ( - | Pick - | Pick - )[] -): Record { - const counts: Record = {}; - for (const value of values) { - if ("kind" in value.target && value.target.kind === "reviewed-removal") continue; - counts[value.target.phase] = (counts[value.target.phase] ?? 0) + 1; - } - return counts; -} - -describe("reviewed frontend parity inventory", () => { - test("matches current route, navigation, lazy-module, and search sources exactly", async () => { - const [reviewed, observed] = await Promise.all([ - loadReviewedParityInventory(), - loadSourceParityInventory(repositoryRoot), - ]); - - expect(() => assertSourcesMatchReviewedParity(observed, reviewed)).not.toThrow(); - expect(reviewed.frontend.routes).toHaveLength(16); - expect( - reviewed.frontend.routes.filter((route) => route.navigationPosition !== null) - ).toHaveLength(15); - expect( - reviewed.frontend.routes - .filter((route) => route.navigationPosition !== null) - .toSorted( - (left, right) => left.navigationPosition! - right.navigationPosition! - ) - .map((route) => route.navigationPosition) - ).toEqual(Array.from({ length: 15 }, (_, index) => index)); - expect( - reviewed.frontend.routes.every((route) => route.target.delivery === "planned") - ).toBeTrue(); - expect(countByPhase(reviewed.frontend.routes)).toEqual({ - "phase-2": 1, - "phase-3": 5, - "phase-4": 2, - "phase-5": 8, - }); - }); - - test("generates the same candidate while requiring explicit review for new routes", async () => { - const [reviewed, observed] = await Promise.all([ - loadReviewedParityInventory(), - loadSourceParityInventory(repositoryRoot), - ]); - expect(buildParityFixtureCandidate(observed, reviewed).frontend).toEqual( - reviewed.frontend - ); - - const changedRoute: SourceParityInventory = structuredClone(observed); - changedRoute.routes[0] = { ...changedRoute.routes[0]!, path: "/new-route" }; - expect(() => buildParityFixtureCandidate(changedRoute, reviewed)).toThrow( - "Frontend route /new-route needs an explicit parity target review" - ); - }); - - test("uses strict fixture objects", async () => { - const { frontend } = await loadReviewedParityInventory(); - expect(() => - parseFrontendParityFixture({ ...frontend, unreviewedField: true }) - ).toThrow(); - }); -}); - -describe("reviewed legacy endpoint parity inventory", () => { - test("accounts for every executable backend route and documented row exactly once", async () => { - const [reviewed, observed, backendRoutes] = await Promise.all([ - loadReviewedParityInventory(), - loadSourceParityInventory(repositoryRoot), - loadLegacyBackendRouteIdentities(repositoryRoot), - ]); - - expect(() => assertSourcesMatchReviewedParity(observed, reviewed)).not.toThrow(); - expect( - reviewed.legacyEndpoints.endpoints.map(({ id, method, path: routePath }) => ({ - id, - method, - path: routePath, - })) - ).toEqual(backendRoutes); - expect(backendRoutes.filter(({ method }) => method !== "WebSocket")).toHaveLength( - 156 - ); - expect(backendRoutes.filter(({ method }) => method === "WebSocket")).toEqual([ - { id: "WebSocket /ws", method: "WebSocket", path: "/ws" }, - ]); - expect(reviewed.legacyEndpoints.endpoints).toHaveLength( - reviewedLegacyEndpointRowCount - ); - expect(new Set(reviewed.legacyEndpoints.endpoints.map(({ id }) => id)).size).toBe( - reviewedLegacyEndpointRowCount - ); - expect(countByPhase(reviewed.legacyEndpoints.endpoints)).toEqual({ - "phase-1": 7, - "phase-2": 28, - "phase-3": 45, - "phase-4": 7, - "phase-5": 70, - }); - expect( - reviewed.legacyEndpoints.endpoints.filter( - ({ target }) => - target.kind !== "reviewed-removal" && - target.delivery === "implemented" - ) - ).toHaveLength(29); - expect( - reviewed.legacyEndpoints.endpoints.filter( - ({ target }) => target.kind === "reviewed-removal" - ) - ).toHaveLength(0); - }, 20_000); - - test("checks implemented mappings against the greenfield registries", async () => { - const reviewed = await loadReviewedParityInventory(); - expect(() => - assertGreenfieldRegistryMatchesReviewed( - reviewed, - procedureContracts, - rawHttpContracts - ) - ).not.toThrow(); - expect( - buildGreenfieldContractFixtureCandidate(procedureContracts, rawHttpContracts) - ).toEqual(reviewed.greenfieldContracts); - expect(reviewed.greenfieldContracts.procedures).toHaveLength(36); - expect(reviewed.greenfieldContracts.rawHttp).toHaveLength(4); - expect(() => - assertGreenfieldTargetAccounting( - reviewed, - procedureContracts, - rawHttpContracts - ) - ).not.toThrow(); - - const missingContract = structuredClone(reviewed); - const implementedProcedure = missingContract.legacyEndpoints.endpoints.find( - ({ target }) => - target.kind === "procedure" && target.delivery === "implemented" - ); - expect(implementedProcedure?.target.kind).toBe("procedure"); - if (implementedProcedure?.target.kind !== "procedure") { - throw new Error("Test fixture has no implemented procedure target"); - } - implementedProcedure.target.names = ["missing.procedure"]; - expect(() => - assertGreenfieldTargetAccounting( - missingContract, - procedureContracts, - rawHttpContracts - ) - ).toThrow("is not registered"); - }); - - test("keeps unresolved Phase 2 browser behavior explicit instead of overclaiming", async () => { - const reviewed = await loadReviewedParityInventory(); - expect( - reviewed.legacyEndpoints.endpoints - .filter( - ({ target }) => - target.kind !== "reviewed-removal" && - target.phase === "phase-2" && - target.delivery === "planned" - ) - .map(({ id }) => id) - ).toEqual([ - "GET /api/audit-events", - "POST /api/account/security/sessions/revoke-all", - "POST /api/account/security/sessions/revoke-others", - ]); - }); - - test("requires an explicit target before generating a candidate for a new endpoint", async () => { - const [reviewed, observed] = await Promise.all([ - loadReviewedParityInventory(), - loadSourceParityInventory(repositoryRoot), - ]); - expect(buildParityFixtureCandidate(observed, reviewed).legacyEndpoints).toEqual( - reviewed.legacyEndpoints - ); - - const changedEndpoint: SourceParityInventory = structuredClone(observed); - changedEndpoint.endpoints.push({ - id: "GET /api/unreviewed", - method: "GET", - path: "/api/unreviewed", - purpose: "Unreviewed source drift.", - section: "Unreviewed", - }); - expect(() => buildParityFixtureCandidate(changedEndpoint, reviewed)).toThrow( - "Legacy endpoint GET /api/unreviewed needs an explicit parity target review" - ); - }); -}); diff --git a/qualification/parity/sourceParityInventory.test.ts b/qualification/parity/sourceParityInventory.test.ts deleted file mode 100644 index 2d9d94ec2..000000000 --- a/qualification/parity/sourceParityInventory.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { expect, test } from "bun:test"; -import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { loadSourceParityInventory, paritySourcePaths } from "./sourceParityInventory.ts"; - -const repositoryRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "../.." -); - -function replaceExactly(source: string, target: string, replacement: string): string { - const parts = source.split(target); - if (parts.length !== 2) { - throw new Error(`Expected one source occurrence of ${JSON.stringify(target)}`); - } - return `${parts[0]}${replacement}${parts[1]}`; -} - -async function withModifiedSource( - relativeSourcePath: (typeof paritySourcePaths)[keyof typeof paritySourcePaths], - modifySource: (source: string) => string, - verify: (temporaryRepositoryRoot: string) => Promise -): Promise { - const temporaryRepositoryRoot = await mkdtemp( - path.join(tmpdir(), "mira-parity-source-") - ); - try { - await Promise.all( - Object.values(paritySourcePaths).map(async (relativePath) => { - const destination = path.join(temporaryRepositoryRoot, relativePath); - await mkdir(path.dirname(destination), { recursive: true }); - await copyFile(path.join(repositoryRoot, relativePath), destination); - }) - ); - const sourcePath = path.join(temporaryRepositoryRoot, relativeSourcePath); - const source = await readFile(sourcePath, "utf8"); - await writeFile(sourcePath, modifySource(source), "utf8"); - await verify(temporaryRepositoryRoot); - } finally { - await rm(temporaryRepositoryRoot, { force: true, recursive: true }); - } -} - -async function expectInventoryLoadFailure( - temporaryRepositoryRoot: string, - expectedMessage: string -): Promise { - try { - await loadSourceParityInventory(temporaryRepositoryRoot); - } catch (error) { - if (!(error instanceof Error)) throw error; - expect(error.message).toContain(expectedMessage); - return; - } - throw new Error(`Expected parity inventory loading to fail with ${expectedMessage}`); -} - -test("allows the explicitly reviewed authenticated pathless layout", async () => { - const inventory = await loadSourceParityInventory(repositoryRoot); - expect(inventory.routes).toHaveLength(16); - expect( - inventory.routes.some( - ({ sourceRouteName }) => sourceRouteName === "authenticated" - ) - ).toBeFalse(); -}); - -test("rejects a pathless layout whose authentication guard is weakened", async () => { - await withModifiedSource( - paritySourcePaths.router, - (source) => - replaceExactly( - source, - " if (!authStore.state.isAuthenticated) {", - " if (false) {" - ), - (temporaryRepositoryRoot) => - expectInventoryLoadFailure( - temporaryRepositoryRoot, - "Pathless route authenticatedRoute changed outside its explicit review" - ) - ); -}); - -test("rejects an unreviewed pathless createRoute declaration", async () => { - await withModifiedSource( - paritySourcePaths.router, - (source) => - replaceExactly( - source, - "const routeTree = rootRoute.addChildren([", - `const hiddenRoute = createRoute({ - getParentRoute: () => rootRoute, - id: "hidden", - component: Login, -}); - -const routeTree = rootRoute.addChildren([` - ), - (temporaryRepositoryRoot) => - expectInventoryLoadFailure( - temporaryRepositoryRoot, - "Pathless route hiddenRoute is not explicitly reviewed" - ) - ); -}); - -test("rejects a createRoute path that is no longer a reviewed literal", async () => { - await withModifiedSource( - paritySourcePaths.router, - (source) => replaceExactly(source, ' path: "/login",', " path: loginPath,"), - (temporaryRepositoryRoot) => - expectInventoryLoadFailure( - temporaryRepositoryRoot, - "Route loginRoute path changed outside the reviewed literal shape" - ) - ); -}); - -test("rejects a createRoute declaration outside the reviewed block shape", async () => { - await withModifiedSource( - paritySourcePaths.router, - (source) => - replaceExactly( - source, - "const loginRoute = createRoute({", - "const loginRoute = createRoute( {" - ), - (temporaryRepositoryRoot) => - expectInventoryLoadFailure( - temporaryRepositoryRoot, - "Reviewed router createRoute declarations changed outside the reviewed literal shape" - ) - ); -}); - -test("rejects routeTree identifiers that do not exactly match declarations", async () => { - await withModifiedSource( - paritySourcePaths.router, - (source) => - replaceExactly(source, " settingsRoute,", " loginRoute,"), - (temporaryRepositoryRoot) => - expectInventoryLoadFailure( - temporaryRepositoryRoot, - "Reviewed route tree identifiers differ" - ) - ); -}); - -test("rejects a routeTree child identifier without the Route suffix", async () => { - await withModifiedSource( - paritySourcePaths.router, - (source) => - replaceExactly( - source, - " settingsRoute,", - " settingsRoute,\n settingsPage," - ), - (temporaryRepositoryRoot) => - expectInventoryLoadFailure( - temporaryRepositoryRoot, - "Reviewed route tree identifiers differ" - ) - ); -}); - -test("rejects unrecognized routeTree syntax", async () => { - await withModifiedSource( - paritySourcePaths.router, - (source) => - replaceExactly( - source, - " loginRoute,", - " loginRoute,\n ...conditionallyIncludedRoutes," - ), - (temporaryRepositoryRoot) => - expectInventoryLoadFailure( - temporaryRepositoryRoot, - "Reviewed route tree contains unrecognized syntax" - ) - ); -}); - -test("rejects navigation entries outside the reviewed literal shape", async () => { - await withModifiedSource( - paritySourcePaths.navigation, - (source) => - replaceExactly( - source, - ' { to: "/", icon: Home, label: "Dashboard" },', - ' { to: "/", icon: Home, label: "Dashboard", unreviewed: true },' - ), - (temporaryRepositoryRoot) => - expectInventoryLoadFailure( - temporaryRepositoryRoot, - "Reviewed navigation contains an unrecognized item shape" - ) - ); -}); - -test("rejects route modules outside the reviewed literal shape", async () => { - await withModifiedSource( - paritySourcePaths.routeModules, - (source) => - replaceExactly( - source, - ' agents: () => import("../pages/Agents"),', - ' agents: () => import("../pages/Agents") ,' - ), - (temporaryRepositoryRoot) => - expectInventoryLoadFailure( - temporaryRepositoryRoot, - "Reviewed route module registry changed outside the literal shape" - ) - ); -}); - -test("rejects preload entries outside the reviewed literal shape", async () => { - await withModifiedSource( - paritySourcePaths.routeModules, - (source) => - replaceExactly( - source, - ' "/agents": routeModules.agents,', - ' "/agents": routeModules.agents ,' - ), - (temporaryRepositoryRoot) => - expectInventoryLoadFailure( - temporaryRepositoryRoot, - "Reviewed route preload registry changed outside the literal shape" - ) - ); -}); diff --git a/qualification/parity/sourceParityInventory.ts b/qualification/parity/sourceParityInventory.ts deleted file mode 100644 index 06374fce3..000000000 --- a/qualification/parity/sourceParityInventory.ts +++ /dev/null @@ -1,550 +0,0 @@ -import path from "node:path"; - -import { readBoundedUtf8RegularFile } from "../files/boundedFile.ts"; -import type { - FrontendRouteInventory, - LegacyEndpointInventory, -} from "./parityInventorySchemas.ts"; - -const maximumSourceBytes = 2 * 1024 * 1024; - -export const paritySourcePaths = { - endpoints: "docs/api/endpoints.md", - navigation: "frontend/src/components/layout/Layout.tsx", - routeModules: "frontend/src/lib/routeModules.ts", - router: "frontend/src/router.tsx", -} as const; - -type SourceFrontendRoute = Omit; -type SourceLegacyEndpoint = Omit; - -export interface SourceParityInventory { - endpoints: SourceLegacyEndpoint[]; - routes: SourceFrontendRoute[]; -} - -interface NavigationEntry { - label: string; - path: string; - position: number; -} - -interface RouteModuleEntry { - key: string; - pageModule: string; -} - -interface LazyComponentEntry { - component: string; - moduleKey: string; -} - -interface PreloadEntry { - moduleKey: string; - path: string; -} - -interface RouteEntry { - access: "public" | "session"; - component: string; - path: string; - searchNormalizer: SourceFrontendRoute["searchNormalizer"]; - sourceRouteName: string; -} - -interface RouteDeclaration { - block: string; - identifier: string; - sourceRouteName: string; -} - -interface RouteTreeToken { - kind: "identifier" | "punctuation"; - offset: number; - value: string; -} - -const reviewedPathlessRoutes = { - authenticatedRoute: { - id: "authenticated", - parent: "rootRoute", - }, -} as const; -const routeIdentifierSuffix = "Route"; - -function compareStrings(left: string, right: string): number { - if (left < right) return -1; - if (left > right) return 1; - return 0; -} - -function requiredBlock(source: string, pattern: RegExp, context: string): string { - const block = source.match(pattern)?.[1]; - if (!block) throw new Error(`Cannot locate reviewed ${context}`); - return block; -} - -function extractNavigationEntries(source: string): NavigationEntry[] { - const block = requiredBlock( - source, - /^const navItems = \[\n([\s\S]*?)^\];$/mu, - "navigation array" - ); - const entries = [ - ...block.matchAll( - /^\s{4}\{ to: "([^"]+)", icon: [A-Za-z][A-Za-z0-9]*, label: "([^"]+)" \},$/gmu - ), - ].map((match, position) => ({ - label: match[2]!, - path: match[1]!, - position, - })); - if (entries.length === 0) throw new Error("Reviewed navigation has no literal items"); - const remaining = block.replaceAll( - /^\s{4}\{ to: "([^"]+)", icon: [A-Za-z][A-Za-z0-9]*, label: "([^"]+)" \},\n?/gmu, - "" - ); - if (remaining.trim()) { - throw new Error("Reviewed navigation contains an unrecognized item shape"); - } - return entries; -} - -function extractRouteModules(source: string): RouteModuleEntry[] { - const block = requiredBlock( - source, - /^export const routeModules = \{\n([\s\S]*?)^\};$/mu, - "route module registry" - ); - const entries = [ - ...block.matchAll(/^\s{4}([a-z][A-Za-z0-9]*): \(\) => import\("([^"]+)"\),$/gmu), - ].map((match) => ({ key: match[1]!, pageModule: match[2]! })); - const remaining = block.replaceAll( - /^\s{4}([a-z][A-Za-z0-9]*): \(\) => import\("([^"]+)"\),\n?/gmu, - "" - ); - if (entries.length === 0 || remaining.trim()) { - throw new Error( - "Reviewed route module registry changed outside the literal shape" - ); - } - return entries; -} - -function extractPreloadEntries(source: string): PreloadEntry[] { - const block = requiredBlock( - source, - /^const routeModulesByPath: Readonly> = \{\n([\s\S]*?)^\};$/mu, - "route preload registry" - ); - const entries = [ - ...block.matchAll(/^\s{4}"([^"]+)": routeModules\.([a-z][A-Za-z0-9]*),$/gmu), - ].map((match) => ({ moduleKey: match[2]!, path: match[1]! })); - const remaining = block.replaceAll( - /^\s{4}"([^"]+)": routeModules\.([a-z][A-Za-z0-9]*),\n?/gmu, - "" - ); - if (entries.length === 0 || remaining.trim()) { - throw new Error( - "Reviewed route preload registry changed outside the literal shape" - ); - } - return entries; -} - -function extractLazyComponents(source: string): LazyComponentEntry[] { - return [ - ...source.matchAll( - /^const ([A-Z][A-Za-z0-9]*) = lazyRouteComponent\(\n\s*\(\) => loadLazyModule\("route-[a-z-]+", routeModules\.([a-z][A-Za-z0-9]*)\),\n\s*"\1"\n\);$/gmu - ), - ].map((match) => ({ component: match[1]!, moduleKey: match[2]! })); -} - -function extractRouteDeclarations(source: string): RouteDeclaration[] { - const createRouteCallCount = [...source.matchAll(/\bcreateRoute\s*\(/gu)].length; - const declaredIdentifiers = [ - ...source.matchAll(/^const ([a-z][A-Za-z0-9]*Route) = createRoute\s*\(/gmu), - ].map((match) => match[1]!); - if (createRouteCallCount !== declaredIdentifiers.length) { - throw new Error( - "Reviewed router contains a createRoute call outside the reviewed declaration shape" - ); - } - - const declarations = [ - ...source.matchAll( - /^const ([a-z][A-Za-z0-9]*Route) = createRoute\(\{\n([\s\S]*?)^\}\);$/gmu - ), - ].map((match): RouteDeclaration => ({ - block: match[2]!, - identifier: match[1]!, - sourceRouteName: match[1]!.slice(0, -routeIdentifierSuffix.length), - })); - if ( - declarations.length !== declaredIdentifiers.length || - declarations.some( - (declaration, index) => declaration.identifier !== declaredIdentifiers[index] - ) - ) { - throw new Error( - "Reviewed router createRoute declarations changed outside the reviewed literal shape" - ); - } - if (new Set(declaredIdentifiers).size !== declaredIdentifiers.length) { - throw new Error("Reviewed router contains duplicate createRoute declarations"); - } - if (declarations.length === 0) { - throw new Error("Reviewed router has no literal createRoute declarations"); - } - return declarations; -} - -function assertReviewedPathlessRoute(declaration: RouteDeclaration): void { - const review = - reviewedPathlessRoutes[ - declaration.identifier as keyof typeof reviewedPathlessRoutes - ]; - if (!review) { - throw new Error( - `Pathless route ${declaration.identifier} is not explicitly reviewed` - ); - } - const parent = declaration.block.match( - /^\s{4}getParentRoute: \(\) => ([a-z][A-Za-z0-9]*Route),$/mu - )?.[1]; - const routeId = declaration.block.match(/^\s{4}id: "([^"]+)",$/mu)?.[1]; - const rendersAuthenticatedLayout = - /^\s{4}component: \(\) => \(\n\s{8}\n\s{12}\n\s{8}<\/Layout>\n\s{4}\),$/mu.test( - declaration.block - ); - const enforcesAuthenticatedSession = - /^\s{4}beforeLoad: async \(\) => \{\n\s{8}await authActions\.initialize\(\);\n\s{8}if \(!authStore\.state\.isAuthenticated\) \{\n\s{12}redirect\(\{ throw: true, to: "\/login" \}\);\n\s{8}\}\n\s{4}\},$/mu.test( - declaration.block - ); - if ( - parent !== review.parent || - routeId !== review.id || - !enforcesAuthenticatedSession || - !rendersAuthenticatedLayout - ) { - throw new Error( - `Pathless route ${declaration.identifier} changed outside its explicit review` - ); - } -} - -function tokenizeRouteTree(routeTree: string): RouteTreeToken[] { - const tokens: RouteTreeToken[] = []; - let offset = 0; - while (offset < routeTree.length) { - const character = routeTree[offset]!; - if (/\s/u.test(character)) { - offset += 1; - continue; - } - const identifier = routeTree - .slice(offset) - .match(/^[A-Za-z_$][A-Za-z0-9_$]*/u)?.[0]; - if (identifier) { - tokens.push({ kind: "identifier", offset, value: identifier }); - offset += identifier.length; - continue; - } - if (".()[],;".includes(character)) { - tokens.push({ kind: "punctuation", offset, value: character }); - offset += 1; - continue; - } - throw new Error( - `Reviewed route tree contains unrecognized syntax at offset ${offset}` - ); - } - return tokens; -} - -function parseRouteTreeIdentifiers(routeTree: string): string[] { - const tokens = tokenizeRouteTree(routeTree); - const identifiers: string[] = []; - let position = 0; - - function syntaxError(): Error { - const token = tokens[position]; - const context = token - ? `${JSON.stringify(token.value)} at offset ${token.offset}` - : "the end of the route tree"; - return new Error( - `Reviewed route tree contains unrecognized syntax near ${context}` - ); - } - - function consume(value: string): void { - if (tokens[position]?.value !== value) throw syntaxError(); - position += 1; - } - - function consumeIdentifier(): string { - const token = tokens[position]; - if (token?.kind !== "identifier") throw syntaxError(); - position += 1; - return token.value; - } - - function parseNode(): void { - identifiers.push(consumeIdentifier()); - if (tokens[position]?.value !== ".") return; - consume("."); - if (consumeIdentifier() !== "addChildren") throw syntaxError(); - consume("("); - consume("["); - while (tokens[position]?.value !== "]") { - parseNode(); - consume(","); - } - consume("]"); - consume(")"); - } - - parseNode(); - consume(";"); - if (position !== tokens.length) throw syntaxError(); - return identifiers; -} - -function assertExactRouteTreeIdentifiers( - source: string, - declarations: readonly RouteDeclaration[] -): void { - const routeTree = requiredBlock( - source, - /^const routeTree = ([\s\S]*?)^\/\*\* Defines router\. \*\/$/mu, - "route tree" - ); - const observedIdentifiers = - parseRouteTreeIdentifiers(routeTree).toSorted(compareStrings); - const expectedIdentifiers = [ - "rootRoute", - ...declarations.map((declaration) => declaration.identifier), - ].toSorted(compareStrings); - if ( - observedIdentifiers.length !== expectedIdentifiers.length || - observedIdentifiers.some( - (identifier, index) => identifier !== expectedIdentifiers[index] - ) - ) { - throw new Error( - `Reviewed route tree identifiers differ: expected ${expectedIdentifiers.join( - ", " - )}; observed ${observedIdentifiers.join(", ")}` - ); - } -} - -function extractRoutes(source: string): RouteEntry[] { - const declarations = extractRouteDeclarations(source); - const routes: RouteEntry[] = []; - for (const declaration of declarations) { - const literalPaths = [ - ...declaration.block.matchAll(/^\s{4}path: "([^"]+)",$/gmu), - ]; - if (literalPaths.length === 0) { - if (/^\s{4}path\s*:/mu.test(declaration.block)) { - throw new Error( - `Route ${declaration.identifier} path changed outside the reviewed literal shape` - ); - } - assertReviewedPathlessRoute(declaration); - continue; - } - if (literalPaths.length !== 1) { - throw new Error(`Route ${declaration.identifier} has multiple literal paths`); - } - if (declaration.identifier in reviewedPathlessRoutes) { - throw new Error( - `Explicitly reviewed pathless route ${declaration.identifier} now has a path` - ); - } - const routePath = literalPaths[0]![1]!; - const parent = declaration.block.match( - /^\s{4}getParentRoute: \(\) => (rootRoute|authenticatedRoute),$/mu - )?.[1]; - const component = declaration.block.match( - /^\s{4}component: ([A-Z][A-Za-z0-9]*),$/mu - )?.[1]; - if (!parent || !component) { - throw new Error( - `Route ${declaration.identifier} changed outside the reviewed shape` - ); - } - const normalizer = declaration.block.match( - /^\s{4}validateSearch: (normalizeChatSearch|normalizeSettingsSearch),$/mu - )?.[1]; - routes.push({ - access: parent === "rootRoute" ? "public" : "session", - component, - path: routePath, - searchNormalizer: - normalizer === "normalizeChatSearch" || - normalizer === "normalizeSettingsSearch" - ? normalizer - : null, - sourceRouteName: declaration.sourceRouteName, - }); - } - if (routes.length === 0) throw new Error("Reviewed router has no literal routes"); - assertExactRouteTreeIdentifiers(source, declarations); - return routes; -} - -function buildFrontendSourceInventory( - routerSource: string, - navigationSource: string, - routeModulesSource: string -): SourceFrontendRoute[] { - const routes = extractRoutes(routerSource); - const lazyComponents = extractLazyComponents(routerSource); - const routeModules = extractRouteModules(routeModulesSource); - const preloadEntries = extractPreloadEntries(routeModulesSource); - const navigation = extractNavigationEntries(navigationSource); - const navigationPaths = new Set(); - for (const item of navigation) { - if (navigationPaths.has(item.path)) { - throw new Error(`Duplicate reviewed navigation path ${item.path}`); - } - navigationPaths.add(item.path); - } - const routePaths = new Set(routes.map((route) => route.path)); - for (const item of navigation) { - if (!routePaths.has(item.path)) { - throw new Error(`Navigation path ${item.path} has no reviewed route`); - } - } - const usedModuleKeys = new Set(); - const inventory = routes.map((route): SourceFrontendRoute => { - const lazyComponent = lazyComponents.find( - (candidate) => candidate.component === route.component - ); - if (!lazyComponent) { - throw new Error(`Route ${route.path} has no reviewed lazy component`); - } - const routeModule = routeModules.find( - (candidate) => candidate.key === lazyComponent.moduleKey - ); - if (!routeModule) { - throw new Error(`Route ${route.path} has no reviewed route module`); - } - usedModuleKeys.add(routeModule.key); - const navigationItem = navigation.find((item) => item.path === route.path); - const preloadEntry = preloadEntries.find((entry) => entry.path === route.path); - if ( - navigationItem && - (!preloadEntry || preloadEntry.moduleKey !== routeModule.key) - ) { - throw new Error( - `Navigation path ${route.path} has no matching route preload` - ); - } - if (!navigationItem && preloadEntry) { - throw new Error( - `Non-navigation route ${route.path} has an unreviewed preload` - ); - } - return { - access: route.access, - moduleKey: routeModule.key, - navigationLabel: navigationItem?.label ?? null, - navigationPosition: navigationItem?.position ?? null, - pageModule: routeModule.pageModule, - path: route.path, - searchNormalizer: route.searchNormalizer, - sourceRouteName: route.sourceRouteName, - }; - }); - if (usedModuleKeys.size !== routeModules.length) { - const unused = routeModules - .filter((routeModule) => !usedModuleKeys.has(routeModule.key)) - .map((routeModule) => routeModule.key) - .join(", "); - throw new Error(`Reviewed route modules are not routed: ${unused}`); - } - if (preloadEntries.length !== navigation.length) { - throw new Error("Reviewed route preload and navigation counts differ"); - } - return inventory.toSorted((left, right) => compareStrings(left.path, right.path)); -} - -function parseLegacyEndpointRows(markdown: string): SourceLegacyEndpoint[] { - let section = ""; - const endpoints: SourceLegacyEndpoint[] = []; - for (const line of markdown.split("\n")) { - const sectionMatch = line.match(/^## (.+)$/u); - if (sectionMatch?.[1]) { - section = sectionMatch[1]; - continue; - } - const row = line.match( - /^\|\s*`?(DELETE|GET|HEAD|PATCH|POST|PUT|WebSocket)`?\s*\|\s*`([^`]+)`\s*\|\s*(.+?)\s*\|$/u - ); - if (!row) continue; - if (!section) throw new Error("Legacy endpoint row has no section"); - const method = row[1]! as SourceLegacyEndpoint["method"]; - const endpointPath = row[2]!; - endpoints.push({ - id: `${method} ${endpointPath}`, - method, - path: endpointPath, - purpose: row[3]!, - section, - }); - } - const sorted = endpoints.toSorted((left, right) => compareStrings(left.id, right.id)); - for (const [index, endpoint] of sorted.entries()) { - if (index > 0 && sorted[index - 1]!.id === endpoint.id) { - throw new Error(`Duplicate legacy endpoint row ${endpoint.id}`); - } - } - return sorted; -} - -async function readBoundedUtf8( - repositoryRoot: string, - relativePath: string -): Promise { - const absolutePath = path.resolve(repositoryRoot, relativePath); - const relative = path.relative(repositoryRoot, absolutePath); - if (relative.startsWith("..") || path.isAbsolute(relative)) { - throw new Error("Parity source path escaped the repository root"); - } - const source = await readBoundedUtf8RegularFile( - absolutePath, - repositoryRoot, - maximumSourceBytes, - `Parity source ${relativePath} has invalid file state`, - `Parity source ${relativePath} is not valid UTF-8` - ); - return source.text; -} - -/** - * Loads the semantic current-production parity inventory from reviewed repository sources. - * @param repositoryRoot Absolute repository root. - * @returns Current route, navigation, module, and endpoint source inventory. - */ -export async function loadSourceParityInventory( - repositoryRoot: string -): Promise { - const [endpointMarkdown, navigationSource, routeModulesSource, routerSource] = - await Promise.all([ - readBoundedUtf8(repositoryRoot, paritySourcePaths.endpoints), - readBoundedUtf8(repositoryRoot, paritySourcePaths.navigation), - readBoundedUtf8(repositoryRoot, paritySourcePaths.routeModules), - readBoundedUtf8(repositoryRoot, paritySourcePaths.router), - ]); - return { - endpoints: parseLegacyEndpointRows(endpointMarkdown), - routes: buildFrontendSourceInventory( - routerSource, - navigationSource, - routeModulesSource - ), - }; -} diff --git a/qualification/shutdown/runCompleteShutdownEvidence.ts b/qualification/shutdown/runCompleteShutdownEvidence.ts deleted file mode 100644 index 8457072df..000000000 --- a/qualification/shutdown/runCompleteShutdownEvidence.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Effect } from "effect"; - -import { completeShutdownQualification } from "./completeShutdownQualification.ts"; - -const report = await Effect.runPromise(completeShutdownQualification); -process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/qualification/trpc/client.ts b/qualification/trpc/client.ts deleted file mode 100644 index 257d0c5f2..000000000 --- a/qualification/trpc/client.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - createTRPCClient, - httpBatchLink, - httpSubscriptionLink, - retryLink, - splitLink, -} from "@trpc/client"; -import { EventSource, type EventSourceInit } from "eventsource"; - -import type { QualificationRouter } from "./router.ts"; - -/** Fetch surface shared by the tRPC and EventSource qualification transports. */ -export type QualificationFetch = ( - input: Request | string | URL, - init?: RequestInit -) => Promise; - -/** Transport options for one qualification client. */ -export interface QualificationClientOptions { - eventSourceOptions?: EventSourceInit; - fetch?: QualificationFetch; - retrySubscriptions?: boolean; - url: URL; -} - -/** - * Creates the shared query, mutation, and SSE qualification client. - * @param options Stable endpoint and optional TLS/retry transport behavior. - * @returns A typed tRPC client. - */ -export function createQualificationClient(options: QualificationClientOptions) { - const url = new URL("/trpc", options.url).toString(); - - return createTRPCClient({ - links: [ - retryLink({ - retry: ({ attempts, op }) => - options.retrySubscriptions === true && - op.type === "subscription" && - attempts <= 20, - retryDelayMs: () => 100, - }), - splitLink({ - condition: (operation) => operation.type === "subscription", - false: httpBatchLink({ fetch: options.fetch, url }), - true: httpSubscriptionLink({ - EventSource, - eventSourceOptions: options.eventSourceOptions, - url, - }), - }), - ], - }); -} diff --git a/scripts/buildFrontend.ts b/scripts/buildFrontend.ts index 6da4c9998..db6c7cac0 100644 --- a/scripts/buildFrontend.ts +++ b/scripts/buildFrontend.ts @@ -1,3 +1,3 @@ -import { buildFrontend } from "./frontendBuild.ts"; +import { buildFrontend } from "./frontendBuild"; await buildFrontend({ mode: "production" }); diff --git a/scripts/frontendBuild.ts b/scripts/frontendBuild.ts index fa4736839..96837f074 100644 --- a/scripts/frontendBuild.ts +++ b/scripts/frontendBuild.ts @@ -13,8 +13,8 @@ import { measureFrontendBundle, writeFrontendHtmlAppEntrypoint, writePrecompressedFrontendAssets, -} from "./frontendBuildArtifacts.ts"; -import reactCompilerPlugin from "./reactCompilerPlugin.ts"; +} from "./frontendBuildArtifacts"; +import reactCompilerPlugin from "./reactCompilerPlugin"; type FrontendBuildMode = "development" | "production"; @@ -70,8 +70,8 @@ export async function buildFrontend({ outdir: resolvedOutdir, plugins: [ ...(isProduction ? [productionDevtoolsPlugin] : []), - reactCompilerPlugin, tailwindPlugin, + reactCompilerPlugin, ], publicPath: "/", sourcemap: isProduction ? "none" : "linked", diff --git a/scripts/qualification/legacyBackendRouteProbe.ts b/scripts/qualification/legacyBackendRouteProbe.ts deleted file mode 100644 index d2ac01083..000000000 --- a/scripts/qualification/legacyBackendRouteProbe.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { routes } from "../../backend/src/routes/registry.ts"; - -const httpMethods = new Set(["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"]); - -interface RouteIdentity { - readonly id: string; - readonly method: "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; - readonly path: string; -} - -const identities: RouteIdentity[] = []; -for (const [routePath, entry] of Object.entries( - routes as Readonly> -)) { - if (routePath === "/api/*") continue; - if (!routePath.startsWith("/api/")) { - throw new Error(`Legacy route registry contains an unexpected path ${routePath}`); - } - if (typeof entry !== "object" || entry === null || entry instanceof Response) { - throw new Error(`Legacy API route ${routePath} has no explicit method table`); - } - const methods = Object.keys(entry); - if (methods.length === 0 || methods.some((method) => !httpMethods.has(method))) { - throw new Error(`Legacy API route ${routePath} has an unrecognized method table`); - } - for (const method of methods) { - const typedMethod = method as RouteIdentity["method"]; - identities.push({ - id: `${typedMethod} ${routePath}`, - method: typedMethod, - path: routePath, - }); - } -} - -process.stdout.write(`${JSON.stringify(identities)}\n`); diff --git a/tsconfig.browser.json b/tsconfig.browser.json deleted file mode 100644 index a28277191..000000000 --- a/tsconfig.browser.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "useDefineForClassFields": true, - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "types": [], - "jsx": "react-jsx" - }, - "include": [ - "src/app/browser.tsx", - "src/browser/**/*.ts", - "src/browser/**/*.tsx", - "src/contracts/**/*.ts", - "src/shared/**/*.ts" - ], - "exclude": [ - "src/**/*.test.ts", - "src/**/*.test.tsx", - "src/**/*.spec.ts", - "src/**/*.spec.tsx", - "src/**/__tests__/**/*.ts", - "src/**/__tests__/**/*.tsx", - "src/**/testSupport/**/*.ts", - "src/**/testSupport/**/*.tsx" - ] -} diff --git a/tsconfig.contracts.json b/tsconfig.contracts.json deleted file mode 100644 index 44f8b5f69..000000000 --- a/tsconfig.contracts.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - // Contracts/shared stay environment-neutral: no DOM, Bun, or Node ambient types; - // byte budgets therefore use the shared manual UTF-8 counter. - "lib": ["ESNext"], - "types": [] - }, - "include": ["src/contracts/**/*.ts", "src/shared/**/*.ts"], - "exclude": [ - "src/**/*.spec.ts", - "src/**/*.test.ts", - "src/**/__tests__/**/*.ts", - "src/**/testSupport/**/*.ts" - ] -} diff --git a/tsconfig.json b/tsconfig.json index 9808e5509..3a4ef1e28 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,22 +18,8 @@ "noUncheckedSideEffectImports": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, - "erasableSyntaxOnly": true, - "jsx": "react-jsx", - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "types": ["bun-types", "node"] + "erasableSyntaxOnly": true }, - // Repository-wide Oxlint compatibility graph; strict runtime partitions use their own configs. - "include": [ - "backend/**/*.ts", - "contracts/**/*.ts", - "drizzle.config.ts", - "frontend/src/**/*", - "qualification/**/*.ts", - "scripts/**/*.ts", - "src/**/*.ts", - "src/**/*.tsx", - "tailwind.config.ts", - "test/**/*.ts" - ] + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] } diff --git a/tsconfig.node.json b/tsconfig.node.json index ab6de7a7d..e26200b2f 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -9,6 +9,7 @@ "backend/src/**/*.ts", "backend/test/**/*.ts", "contracts/**/*.ts", + "scripts/**/*.ts", "test/**/*.ts", "frontend/src/globals.d.ts" ] diff --git a/tsconfig.qualification.json b/tsconfig.qualification.json deleted file mode 100644 index 44dfdfa77..000000000 --- a/tsconfig.qualification.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.qualification.tsbuildinfo", - "types": ["bun-types", "node"] - }, - "include": ["qualification/**/*.ts"] -} diff --git a/tsconfig.server.json b/tsconfig.server.json deleted file mode 100644 index 343284d05..000000000 --- a/tsconfig.server.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "lib": ["ESNext"], - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo", - "types": ["bun-types", "node"] - }, - "files": [ - "src/app/dashboardServer.test.ts", - "src/app/dashboardServer.ts", - "src/app/environmentSource.ts", - "src/app/server.ts", - "src/app/trpcHttpHandler.test.ts", - "src/app/trpcHttpHandler.ts", - "src/app/trpcRequestPolicy.test.ts", - "src/app/trpcRequestPolicy.ts" - ], - "include": ["src/contracts/**/*.ts", "src/server/**/*.ts", "src/shared/**/*.ts"] -} diff --git a/tsconfig.worker.json b/tsconfig.worker.json deleted file mode 100644 index 10803b689..000000000 --- a/tsconfig.worker.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "lib": ["ESNext"], - "types": ["bun-types", "node"] - }, - "include": [ - "src/app/worker*.ts", - "src/contracts/**/*.ts", - "src/shared/**/*.ts", - "src/worker/**/*.ts" - ], - "exclude": [ - "src/**/*.spec.ts", - "src/**/*.test.ts", - "src/**/__tests__/**/*.ts", - "src/**/testSupport/**/*.ts" - ] -} From 5818fe6d015124e772922a4a0613533f9408be4a Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Thu, 6 Aug 2026 14:45:47 +0200 Subject: [PATCH 2/6] feat(database): own SQLite runtime lifecycle --- .../application-architecture.md | 30 +- .../greenfield-rewrite/data-and-security.md | 82 +-- .../greenfield-rewrite/progress.md | 36 +- .../runtime-and-delivery.md | 16 +- .../migration.sql | 25 +- .../snapshot.json | 26 +- greenfield/src/app/dashboardServer.test.ts | 56 +- greenfield/src/app/dashboardServer.ts | 185 ++++--- greenfield/src/app/server.ts | 7 +- .../migrations/applyVerifiedMigrations.ts | 75 ++- .../migrations/loadVerifiedMigrations.test.ts | 363 ++++++++++++- .../migrations/loadVerifiedMigrations.ts | 113 +++-- .../server/database/migrations/manifest.ts | 4 +- .../migrationApplicationTime.test.ts | 92 +++- .../migrations/migrationArtifactFilesystem.ts | 477 ++++++++++++++++++ .../migrations/migrationGraph.test.ts | 5 + .../migrationLedgerValidation.test.ts | 107 +++- .../server/database/migrations/validation.ts | 7 +- .../server/database/runtime/databaseErrors.ts | 93 ++++ .../database/runtime/databasePath.test.ts | 159 ++++++ .../server/database/runtime/databasePath.ts | 250 +++++++++ .../server/database/runtime/databasePolicy.ts | 303 +++++++++++ .../database/runtime/databaseService.test.ts | 300 +++++++++++ .../database/runtime/databaseService.ts | 226 +++++++++ .../database/runtime/databaseStartup.ts | 255 ++++++++++ .../database/schema/schemaMigrations.ts | 40 +- .../database/validation/rowSchemas.test.ts | 8 + .../database/validation/schemaMigrations.ts | 8 +- .../security/testSupport/authentication.ts | 165 +++--- .../platform/errors/safeFailure.test.ts | 37 ++ .../src/server/platform/errors/safeFailure.ts | 6 + .../platform/runtime/applicationRuntime.ts | 129 ++++- .../dashboardApplicationRuntime.test.ts | 142 ++++++ .../test/support/automationHttpSystem.ts | 7 +- .../src/server/test/support/mfaHttpSystem.ts | 5 +- .../src/server/test/support/requestContext.ts | 32 ++ .../system/serverAutomationSecurity.test.ts | 25 +- ...erverGatewayCredentialVerification.test.ts | 11 +- .../server/test/system/serverRealtime.test.ts | 4 +- .../server/test/system/serverShutdown.test.ts | 133 +++++ 40 files changed, 3709 insertions(+), 335 deletions(-) create mode 100644 greenfield/src/server/database/migrations/migrationArtifactFilesystem.ts create mode 100644 greenfield/src/server/database/runtime/databaseErrors.ts create mode 100644 greenfield/src/server/database/runtime/databasePath.test.ts create mode 100644 greenfield/src/server/database/runtime/databasePath.ts create mode 100644 greenfield/src/server/database/runtime/databasePolicy.ts create mode 100644 greenfield/src/server/database/runtime/databaseService.test.ts create mode 100644 greenfield/src/server/database/runtime/databaseService.ts create mode 100644 greenfield/src/server/database/runtime/databaseStartup.ts create mode 100644 greenfield/src/server/platform/runtime/dashboardApplicationRuntime.test.ts diff --git a/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md index 965be3929..e5d4cb25b 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md +++ b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md @@ -398,15 +398,23 @@ the existing `ManagedRuntime` and exposed by `ApplicationRuntime` to ordinary Ty boundaries. Event-specific allowlists drop unknown fields and Effect messages/annotations; runtime disposal precedes the logger's idempotent flush. -The web `ApplicationRuntime` merges the structured logger, realtime pump, and one process-scoped -authentication-work service into the same `ManagedRuntime`. That authentication service owns separate bounded admission -and active-work semaphores for Gateway verification, password/Argon2 work, TOTP AES/HMAC work, and -WebAuthn parsing/signature verification, plus a scoped fiber set for work that outlives an -interrupted caller. Queued cancellation releases admission immediately; active non-cooperative -work retains its permit until settlement. Promise-facing adapters fold typed capacity into -explicit domain throttling outcomes, while Gateway -capacity, deadline, and unavailable tags are exhaustively translated before the tRPC procedure -maps the resulting domain outcome. No request creates or disposes a runtime. +The production web `ApplicationRuntime` merges the structured logger, process-owned SQLite layer, +database-backed realtime pump, and one process-scoped authentication-work service into the same +`ManagedRuntime`. The database layer loads and verifies the release migration graph before opening +one fixed private state file, configures and verifies the connection policy, and constructs +Drizzle from that retained native handle. The composition root obtains that one ORM for every +domain repository, while the realtime layer depends on the same service and therefore finalizes +before SQLite. The narrower generic runtime factory remains available for focused service and +transport tests with an injected realtime layer. + +The authentication service owns separate bounded admission and active-work semaphores for Gateway +verification, password/Argon2 work, TOTP AES/HMAC work, and WebAuthn parsing/signature +verification, plus a scoped fiber set for work that outlives an interrupted caller. Queued +cancellation releases admission immediately; active non-cooperative work retains its permit until +settlement. Promise-facing adapters fold typed capacity into explicit domain throttling outcomes, +while Gateway capacity, deadline, and unavailable tags are exhaustively translated before the +tRPC procedure maps the resulting domain outcome. No request creates or disposes a database or +runtime. The same `ManagedRuntime` coordinates listener shutdown. An external `stop(true)` request crosses the Promise-facing composition boundary as an abort signal; Effect owns the graceful-stop fiber, @@ -414,6 +422,10 @@ deadline/force race, tagged stop and timeout failures, separately bounded force settlement of the original graceful operation before the runtime scope is disposed. A rejected graceful stop receives one bounded best-effort force attempt while preserving the initiating failure. No second runtime or manual timer/`Promise.race` shutdown system is created. +After listener settlement, runtime finalization closes realtime, passively checkpoints and strictly +closes SQLite, and only then flushes the process logger. If listener escalation cannot prove +settlement, the server keeps runtime resources alive for supervisor containment rather than +closing a database beneath potentially active requests. ## Realtime Architecture diff --git a/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md index 59bcff16a..023bafd40 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md +++ b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md @@ -6,12 +6,20 @@ ### Core rules -- Create `new Database(path, { create: true, strict: true })` through `bun:sqlite`, retain that - native client, and pass the same client into `drizzle({ client })`. Drizzle v1 RC's current - Bun driver no longer accepts the legacy `schema` option; add its explicit `relations` model - only for domains that use the relational query API. -- Enable foreign keys, WAL, a measured busy timeout, and explicit synchronous/checkpoint - policy at process startup. +- Resolve one fixed database filename beneath a canonical current-user-owned `0700` state + directory. Create a missing file with exclusive no-follow `0600` semantics, then call + `new Database(path, { create: false, readwrite: true, strict: true })` through `bun:sqlite`. + Retain that native client and pass the same client into `drizzle({ client })`. Drizzle v1 RC's + current Bun driver no longer accepts the legacy `schema` option; add its explicit `relations` + model only for domains that use the relational query API. Pin and revalidate the directory/file + identities during acquisition, and reject a rollback-journal, shared-memory, or WAL sidecar + unless it is a single-link current-user-owned `0600` regular file. +- Enable and verify foreign keys and checks, disable `trusted_schema`, select WAL with + `synchronous=FULL` and a 1,000-page automatic checkpoint, and set `busy_timeout=0`. The zero + timeout is the measured non-blocking policy: a synchronous SQLite wait must not stall Bun's + event loop. Effect owns explicit bounded retry/deadline schedules at cross-process admission and + read boundaries; the future worker's write adapters must adopt the same explicit policy before + that process starts. - Use Drizzle's typed query builder for ordinary reads/writes and its parameterized `sql` tagged template for SQLite-specific queries, CTEs, queue claims, and expressions not represented cleanly by the builder. @@ -59,9 +67,11 @@ domain repositories still own query intent; the application does not expose Driz objects through service or transport layers. Drizzle also does not own production migration safety. Drizzle Kit generates SQL from the -reviewed TypeScript schema, and that SQL is reviewed and tracked. Dashboard's migration runner -still verifies immutable checksums, snapshots the database, serializes web/worker startup, -applies the SQL, and runs integrity checks. `drizzle-kit push` is forbidden in production. +reviewed TypeScript schema, and that SQL is reviewed and tracked. Dashboard's current runtime +verifies immutable artifact checksums, serializes empty-database initialization, applies only the +unpublished baseline, validates exact schema/history, and runs integrity checks. A pending +published migration fails closed until the release snapshot-and-promotion slice exists; +`drizzle-kit push` is forbidden in production. Drizzle ORM/Kit `1.0.0-rc.4` does not model SQLite's table-level `STRICT` option in `sqliteTable`. Generated `CREATE TABLE` statements are therefore reviewed to add the `STRICT` @@ -184,33 +194,45 @@ Drizzle Kit v1 stores the migration graph as timestamped directories containing `migration.sql` and `snapshot.json`. During the unpublished rewrite, `migrations/` contains exactly one evolving `*_dashboard-foundation` baseline generated from the complete current Drizzle schema. The generated SQL includes the security identity objects, SQLite `STRICT` table options, canonical -NUL-free constraints, and deliberate `audit_events WITHOUT ROWID` hardening. The custom audit -metadata, append-only, monitoring-JSON, and automation replacement-integrity triggers are reviewed -additions because Drizzle does not model them. +NUL-free constraints, bounded migration-ledger identity fields, and deliberate +`audit_events WITHOUT ROWID` hardening. The custom audit metadata, append-only audit/migration +ledger, monitoring-JSON, and automation replacement-integrity triggers are reviewed additions +because Drizzle does not model them. There is no compatibility preflight or upgrade path for an intermediate rewrite database: every test and the final cutover start empty and apply this one baseline. Each schema slice regenerates the baseline, reviews the complete SQL/snapshot diff, and updates the explicit manifest checksums. At cutover those bytes become immutable; later production schema changes are generated as new, forward-only, checksummed nodes. -The snapshot files form Drizzle Kit's conflict-analysis DAG. Dashboard's runtime loader applies -the explicit manifest order after verifying valid 14-digit timestamp prefixes, unique full folder -names, lexicographic ordering, SQL checksums, snapshot checksums, and the absence of unreviewed -directories. After the raw bytes are verified, the runner trims only each statement's outer -whitespace before execution so Bun SQLite cannot mask a trigger abort behind a trailing `;\n`; -the checksummed source stays unchanged. `drizzle-kit check` must be green before release; stock -Drizzle name-based pending detection is not accepted as the integrity boundary. -Startup acquires a migration lock, creates and verifies a WAL-safe snapshot before a post-cutover -schema change, and rejects unknown or checksum-mismatched history. - -Web and worker may start concurrently, but only one migrates. The other waits with a bounded -deadline and validates the final schema. Neither process contains table/column existence -fallbacks. - -Retention is explicit per append-only table. A maintenance job removes bounded batches, -performs passive checkpoints during normal operation, exposes WAL/checkpoint health, and runs -expensive optimization only under a resource-scoped job. Backups include the database and its -release/schema identity and are restore-tested. +The snapshot files form Drizzle Kit's conflict-analysis DAG. Before touching database state, the +Linux runtime loader holds the complete release graph through descriptor-rooted `/proc/self/fd` +paths. It verifies exact root/node inventories, stable single-link regular files, valid 14-digit +timestamp prefixes, identifiers capped at 128 bytes, at most 64 ordered unique nodes, SQL and +snapshot checksums, strict UTF-8 SQL, and 1 MiB SQL / 4 MiB snapshot / 32 MiB total byte ceilings. +After the raw bytes are verified, the runner trims only each statement's outer whitespace before +execution so Bun SQLite cannot mask a trigger abort behind a trailing `;\n`; the checksummed source +stays unchanged. `drizzle-kit check` must be green before release; stock Drizzle name-based pending +detection is not accepted as the integrity boundary. + +`initialize-empty` may exclusively create the fixed private database and applies the complete +baseline inside one immediate transaction. `validate-only` never creates an absent database. Two +concurrent runtime starts serialize through SQLite admission with an Effect-owned five-second +busy/locked deadline, then the loser validates the winner's exact result. Current databases must +match the reviewed schema, immutable checksum ledger, strictly increasing non-future application +times, connection policy, and integrity checks. Unknown history fails closed. A reviewed pending +prefix raises `DatabaseRuntimeSnapshotRequiredError`; it is not migrated in place. + +Post-cutover delivery must add the missing snapshot/promotion protocol before enabling forward +migrations: quiesce writers, acquire the deployment lease, create and verify a WAL-safe snapshot, +apply to a copy, and atomically promote the matching release/database pair. The future web and +worker executable roots may start concurrently, but only one may own that protocol while the other +waits with a bounded deadline and validates the final schema. Neither process may contain +table/column existence fallbacks. + +Retention remains explicit per append-only table. The later maintenance job removes bounded +batches, requests passive checkpoints during normal operation, exposes WAL/checkpoint health, and +runs expensive optimization only under a resource-scoped job. Backups include database plus +release/schema identity and remain restore-tested release artifacts. ## Worker and Privileged Operations diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md index 5d2baf17e..09eb482d0 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -10,7 +10,7 @@ closes a phase; dated entries below provide the evidence, not a second status so | Phase | Status | Current evidence and remaining gate | | ----------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | -| 1 — Foundation | In progress | Server composition, migrations, contracts, raw HTTP/realtime foundations, source-boundary enforcement, staged typed configuration, generated configuration reference, structured logging/request correlation, and procedure error policy exist; executable web/worker roots, database runtime, browser shell, complete generated references, and release/rollback closure remain. | +| 1 — Foundation | In progress | Server composition, migrations, contracts, raw HTTP/realtime foundations, process-owned database runtime, source-boundary enforcement, staged typed configuration, generated configuration reference, structured logging/request correlation, and procedure error policy exist; executable web/worker roots, browser shell, complete generated references, and release/rollback closure remain. | | 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | | 3 — Core operator domains | Started | Monitoring transaction/schema foundations exist; task, agent, report, incident, notification, schedule/job, cache/metrics procedures and browser parity are not complete. | | 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. | @@ -711,5 +711,35 @@ closes a phase; dated entries below provide the evidence, not a second status so `ContractErrorCode` allowlist now match mechanically. Immediate and deferred subscription errors outside a route's declared set are internalized, as is any implemented procedure missing from the policy; framework routing and input/transport validation remain implicit. Phase 1 is still - in progress: executable web/worker roots, database runtime, worker lifecycle, browser shell, and - release/rollback delivery remain open. + in progress: executable web/worker roots, worker lifecycle, browser shell, and release/rollback + delivery remain open. + +### 2026-08-06 — Process-owned database runtime + +- The production `ManagedRuntime` now owns one strict native SQLite connection and the exact + Drizzle client built from it. Dashboard repositories and the database-backed realtime pump use + that same handle; request handling cannot create or dispose a database or runtime. The layer + graph finalizes realtime before a passive checkpoint and strict SQLite close, and the process + logger flushes only after runtime disposal. Listener drain still completes before this sequence, + with readiness withdrawn before drain begins. +- Startup verifies the complete release-owned migration graph before database mutation. The Linux + artifact reader holds descriptor-rooted directories and regular files through `/proc/self/fd`, + rejects symlinks, hardlinks, special files, inventory drift, path replacement, invalid UTF-8, + and checksum mismatch, and enforces 1 MiB SQL, 4 MiB snapshot, and 32 MiB graph ceilings. The + manifest is ordered, unique, and capped at 64 nodes with 128-byte identifiers. +- The database lives at one fixed filename beneath a canonical current-user-owned `0700` state + directory. Dashboard creates a missing file with exclusive no-follow `0600` semantics, opens it + through SQLite with `create: false`, pins and revalidates the directory and file device/inode + identities, and validates every rollback-journal, shared-memory, or WAL sidecar present during + acquisition as a single-link current-user-owned `0600` regular file. +- Every connection verifies foreign keys and checks enabled, `trusted_schema` disabled, WAL, + `synchronous=FULL`, a 1,000-page automatic checkpoint, and `busy_timeout=0`. Zero is deliberate: + SQLite never blocks the Bun thread waiting for another process; bounded Effect schedules own + startup admission and realtime read retries instead. A five-second startup deadline covers + busy/locked variants without exposing native errors. +- `initialize-empty` applies the single unpublished baseline atomically; `validate-only` never + creates an absent database. Already-current state is revalidated against the exact schema and + immutable ledger. The ledger enforces bounded canonical ids, exact checksums/release identities, + strictly increasing non-future timestamps, and append-only triggers. A reviewed pending graph + fails closed with `DatabaseRuntimeSnapshotRequiredError`: verified snapshot/promotion, worker + startup, backup/restore, and release-pair rollback remain later delivery slices. diff --git a/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md index 27bdb590d..eaf7c20f7 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md +++ b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md @@ -262,13 +262,15 @@ Every request, job, Gateway call, and domain transaction receives a correlation logs use stable event names and include release identity, process role, duration, outcome, and safe identifiers. They do not serialize arbitrary request bodies or command environments. -The current web factory contract requires one process logger, installs it as the only Effect -logger on the existing `ManagedRuntime`, and reuses that exact instance at ordinary HTTP/tRPC -boundaries. Its serializer emits bounded NDJSON from event-specific allowlisted fields and flushes -the synchronous sink after runtime disposal; a sink failure emits one constant direct-stderr -fallback without recursive logging. The future executable web/worker composition roots still own creation of -the stdout/stderr sink, release/config identity, and startup/shutdown events; this slice does not -claim that absent process entrypoint. +The current production web runtime requires one process logger, installs it as the only Effect +logger, and reuses that exact instance at ordinary HTTP/tRPC boundaries. The same `ManagedRuntime` +owns the retained SQLite/Drizzle service and its database-backed realtime dependency. After the +listener settles, realtime finalizes before passive checkpoint and strict database close; the +synchronous log sink flushes last. Its serializer emits bounded NDJSON from event-specific +allowlisted fields, and a sink failure emits one constant direct-stderr fallback without recursive +logging. The future executable web/worker composition roots still own creation of the stdout/stderr +sink, release/config identity, and startup/shutdown events; this slice does not claim that absent +process entrypoint. Expose distinct probes: diff --git a/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql b/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql index b0371419f..3edc47571 100644 --- a/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql +++ b/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql @@ -265,7 +265,11 @@ CREATE TABLE `schema_migrations` ( `applied_at` integer NOT NULL, `checksum` text NOT NULL, `id` text PRIMARY KEY, - `release_id` text NOT NULL + `release_id` text NOT NULL, + CONSTRAINT "schema_migrations_applied_at_check" CHECK("applied_at" BETWEEN 0 AND 8640000000000000), + CONSTRAINT "schema_migrations_checksum_check" CHECK(length("checksum") = 64 AND instr("checksum", char(0)) = 0 AND "checksum" NOT GLOB '*[^0-9a-f]*'), + CONSTRAINT "schema_migrations_id_check" CHECK(length("id") BETWEEN 16 AND 128 AND instr("id", char(0)) = 0 AND substr("id", 1, 14) NOT GLOB '*[^0-9]*' AND substr("id", 15, 1) = '_' AND substr("id", 16, 1) GLOB '[a-z0-9]' AND substr("id", 16) NOT GLOB '*[^a-z0-9_-]*'), + CONSTRAINT "schema_migrations_release_id_check" CHECK(length("release_id") = 40 AND instr("release_id", char(0)) = 0 AND "release_id" NOT GLOB '*[^0-9a-f]*') ) STRICT; --> statement-breakpoint CREATE TABLE `user_recovery_codes` ( @@ -614,3 +618,22 @@ BEFORE DELETE ON audit_events BEGIN SELECT RAISE(ABORT, 'audit_events is append-only'); END; +--> statement-breakpoint +CREATE TRIGGER schema_migrations_reject_replace +BEFORE INSERT ON schema_migrations +WHEN EXISTS (SELECT 1 FROM schema_migrations WHERE id = NEW.id) +BEGIN + SELECT RAISE(ABORT, 'schema_migrations is append-only'); +END; +--> statement-breakpoint +CREATE TRIGGER schema_migrations_reject_update +BEFORE UPDATE ON schema_migrations +BEGIN + SELECT RAISE(ABORT, 'schema_migrations is append-only'); +END; +--> statement-breakpoint +CREATE TRIGGER schema_migrations_reject_delete +BEFORE DELETE ON schema_migrations +BEGIN + SELECT RAISE(ABORT, 'schema_migrations is append-only'); +END; diff --git a/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json b/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json index e7e1964a9..9cc323c76 100644 --- a/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json +++ b/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json @@ -1,7 +1,7 @@ { "version": "7", "dialect": "sqlite", - "id": "e7bb3180-9946-4254-9b22-e00d9871a332", + "id": "7c296db8-4e4b-4ecd-a214-72e9e88165c3", "prevIds": [ "00000000-0000-0000-0000-000000000000" ], @@ -3439,6 +3439,30 @@ "entityType": "checks", "table": "reports" }, + { + "value": "\"applied_at\" BETWEEN 0 AND 8640000000000000", + "name": "schema_migrations_applied_at_check", + "entityType": "checks", + "table": "schema_migrations" + }, + { + "value": "length(\"checksum\") = 64 AND instr(\"checksum\", char(0)) = 0 AND \"checksum\" NOT GLOB '*[^0-9a-f]*'", + "name": "schema_migrations_checksum_check", + "entityType": "checks", + "table": "schema_migrations" + }, + { + "value": "length(\"id\") BETWEEN 16 AND 128 AND instr(\"id\", char(0)) = 0 AND substr(\"id\", 1, 14) NOT GLOB '*[^0-9]*' AND substr(\"id\", 15, 1) = '_' AND substr(\"id\", 16, 1) GLOB '[a-z0-9]' AND substr(\"id\", 16) NOT GLOB '*[^a-z0-9_-]*'", + "name": "schema_migrations_id_check", + "entityType": "checks", + "table": "schema_migrations" + }, + { + "value": "length(\"release_id\") = 40 AND instr(\"release_id\", char(0)) = 0 AND \"release_id\" NOT GLOB '*[^0-9a-f]*'", + "name": "schema_migrations_release_id_check", + "entityType": "checks", + "table": "schema_migrations" + }, { "value": "length(\"id\") = 36 AND instr(\"id\", char(0)) = 0 AND length(replace(\"id\", '-', '')) = 32 AND replace(\"id\", '-', '') NOT GLOB '*[^0-9a-f]*' AND substr(\"id\", 9, 1) = '-' AND substr(\"id\", 14, 1) = '-' AND substr(\"id\", 15, 1) = '7' AND substr(\"id\", 19, 1) = '-' AND substr(\"id\", 20, 1) GLOB '[89ab]' AND substr(\"id\", 24, 1) = '-'", "name": "user_recovery_codes_id_check", diff --git a/greenfield/src/app/dashboardServer.test.ts b/greenfield/src/app/dashboardServer.test.ts index 9811aff01..a950f4086 100644 --- a/greenfield/src/app/dashboardServer.test.ts +++ b/greenfield/src/app/dashboardServer.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { chmod, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import * as v from "valibot"; @@ -7,12 +10,14 @@ import { createWebAuthnRelyingPartyConfiguration } from "../server/domains/secur import { authenticationTestNow, authenticationTestPrincipalId, - openAuthenticationTestDatabase, + seedAuthenticationTestDatabase, testTotpSecretCipher, } from "../server/domains/security/testSupport/authentication.ts"; import { createReadinessController } from "../server/platform/readiness/readinessState.ts"; +import { createDashboardApplicationRuntime } from "../server/platform/runtime/applicationRuntime.ts"; import { dashboardSessionCookieName } from "../server/rawHttp/authenticationCredentials.ts"; -import { createTestApplicationRuntime } from "../server/test/support/requestContext.ts"; +import { migrationsDirectory } from "../server/test/support/freshDatabase.ts"; +import { createTestStructuredLogger } from "../server/test/support/requestContext.ts"; import { createDashboardServer, validateDashboardWebAuthnBrowserOrigin, @@ -43,19 +48,37 @@ describe("Dashboard security composition", () => { }); test("wires the persisted automation lifecycle through the production server", async () => { - const fixture = await openAuthenticationTestDatabase(); - const server = await createDashboardServer({ - applicationRuntime: createTestApplicationRuntime(), - browserOrigin: "https://dashboard.example", - database: fixture.database.orm, - gatewayUrl: "ws://127.0.0.1:1", - now: () => authenticationTestNow, - port: 0, - readiness: createReadinessController(), - totpSecretCipher: testTotpSecretCipher, + const stateDirectory = await mkdtemp( + path.join(os.tmpdir(), "dashboard-server-composition-") + ); + await chmod(stateDirectory, 0o700); + const applicationRuntime = createDashboardApplicationRuntime({ + database: { + migrationsDirectory, + releaseId: "0".repeat(40), + startupMode: "initialize-empty", + stateDirectory, + }, + logger: createTestStructuredLogger(), }); + let server: Awaited> | undefined; try { + await applicationRuntime.initialize(); + const database = await applicationRuntime.database.orm(); + const fixture = seedAuthenticationTestDatabase( + database, + authenticationTestNow + ); + server = await createDashboardServer({ + applicationRuntime, + browserOrigin: "https://dashboard.example", + gatewayUrl: "ws://127.0.0.1:1", + now: () => authenticationTestNow, + port: 0, + readiness: createReadinessController(), + totpSecretCipher: testTotpSecretCipher, + }); const input = encodeURIComponent(JSON.stringify({ json: {} })); const response = await fetch( new URL( @@ -89,8 +112,13 @@ describe("Dashboard security composition", () => { id: authenticationTestPrincipalId, }); } finally { - await server.stop(true); - fixture.database.sqlite.close(true); + try { + await (server === undefined + ? applicationRuntime.dispose() + : server.stop(true)); + } finally { + await rm(stateDirectory, { force: true, recursive: true }); + } } }); }); diff --git a/greenfield/src/app/dashboardServer.ts b/greenfield/src/app/dashboardServer.ts index 2259e9d5d..383033983 100644 --- a/greenfield/src/app/dashboardServer.ts +++ b/greenfield/src/app/dashboardServer.ts @@ -1,5 +1,3 @@ -import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; - import { createAuthenticationLifecycleService } from "../server/domains/security/authenticationLifecycle.ts"; import { createAuthenticationLifecycleRepository } from "../server/domains/security/authenticationLifecycleRepository.ts"; import { @@ -22,6 +20,7 @@ import type { WebAuthnRelyingPartyConfiguration } from "../server/domains/securi import { createRequestAuthenticator } from "../server/domains/security/requestAuthentication.ts"; import { createRequestAuthenticationRepository } from "../server/domains/security/requestAuthenticationRepository.ts"; import { createGatewayCredentialVerifier } from "../server/platform/gateway/gatewayCredentialVerifier.ts"; +import type { DashboardApplicationRuntime } from "../server/platform/runtime/applicationRuntime.ts"; import { parseBrowserOrigin } from "../server/rawHttp/requestSecurity.ts"; import { createServer, type ApplicationServer, type ServerOptions } from "./server.ts"; @@ -29,6 +28,7 @@ import { createServer, type ApplicationServer, type ServerOptions } from "./serv export interface DashboardServerOptions extends Omit< ServerOptions, | "authenticateCredential" + | "applicationRuntime" | "authenticationLifecycle" | "automationSecurityLifecycle" | "browserOrigin" @@ -36,10 +36,10 @@ export interface DashboardServerOptions extends Omit< | "mfaAccountLifecycle" | "mfaLoginLifecycle" > { + readonly applicationRuntime: DashboardApplicationRuntime; readonly authenticationLeaseDurationMs?: number; /** Canonical public origin used by browser Origin checks behind the proxy. */ readonly browserOrigin: string; - readonly database: SQLiteBunDatabase; /** Explicit native WebSocket endpoint used only for one-shot bootstrap verification. */ readonly gatewayUrl: string; readonly gatewayVerificationTimeoutMs?: number; @@ -77,12 +77,11 @@ export function validateDashboardWebAuthnBrowserOrigin( } /** - * Wires the migrated SQLite identity store into real request authentication. - * The caller retains database and process-runtime lifecycle ownership. - * @param options Server, database, and bounded authentication policy options. + * Wires the runtime-owned SQLite identity store into real request authentication. + * @param options Server and bounded authentication policy options. * @returns A started Bun server using persisted session and automation identities. */ -export function createDashboardServer( +export async function createDashboardServer( options: DashboardServerOptions ): Promise { const browserOrigin = validateDashboardWebAuthnBrowserOrigin( @@ -120,79 +119,101 @@ export function createDashboardServer( workBudget: webAuthnWorkBudget, workRuntime: authenticationWork, }); - const repository = createRequestAuthenticationRepository(options.database); - const authenticator = createRequestAuthenticator({ - authenticationLeaseDurationMs: options.authenticationLeaseDurationMs, - ...(options.now !== undefined && { now: options.now }), - repository, - sessionIdleDurationMs: options.sessionIdleDurationMs, - }); - const mfaRepository = createMfaLifecycleRepository(options.database); - const mfaLoginLifecycle = createMfaLoginLifecycleService({ - ...(options.now !== undefined && { now: options.now }), - passwordWorkBudget, - passwordWorkGate, - repository: mfaRepository, - sessionIdleDurationMs: options.sessionIdleDurationMs, - totpSecretCipher: options.totpSecretCipher, - totpWorkBudget, - totpWorkGate: authenticationWork.totpWorkGate, - ...(webAuthn === undefined ? {} : { webAuthn }), - }); - const mfaAccountLifecycle = createMfaAccountLifecycleService({ - ...(options.now !== undefined && { now: options.now }), - passwordWorkBudget, - passwordWorkGate, - recentAuthenticationWindowMs: options.recentAuthenticationWindowMs, - repository: mfaRepository, - sessionIdleDurationMs: options.sessionIdleDurationMs, - totpSecretCipher: options.totpSecretCipher, - totpWorkBudget, - totpWorkGate: authenticationWork.totpWorkGate, - ...(webAuthn === undefined - ? {} - : { - webAuthnAdapter: webAuthn.adapter, - webAuthnRelyingParty: webAuthn.relyingParty, - ...(webAuthn.verificationTimeoutMs === undefined - ? {} - : { - webAuthnVerificationTimeoutMs: webAuthn.verificationTimeoutMs, - }), - webAuthnWorkBudget, - webAuthnWorkRuntime: authenticationWork, - }), - }); - const authenticationLifecycle = createAuthenticationLifecycleService({ - gatewayVerificationTimeoutMs: options.gatewayVerificationTimeoutMs, - gatewayWorkRuntime: authenticationWork, - mfaLoginLifecycle, - ...(options.now !== undefined && { now: options.now }), - passwordWorkBudget, - passwordWorkGate, - recentAuthenticationWindowMs: options.recentAuthenticationWindowMs, - repository: createAuthenticationLifecycleRepository(options.database), - sessionIdleDurationMs: options.sessionIdleDurationMs, - verifyGatewayCredential, - }); - const automationSecurityLifecycle = createAutomationSecurityLifecycleService({ - ...(options.now !== undefined && { now: options.now }), - recentAuthenticationWindowMs: options.recentAuthenticationWindowMs, - repository: createAutomationLifecycleRepository(options.database), - sessionIdleDurationMs: options.sessionIdleDurationMs, - }); - return createServer({ - applicationRuntime: options.applicationRuntime, - authenticateCredential: (credential) => authenticator.authenticate(credential), - authenticationLifecycle, - automationSecurityLifecycle, - browserOrigin, - gracefulShutdownTimeoutMs: options.gracefulShutdownTimeoutMs, - hostname: "127.0.0.1", - mfaAccountLifecycle, - mfaLoginLifecycle, - port: options.port, - readiness: options.readiness, - trustedProxyAddresses: options.trustedProxyAddresses, - }); + let serverOwnsRuntimeCleanup = false; + try { + await options.applicationRuntime.initialize(); + const database = await options.applicationRuntime.database.orm(); + const repository = createRequestAuthenticationRepository(database); + const authenticator = createRequestAuthenticator({ + authenticationLeaseDurationMs: options.authenticationLeaseDurationMs, + ...(options.now !== undefined && { now: options.now }), + repository, + sessionIdleDurationMs: options.sessionIdleDurationMs, + }); + const mfaRepository = createMfaLifecycleRepository(database); + const mfaLoginLifecycle = createMfaLoginLifecycleService({ + ...(options.now !== undefined && { now: options.now }), + passwordWorkBudget, + passwordWorkGate, + repository: mfaRepository, + sessionIdleDurationMs: options.sessionIdleDurationMs, + totpSecretCipher: options.totpSecretCipher, + totpWorkBudget, + totpWorkGate: authenticationWork.totpWorkGate, + ...(webAuthn === undefined ? {} : { webAuthn }), + }); + const mfaAccountLifecycle = createMfaAccountLifecycleService({ + ...(options.now !== undefined && { now: options.now }), + passwordWorkBudget, + passwordWorkGate, + recentAuthenticationWindowMs: options.recentAuthenticationWindowMs, + repository: mfaRepository, + sessionIdleDurationMs: options.sessionIdleDurationMs, + totpSecretCipher: options.totpSecretCipher, + totpWorkBudget, + totpWorkGate: authenticationWork.totpWorkGate, + ...(webAuthn === undefined + ? {} + : { + webAuthnAdapter: webAuthn.adapter, + webAuthnRelyingParty: webAuthn.relyingParty, + ...(webAuthn.verificationTimeoutMs === undefined + ? {} + : { + webAuthnVerificationTimeoutMs: + webAuthn.verificationTimeoutMs, + }), + webAuthnWorkBudget, + webAuthnWorkRuntime: authenticationWork, + }), + }); + const authenticationLifecycle = createAuthenticationLifecycleService({ + gatewayVerificationTimeoutMs: options.gatewayVerificationTimeoutMs, + gatewayWorkRuntime: authenticationWork, + mfaLoginLifecycle, + ...(options.now !== undefined && { now: options.now }), + passwordWorkBudget, + passwordWorkGate, + recentAuthenticationWindowMs: options.recentAuthenticationWindowMs, + repository: createAuthenticationLifecycleRepository(database), + sessionIdleDurationMs: options.sessionIdleDurationMs, + verifyGatewayCredential, + }); + const automationSecurityLifecycle = createAutomationSecurityLifecycleService({ + ...(options.now !== undefined && { now: options.now }), + recentAuthenticationWindowMs: options.recentAuthenticationWindowMs, + repository: createAutomationLifecycleRepository(database), + sessionIdleDurationMs: options.sessionIdleDurationMs, + }); + serverOwnsRuntimeCleanup = true; + return await createServer({ + applicationRuntime: options.applicationRuntime, + authenticateCredential: (credential) => + authenticator.authenticate(credential), + authenticationLifecycle, + automationSecurityLifecycle, + browserOrigin, + gracefulShutdownTimeoutMs: options.gracefulShutdownTimeoutMs, + hostname: "127.0.0.1", + mfaAccountLifecycle, + mfaLoginLifecycle, + port: options.port, + readiness: options.readiness, + trustedProxyAddresses: options.trustedProxyAddresses, + }); + } catch (error) { + if (!serverOwnsRuntimeCleanup) { + try { + await options.applicationRuntime.dispose(); + } catch { + // Preserve the initiating composition failure. + } + try { + options.applicationRuntime.logger.flush(); + } catch { + // Structured logger fallback handling owns sink failures. + } + } + throw error; + } } diff --git a/greenfield/src/app/server.ts b/greenfield/src/app/server.ts index a7f725076..b0f549a25 100644 --- a/greenfield/src/app/server.ts +++ b/greenfield/src/app/server.ts @@ -267,7 +267,12 @@ export async function createServer(options: ServerOptions): Promise { + if (stopPromise !== undefined) return stopPromise; + + // Withdraw readiness before listener drain begins so the proxy stops + // admitting new work while active HTTP and SSE requests settle. + options.readiness.markUnavailable(); + stopPromise = (async () => { try { await options.applicationRuntime.shutdownListener({ forceSignal: forceStopController.signal, diff --git a/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts b/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts index 1c3cbfd9c..374dfc1ff 100644 --- a/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts +++ b/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts @@ -1,6 +1,6 @@ import { Database } from "bun:sqlite"; -import { addMilliseconds, getTime } from "date-fns"; +import { getTime } from "date-fns"; import * as v from "valibot"; import { timestampMillisecondsSchema } from "../../../shared/dateTime.ts"; @@ -28,6 +28,10 @@ export interface ApplyVerifiedMigrationsOptions { const migrationHistoryMismatchError = "Database migration history does not match the reviewed manifest"; +const futureMigrationHistoryError = + "Database migration history is newer than the application clock"; +const nonAdvancingMigrationClockError = + "Migration appliedAt must advance beyond stored migration history"; const schemaHistoryMismatchError = "Database schema does not match reviewed migration history"; const migrationAppliedAtSchema = timestampMillisecondsSchema( @@ -156,6 +160,59 @@ function assertAppliedPrefix( } } +function latestAppliedAt(applied: readonly AppliedMigrationRow[]): number | undefined { + let previousAppliedAt: number | undefined; + + for (const migration of applied) { + if (previousAppliedAt !== undefined && migration.appliedAt <= previousAppliedAt) { + throw new Error(migrationHistoryMismatchError); + } + previousAppliedAt = migration.appliedAt; + } + + return previousAppliedAt; +} + +/** + * Plans strictly increasing ledger timestamps before any migration statement runs. + * @param checkedAt Application clock in epoch milliseconds. + * @param pendingCount Number of canonical migrations to apply. + * @param storedAppliedAt Latest validated ledger timestamp, when one exists. + * @returns One valid epoch-millisecond value per pending migration. + * @internal + */ +export function planMigrationAppliedAtValues( + checkedAt: number, + pendingCount: number, + storedAppliedAt?: number +): readonly number[] { + const baseAppliedAt = parseSchemaWithRangeError(migrationAppliedAtSchema, checkedAt); + + if (storedAppliedAt !== undefined && storedAppliedAt > baseAppliedAt) { + throw new Error(futureMigrationHistoryError); + } + if (pendingCount === 0) return Object.freeze([]); + + // Right-align the sequence at the observed clock so same-millisecond migrations + // remain ordered without creating ledger rows dated in the future. + const firstAppliedAt = parseSchemaWithRangeError( + migrationAppliedAtSchema, + baseAppliedAt - pendingCount + 1 + ); + if (storedAppliedAt !== undefined && firstAppliedAt <= storedAppliedAt) { + throw new Error(nonAdvancingMigrationClockError); + } + + return Object.freeze( + Array.from({ length: pendingCount }, (_, pendingIndex) => + parseSchemaWithRangeError( + migrationAppliedAtSchema, + firstAppliedAt + pendingIndex + ) + ) + ); +} + function assertCanonicalVerifiedGraph(migrations: readonly VerifiedMigration[]): void { if ( migrations.length !== migrationManifest.length || @@ -216,18 +273,18 @@ export function applyVerifiedMigrations( assertSchemaMatchesReviewedHistory(database, migrations, applied.length); const pending = migrations.slice(applied.length); - const baseAppliedAt = parseSchemaWithRangeError( - migrationAppliedAtSchema, - getTime(options.appliedAt ?? new Date()) + const appliedAtValues = planMigrationAppliedAtValues( + getTime(options.appliedAt ?? new Date()), + pending.length, + latestAppliedAt(applied) ); for (const [pendingIndex, migration] of pending.entries()) { + const appliedAt = appliedAtValues[pendingIndex]; + if (appliedAt === undefined) { + throw new Error("Migration timestamp plan is incomplete"); + } runMigrationStatements(database, migration.statements); - - const appliedAt = parseSchemaWithRangeError( - migrationAppliedAtSchema, - getTime(addMilliseconds(baseAppliedAt, pendingIndex)) - ); database.run( `INSERT INTO schema_migrations ( applied_at, diff --git a/greenfield/src/server/database/migrations/loadVerifiedMigrations.test.ts b/greenfield/src/server/database/migrations/loadVerifiedMigrations.test.ts index ca517c7d9..a8568fff5 100644 --- a/greenfield/src/server/database/migrations/loadVerifiedMigrations.test.ts +++ b/greenfield/src/server/database/migrations/loadVerifiedMigrations.test.ts @@ -1,10 +1,28 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { cp, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { + appendFile, + cp, + link, + mkdir, + mkdtemp, + open, + rename, + rm, + symlink, + truncate, + writeFile, +} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { sha256Hex } from "../../shared/crypto.ts"; import { migrationsDirectory } from "../../test/support/freshDatabase.ts"; -import { loadVerifiedMigrations } from "./loadVerifiedMigrations.ts"; +import { + loadVerifiedMigrations, + migrationArtifactByteLimits, + type MigrationArtifactVerificationTestHooks, + type MigrationArtifactVerificationTestStage, +} from "./loadVerifiedMigrations.ts"; import { migrationManifest } from "./manifest.ts"; const temporaryDirectories: string[] = []; @@ -35,7 +53,7 @@ function reviewedMigration() { async function expectRejection( operation: Promise, expectedMessage: string -): Promise { +): Promise { let rejection: unknown; try { @@ -46,6 +64,51 @@ async function expectRejection( expect(rejection).toBeInstanceOf(Error); expect((rejection as Error).message).toContain(expectedMessage); + return rejection as Error; +} + +function createStageBarrier(targetStage: MigrationArtifactVerificationTestStage): { + hooks: MigrationArtifactVerificationTestHooks; + reached: Promise; + release: () => void; +} { + const reached = Promise.withResolvers(); + const release = Promise.withResolvers(); + let hasReachedTarget = false; + return { + hooks: { + async afterStage(stage) { + if (stage !== targetStage || hasReachedTarget) return; + hasReachedTarget = true; + reached.resolve(); + await release.promise; + }, + }, + reached: reached.promise, + release: release.resolve, + }; +} + +async function rejectAfterStage( + directory: string, + stage: MigrationArtifactVerificationTestStage, + mutate: () => Promise +): Promise { + const barrier = createStageBarrier(stage); + const operation = loadVerifiedMigrations({ + directory, + testHooks: barrier.hooks, + }); + await barrier.reached; + try { + await mutate(); + } finally { + barrier.release(); + } + return expectRejection( + operation, + "Migration artifact graph is not a stable regular-file graph" + ); } describe("reviewed migration manifest", () => { @@ -125,6 +188,34 @@ describe("reviewed migration manifest", () => { ); }); + test("rejects manifest counts and ids beyond the reviewed bounds", async () => { + const directory = await copyMigrationGraph(); + const migration = reviewedMigration(); + + await expectRejection( + loadVerifiedMigrations({ + directory, + manifest: Array.from({ length: 65 }, (_, index) => ({ + ...migration, + id: `2026080403${String(index).padStart(4, "0")}_bounded`, + })), + }), + "Migration manifest contains an invalid or duplicate folder name" + ); + await expectRejection( + loadVerifiedMigrations({ + directory, + manifest: [ + { + ...migration, + id: `20260804022252_${"a".repeat(114)}`, + }, + ], + }), + "Migration manifest contains an invalid or duplicate folder name" + ); + }); + test("rejects manifest ids outside runtime order", async () => { const directory = await copyMigrationGraph(); const migration = reviewedMigration(); @@ -229,4 +320,270 @@ describe("reviewed migration manifest", () => { "Migration directory does not match the reviewed manifest" ); }); + + test("rejects every unreviewed top-level entry type", async () => { + for (const entryType of ["file", "fifo", "symlink"] as const) { + const directory = await copyMigrationGraph(); + const extraEntry = path.join(directory, `unreviewed-${entryType}`); + if (entryType === "file") { + await writeFile(extraEntry, "unreviewed", "utf8"); + } else if (entryType === "symlink") { + await symlink(reviewedMigration().id, extraEntry); + } else { + const creation = Bun.spawnSync({ + cmd: ["mkfifo", extraEntry], + stderr: "pipe", + stdout: "ignore", + }); + expect(creation.success).toBeTrue(); + } + + const error = await expectRejection( + loadVerifiedMigrations({ directory }), + "Migration directory does not match the reviewed manifest" + ); + expect(String(error)).not.toContain(directory); + } + }); + + test("requires exactly migration.sql and snapshot.json in each node", async () => { + for (const mutation of ["extra", "missing"] as const) { + const directory = await copyMigrationGraph(); + const migrationDirectory = path.join(directory, reviewedMigration().id); + await (mutation === "extra" + ? writeFile( + path.join(migrationDirectory, "unreviewed.txt"), + "unreviewed", + "utf8" + ) + : rm(path.join(migrationDirectory, "snapshot.json"))); + + await expectRejection( + loadVerifiedMigrations({ directory }), + "Migration node does not contain the exact reviewed artifacts" + ); + } + }); + + test("rejects symlink, FIFO, and directory artifacts without blocking", async () => { + for (const artifactType of ["symlink", "fifo", "directory"] as const) { + const directory = await copyMigrationGraph(); + const migrationDirectory = path.join(directory, reviewedMigration().id); + const migrationSql = path.join(migrationDirectory, "migration.sql"); + await rm(migrationSql); + if (artifactType === "symlink") { + await symlink("snapshot.json", migrationSql); + } else if (artifactType === "directory") { + await mkdir(migrationSql); + } else { + const creation = Bun.spawnSync({ + cmd: ["mkfifo", migrationSql], + stderr: "pipe", + stdout: "ignore", + }); + expect(creation.success).toBeTrue(); + } + + const error = await expectRejection( + loadVerifiedMigrations({ directory }), + "Migration artifact graph is not a stable regular-file graph" + ); + expect(String(error)).not.toContain(migrationSql); + } + }, 2000); + + test("rejects a hardlinked reviewed artifact", async () => { + const directory = await copyMigrationGraph(); + const migrationSql = path.join( + directory, + reviewedMigration().id, + "migration.sql" + ); + const secondLink = `${directory}-migration-hardlink`; + temporaryDirectories.push(secondLink); + await link(migrationSql, secondLink); + + await expectRejection( + loadVerifiedMigrations({ directory }), + "Migration artifact graph is not a stable regular-file graph" + ); + }); + + test("rejects migration-root and reviewed-node symlinks", async () => { + const rootTarget = await copyMigrationGraph(); + const rootLink = `${rootTarget}-root-link`; + temporaryDirectories.push(rootLink); + await symlink(rootTarget, rootLink); + await expectRejection( + loadVerifiedMigrations({ directory: rootLink }), + "Migration directory does not match the reviewed manifest" + ); + + const directory = await copyMigrationGraph(); + const node = path.join(directory, reviewedMigration().id); + const nodeTarget = `${directory}-node-target`; + temporaryDirectories.push(nodeTarget); + await rename(node, nodeTarget); + await symlink(nodeTarget, node); + await expectRejection( + loadVerifiedMigrations({ directory }), + "Migration artifact graph is not a stable regular-file graph" + ); + }); + + test("rejects a device where a migration directory is required", async () => { + await expectRejection( + loadVerifiedMigrations({ directory: "/dev/null" }), + "Migration directory does not match the reviewed manifest" + ); + }); + + test("enforces per-file byte limits before reading", async () => { + for (const [filename, maximumBytes] of [ + ["migration.sql", migrationArtifactByteLimits.migrationSql], + ["snapshot.json", migrationArtifactByteLimits.snapshot], + ] as const) { + const directory = await copyMigrationGraph(); + await truncate( + path.join(directory, reviewedMigration().id, filename), + maximumBytes + 1 + ); + + await expectRejection( + loadVerifiedMigrations({ directory }), + "Migration artifact graph exceeds the reviewed byte budget" + ); + } + }); + + test("enforces the total graph byte limit before reading artifacts", async () => { + const directory = await copyMigrationGraph(); + const sourceNode = path.join(directory, reviewedMigration().id); + const manifest = []; + for (let index = 0; index < 7; index += 1) { + const id = `2026080402230${index}_bounded-graph-${index}`; + const node = path.join(directory, id); + await cp(sourceNode, node, { recursive: true }); + await truncate( + path.join(node, "migration.sql"), + migrationArtifactByteLimits.migrationSql + ); + await truncate( + path.join(node, "snapshot.json"), + migrationArtifactByteLimits.snapshot + ); + manifest.push({ + id, + migrationSha256: "0".repeat(64), + snapshotSha256: "0".repeat(64), + }); + } + await rm(sourceNode, { recursive: true }); + + await expectRejection( + loadVerifiedMigrations({ directory, manifest }), + "Migration artifact graph exceeds the reviewed byte budget" + ); + }); + + test("rejects migration SQL that is not strict UTF-8 after checksum verification", async () => { + const directory = await copyMigrationGraph(); + const migration = reviewedMigration(); + const invalidUtf8 = Buffer.from([195, 40]); + await writeFile(path.join(directory, migration.id, "migration.sql"), invalidUtf8); + + await expectRejection( + loadVerifiedMigrations({ + directory, + manifest: [ + { + ...migration, + migrationSha256: sha256Hex(invalidUtf8), + }, + ], + }), + `Migration SQL is not valid UTF-8: ${migration.id}` + ); + }); + + test("rejects deterministic shrink and growth after descriptor stat", async () => { + for (const mutation of ["shrink", "grow"] as const) { + const directory = await copyMigrationGraph(); + const migrationSql = path.join( + directory, + reviewedMigration().id, + "migration.sql" + ); + const error = await rejectAfterStage( + directory, + "migration-sql-initial-stat", + () => + mutation === "shrink" + ? truncate(migrationSql, 4) + : appendFile(migrationSql, "\nSELECT 1;", "utf8") + ); + expect(String(error)).not.toContain(migrationSql); + } + }); + + test("rejects a deterministic same-size overwrite after descriptor stat", async () => { + const directory = await copyMigrationGraph(); + const migrationSql = path.join( + directory, + reviewedMigration().id, + "migration.sql" + ); + const mutator = await open(migrationSql, "r+"); + try { + await rejectAfterStage(directory, "migration-sql-initial-stat", async () => { + await mutator.write(Buffer.from("tampered"), 0, 8, 0); + await mutator.sync(); + }); + } finally { + await mutator.close(); + } + }); + + test("rejects requested artifact path replacement with identical bytes", async () => { + const directory = await copyMigrationGraph(); + const migrationSql = path.join( + directory, + reviewedMigration().id, + "migration.sql" + ); + const replacement = `${directory}-replacement.sql`; + temporaryDirectories.push(replacement); + await cp(migrationSql, replacement); + + await rejectAfterStage(directory, "migration-sql-initial-stat", () => + rename(replacement, migrationSql) + ); + }); + + test("rejects reviewed node directory replacement with identical contents", async () => { + const directory = await copyMigrationGraph(); + const node = path.join(directory, reviewedMigration().id); + const originalNode = `${directory}-original-node`; + const replacementNode = `${directory}-replacement-node`; + temporaryDirectories.push(originalNode, replacementNode); + await cp(node, replacementNode, { recursive: true }); + + await rejectAfterStage(directory, "node-inventory", async () => { + await rename(node, originalNode); + await rename(replacementNode, node); + }); + }); + + test("rejects migration root replacement with an identical graph", async () => { + const directory = await copyMigrationGraph(); + const originalRoot = `${directory}-original-root`; + const replacementRoot = `${directory}-replacement-root`; + temporaryDirectories.push(originalRoot, replacementRoot); + await cp(directory, replacementRoot, { recursive: true }); + + await rejectAfterStage(directory, "root-inventory", async () => { + await rename(directory, originalRoot); + await rename(replacementRoot, directory); + }); + }); }); diff --git a/greenfield/src/server/database/migrations/loadVerifiedMigrations.ts b/greenfield/src/server/database/migrations/loadVerifiedMigrations.ts index 74de2fcc6..f06293272 100644 --- a/greenfield/src/server/database/migrations/loadVerifiedMigrations.ts +++ b/greenfield/src/server/database/migrations/loadVerifiedMigrations.ts @@ -1,20 +1,27 @@ -import { readdir, readFile } from "node:fs/promises"; - import * as v from "valibot"; import { lowercaseSha256Schema } from "../../../shared/validation.ts"; import { sha256Hex } from "../../shared/crypto.ts"; import { migrationManifest, type MigrationManifestEntry } from "./manifest.ts"; -import { migrationIdSchema } from "./validation.ts"; +import { + type MigrationArtifactVerificationTestHooks, + readStableMigrationArtifactGraph, +} from "./migrationArtifactFilesystem.ts"; +import { migrationIdMaximumLength, migrationIdSchema } from "./validation.ts"; + +export { + migrationArtifactByteLimits, + type MigrationArtifactVerificationTestHooks, + type MigrationArtifactVerificationTestStage, +} from "./migrationArtifactFilesystem.ts"; export const drizzleStatementBreakpoint = "--> statement-breakpoint"; +const maximumMigrationCount = 64; const invalidManifestFolderError = "Migration manifest contains an invalid or duplicate folder name"; const invalidManifestChecksumError = "Migration manifest contains an invalid SHA-256 checksum"; -const migrationDirectoryMismatchError = - "Migration directory does not match the reviewed manifest"; const manifestMigrationIdSchema = migrationIdSchema(invalidManifestFolderError); const unverifiedMigrationManifestEntrySchema = v.strictObject( { @@ -29,10 +36,6 @@ const unverifiedMigrationManifestSchema = v.array( invalidManifestFolderError ); const manifestChecksumSchema = lowercaseSha256Schema(invalidManifestChecksumError); -const migrationDirectoryNamesSchema = v.array( - migrationIdSchema(migrationDirectoryMismatchError), - migrationDirectoryMismatchError -); export interface VerifiedMigration extends MigrationManifestEntry { statements: readonly string[]; @@ -41,11 +44,26 @@ export interface VerifiedMigration extends MigrationManifestEntry { export interface VerifyMigrationOptions { directory: string; manifest?: unknown; + /** + * Deterministic mutation boundary for loader security tests. + * @internal + */ + testHooks?: MigrationArtifactVerificationTestHooks; +} + +function invalidState(message: string): Error { + return new Error(message); } function parseAndAssertManifest( unverifiedManifest: unknown ): readonly MigrationManifestEntry[] { + if ( + !Array.isArray(unverifiedManifest) || + unverifiedManifest.length > maximumMigrationCount + ) { + throw invalidState(invalidManifestFolderError); + } const validation = v.safeParse( unverifiedMigrationManifestSchema, unverifiedManifest, @@ -54,14 +72,17 @@ function parseAndAssertManifest( } ); if (!validation.success) { - throw new Error(validation.issues[0]?.message ?? invalidManifestFolderError); + throw invalidState(validation.issues[0]?.message ?? invalidManifestFolderError); } const manifestWithValidatedIds = validation.output.map((entry) => { + if (typeof entry.id !== "string" || entry.id.length > migrationIdMaximumLength) { + throw invalidState(invalidManifestFolderError); + } const idValidation = v.safeParse(manifestMigrationIdSchema, entry.id, { abortEarly: true, }); if (!idValidation.success) { - throw new Error(invalidManifestFolderError); + throw invalidState(invalidManifestFolderError); } return { ...entry, id: idValidation.output }; }); @@ -69,10 +90,10 @@ function parseAndAssertManifest( const sortedIds = ids.toSorted(); if (new Set(ids).size !== ids.length) { - throw new Error(invalidManifestFolderError); + throw invalidState(invalidManifestFolderError); } if (ids.some((id, index) => id !== sortedIds[index])) { - throw new Error("Migration manifest is not in runtime application order"); + throw invalidState("Migration manifest is not in runtime application order"); } return manifestWithValidatedIds.map((entry) => { @@ -87,7 +108,7 @@ function parseAndAssertManifest( { abortEarly: true } ); if (!migrationChecksum.success || !snapshotChecksum.success) { - throw new Error(invalidManifestChecksumError); + throw invalidState(invalidManifestChecksumError); } return { id: entry.id, @@ -97,6 +118,14 @@ function parseAndAssertManifest( }); } +function decodeMigrationSql(bytes: Buffer, migrationId: string): string { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw invalidState(`Migration SQL is not valid UTF-8: ${migrationId}`); + } +} + /** * Loads the Drizzle graph only after every tracked file matches the reviewed manifest. * @param options Migration directory and optional manifest override for isolated tests. @@ -106,47 +135,31 @@ export async function loadVerifiedMigrations( options: VerifyMigrationOptions ): Promise { const manifest = parseAndAssertManifest(options.manifest ?? migrationManifest); - - const directoryEntries = await readdir(options.directory, { withFileTypes: true }); - const directoryValidation = v.safeParse( - migrationDirectoryNamesSchema, - directoryEntries - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name), - { abortEarly: true } + const artifacts = await readStableMigrationArtifactGraph( + options.directory, + manifest.map((entry) => entry.id), + options.testHooks ); - if (!directoryValidation.success) { - throw new Error(migrationDirectoryMismatchError); - } - const migrationDirectories = directoryValidation.output.toSorted(); - const manifestIds = manifest.map((entry) => entry.id); - - if (migrationDirectories.join("\n") !== manifestIds.join("\n")) { - throw new Error(migrationDirectoryMismatchError); - } - const verifiedMigrations: VerifiedMigration[] = []; - for (const entry of manifest) { - const migrationDirectory = `${options.directory}/${entry.id}`; - const [migrationSql, snapshot] = await Promise.all([ - readFile(`${migrationDirectory}/migration.sql`), - readFile(`${migrationDirectory}/snapshot.json`), - ]); - - if (sha256Hex(migrationSql) !== entry.migrationSha256) { - throw new Error(`Migration SQL checksum mismatch: ${entry.id}`); + const verifiedMigrations = manifest.map((entry, index) => { + const artifact = artifacts[index]; + if (!artifact) { + throw invalidState( + "Migration directory does not match the reviewed manifest" + ); } - - if (sha256Hex(snapshot) !== entry.snapshotSha256) { - throw new Error(`Migration snapshot checksum mismatch: ${entry.id}`); + if (sha256Hex(artifact.migrationSql) !== entry.migrationSha256) { + throw invalidState(`Migration SQL checksum mismatch: ${entry.id}`); + } + if (sha256Hex(artifact.snapshot) !== entry.snapshotSha256) { + throw invalidState(`Migration snapshot checksum mismatch: ${entry.id}`); } - const statements = Object.freeze( - migrationSql.toString().split(drizzleStatementBreakpoint) + decodeMigrationSql(artifact.migrationSql, entry.id).split( + drizzleStatementBreakpoint + ) ); - const verifiedMigration = Object.freeze({ ...entry, statements }); - verifiedMigrations.push(verifiedMigration); - } - + return Object.freeze({ ...entry, statements }); + }); return Object.freeze(verifiedMigrations); } diff --git a/greenfield/src/server/database/migrations/manifest.ts b/greenfield/src/server/database/migrations/manifest.ts index 5447b5709..4b3291926 100644 --- a/greenfield/src/server/database/migrations/manifest.ts +++ b/greenfield/src/server/database/migrations/manifest.ts @@ -13,8 +13,8 @@ export const migrationManifest = Object.freeze { + const appliedAtValues = planMigrationAppliedAtValues(1000, 3, 997); + + expect(appliedAtValues).toEqual([998, 999, 1000]); + expect(Object.isFrozen(appliedAtValues)).toBeTrue(); +}); + +test("rejects clock rollback, future history, and a same-millisecond append", () => { + expect(() => planMigrationAppliedAtValues(999, 0, 1000)).toThrow( + "Database migration history is newer than the application clock" + ); + expect(() => planMigrationAppliedAtValues(999, 1, 1000)).toThrow( + "Database migration history is newer than the application clock" + ); + expect(() => planMigrationAppliedAtValues(1000, 1, 1000)).toThrow( + "Migration appliedAt must advance beyond stored migration history" + ); + expect(planMigrationAppliedAtValues(1000, 0, 1000)).toEqual([]); +}); + +test("keeps multiple pending timestamps out of the future and fails atomically at epoch", () => { + expect(planMigrationAppliedAtValues(maxTime, 2)).toEqual([maxTime - 1, maxTime]); + expect(() => planMigrationAppliedAtValues(0, 2)).toThrow( + "Migration appliedAt must be valid Date milliseconds" + ); +}); + test("rejects an invalid migration timestamp without changing the database", async () => { const migrations = await loadVerifiedMigrations({ directory: migrationsDirectory }); const database = new Database(":memory:", { strict: true }); @@ -32,3 +66,57 @@ test("rejects an invalid migration timestamp without changing the database", asy database.close(true); } }); + +test("rejects future stored history without changing an already-current database", async () => { + const migrations = await loadVerifiedMigrations({ directory: migrationsDirectory }); + const database = await openFreshMigratedDatabase(); + + try { + const before = database.sqlite + .query< + { appliedAt: number; checksum: string; id: string; releaseId: string }, + [] + >(` + SELECT + applied_at AS appliedAt, + checksum, + id, + release_id AS releaseId + FROM schema_migrations + `) + .all(); + const storedAppliedAt = before[0]?.appliedAt; + if (storedAppliedAt === undefined) { + throw new Error("Expected one applied migration"); + } + + expect(() => + applyVerifiedMigrations(database.sqlite, migrations, { + appliedAt: new Date(storedAppliedAt - 1), + releaseId: "1".repeat(40), + }) + ).toThrow("Database migration history is newer than the application clock"); + expect( + database.sqlite + .query< + { + appliedAt: number; + checksum: string; + id: string; + releaseId: string; + }, + [] + >(` + SELECT + applied_at AS appliedAt, + checksum, + id, + release_id AS releaseId + FROM schema_migrations + `) + .all() + ).toEqual(before); + } finally { + database.sqlite.close(true); + } +}); diff --git a/greenfield/src/server/database/migrations/migrationArtifactFilesystem.ts b/greenfield/src/server/database/migrations/migrationArtifactFilesystem.ts new file mode 100644 index 000000000..fe1774836 --- /dev/null +++ b/greenfield/src/server/database/migrations/migrationArtifactFilesystem.ts @@ -0,0 +1,477 @@ +import { constants, type BigIntStats } from "node:fs"; +import { type FileHandle, open, opendir, realpath } from "node:fs/promises"; +import path from "node:path"; + +const bytesPerMebibyte = 1024 * 1024; +const reviewedMigrationArtifactNames = Object.freeze([ + "migration.sql", + "snapshot.json", +] as const); + +/** Reviewable resource ceilings for the canonical migration artifact graph. */ +export const migrationArtifactByteLimits = Object.freeze({ + graph: 32 * bytesPerMebibyte, + migrationSql: bytesPerMebibyte, + snapshot: 4 * bytesPerMebibyte, +}); + +const migrationDirectoryMismatchError = + "Migration directory does not match the reviewed manifest"; +const migrationArtifactInventoryError = + "Migration node does not contain the exact reviewed artifacts"; +const migrationArtifactStateError = + "Migration artifact graph is not a stable regular-file graph"; +const migrationArtifactByteLimitError = + "Migration artifact graph exceeds the reviewed byte budget"; + +/** Stable read boundaries exposed only for deterministic adversarial tests. */ +export type MigrationArtifactVerificationTestStage = + | "migration-sql-initial-stat" + | "node-inventory" + | "root-inventory" + | "snapshot-initial-stat"; + +/** + * Deterministic test hook. Production composition must leave this absent. + * @internal + */ +export interface MigrationArtifactVerificationTestHooks { + readonly afterStage?: ( + stage: MigrationArtifactVerificationTestStage + ) => Promise | void; +} + +/** Stable artifact bytes aligned with the reviewed manifest order. */ +export interface StableMigrationArtifacts { + readonly migrationSql: Buffer; + readonly snapshot: Buffer; +} + +interface OpenedDirectory { + readonly canonicalPath: string; + readonly descriptorPath: string; + readonly handle: FileHandle; + readonly snapshot: BigIntStats; +} + +interface OpenedArtifact { + readonly directory: OpenedDirectory; + readonly filename: (typeof reviewedMigrationArtifactNames)[number]; + readonly handle: FileHandle; + readonly snapshot: BigIntStats; +} + +interface OpenedMigrationNode { + readonly artifacts: readonly [OpenedArtifact, OpenedArtifact]; + readonly directory: OpenedDirectory; + readonly id: string; +} + +interface DirectoryPathExpectation { + readonly directChild?: { + readonly name: string; + readonly parentCanonicalPath: string; + }; + readonly requestedPath: string; +} + +function invalidState(message: string): Error { + return new Error(message); +} + +function descriptorPath(handle: FileHandle): string { + return `/proc/self/fd/${handle.fd}`; +} + +function isDirectChild(parent: string, child: string, expectedName: string): boolean { + return path.dirname(child) === parent && path.basename(child) === expectedName; +} + +function matchesSnapshot(before: BigIntStats, after: BigIntStats): boolean { + return ( + after.dev === before.dev && + after.ino === before.ino && + after.mode === before.mode && + after.nlink === before.nlink && + after.uid === before.uid && + after.gid === before.gid && + after.size === before.size && + after.ctimeNs === before.ctimeNs && + after.mtimeNs === before.mtimeNs + ); +} + +async function readExactDirectoryInventory( + directory: OpenedDirectory, + expectedNames: readonly string[], + invalidMessage: string +): Promise { + let openedDirectory: Awaited> | undefined; + let names: string[] | undefined; + let failed = false; + try { + openedDirectory = await opendir(directory.descriptorPath); + const readNames: string[] = []; + while (true) { + const entry = await openedDirectory.read(); + if (!entry) break; + if (readNames.length >= expectedNames.length) { + throw invalidState(invalidMessage); + } + readNames.push(entry.name); + } + names = readNames.toSorted(); + } catch { + failed = true; + } + if (openedDirectory) { + try { + await openedDirectory.close(); + } catch { + failed = true; + } + } + if (failed || !names || names.join("\n") !== expectedNames.join("\n")) { + throw invalidState(invalidMessage); + } +} + +async function openRootDirectory( + requestedDirectory: string, + resources: FileHandle[] +): Promise { + if ( + typeof requestedDirectory !== "string" || + requestedDirectory.length === 0 || + requestedDirectory.includes("\0") + ) { + throw invalidState(migrationDirectoryMismatchError); + } + const absoluteDirectory = path.resolve(requestedDirectory); + try { + const handle = await open( + absoluteDirectory, + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK + ); + resources.push(handle); + const snapshot = await handle.stat({ bigint: true }); + if (!snapshot.isDirectory()) { + throw invalidState(migrationDirectoryMismatchError); + } + const heldDescriptorPath = descriptorPath(handle); + return { + canonicalPath: await realpath(heldDescriptorPath), + descriptorPath: heldDescriptorPath, + handle, + snapshot, + }; + } catch { + throw invalidState(migrationDirectoryMismatchError); + } +} + +async function openChildDirectory( + parent: OpenedDirectory, + childName: string, + resources: FileHandle[] +): Promise { + try { + const handle = await open( + path.join(parent.descriptorPath, childName), + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK + ); + resources.push(handle); + const snapshot = await handle.stat({ bigint: true }); + const heldDescriptorPath = descriptorPath(handle); + const canonicalPath = await realpath(heldDescriptorPath); + if ( + !snapshot.isDirectory() || + snapshot.dev !== parent.snapshot.dev || + !isDirectChild(parent.canonicalPath, canonicalPath, childName) + ) { + throw invalidState(migrationArtifactStateError); + } + return { + canonicalPath, + descriptorPath: heldDescriptorPath, + handle, + snapshot, + }; + } catch { + throw invalidState(migrationArtifactStateError); + } +} + +async function openArtifact( + directory: OpenedDirectory, + filename: OpenedArtifact["filename"], + byteLimit: number, + resources: FileHandle[] +): Promise { + try { + const handle = await open( + path.join(directory.descriptorPath, filename), + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + resources.push(handle); + const snapshot = await handle.stat({ bigint: true }); + const canonicalPath = await realpath(descriptorPath(handle)); + if ( + !snapshot.isFile() || + snapshot.nlink !== 1n || + snapshot.dev !== directory.snapshot.dev || + snapshot.size <= 0n || + snapshot.size > BigInt(byteLimit) || + !isDirectChild(directory.canonicalPath, canonicalPath, filename) + ) { + throw invalidState( + snapshot.size > BigInt(byteLimit) + ? migrationArtifactByteLimitError + : migrationArtifactStateError + ); + } + return { directory, filename, handle, snapshot }; + } catch (error) { + if (error instanceof Error && error.message === migrationArtifactByteLimitError) { + throw error; + } + throw invalidState(migrationArtifactStateError); + } +} + +async function revalidateArtifactPath(artifact: OpenedArtifact): Promise { + let pathHandle: FileHandle | undefined; + let failed = false; + try { + pathHandle = await open( + path.join(artifact.directory.descriptorPath, artifact.filename), + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + const pathSnapshot = await pathHandle.stat({ bigint: true }); + const canonicalPath = await realpath(descriptorPath(pathHandle)); + if ( + !pathSnapshot.isFile() || + pathSnapshot.nlink !== 1n || + !matchesSnapshot(artifact.snapshot, pathSnapshot) || + !isDirectChild( + artifact.directory.canonicalPath, + canonicalPath, + artifact.filename + ) + ) { + failed = true; + } + } catch { + failed = true; + } + if (pathHandle) { + try { + await pathHandle.close(); + } catch { + failed = true; + } + } + if (failed) throw invalidState(migrationArtifactStateError); +} + +async function readStableArtifact(artifact: OpenedArtifact): Promise { + const expectedBytes = Number(artifact.snapshot.size); + const bytes = Buffer.alloc(expectedBytes + 1); + let bytesRead = 0; + try { + while (bytesRead < bytes.byteLength) { + const read = await artifact.handle.read( + bytes, + bytesRead, + bytes.byteLength - bytesRead, + bytesRead + ); + if (read.bytesRead === 0) break; + bytesRead += read.bytesRead; + } + const afterRead = await artifact.handle.stat({ bigint: true }); + if ( + bytesRead !== expectedBytes || + !matchesSnapshot(artifact.snapshot, afterRead) + ) { + throw invalidState(migrationArtifactStateError); + } + await revalidateArtifactPath(artifact); + return bytes.subarray(0, bytesRead); + } catch { + throw invalidState(migrationArtifactStateError); + } +} + +async function revalidateDirectory( + directory: OpenedDirectory, + expectedInventory: readonly string[], + pathExpectation: DirectoryPathExpectation, + invalidMessage: string +): Promise { + try { + await readExactDirectoryInventory(directory, expectedInventory, invalidMessage); + const afterRead = await directory.handle.stat({ bigint: true }); + if (!matchesSnapshot(directory.snapshot, afterRead)) { + throw invalidState(invalidMessage); + } + + const pathHandle = await open( + pathExpectation.requestedPath, + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK + ); + let pathFailed = false; + try { + const pathSnapshot = await pathHandle.stat({ bigint: true }); + const canonicalPath = await realpath(descriptorPath(pathHandle)); + if ( + !pathSnapshot.isDirectory() || + !matchesSnapshot(directory.snapshot, pathSnapshot) || + (pathExpectation.directChild && + !isDirectChild( + pathExpectation.directChild.parentCanonicalPath, + canonicalPath, + pathExpectation.directChild.name + )) + ) { + pathFailed = true; + } + } catch { + pathFailed = true; + } + try { + await pathHandle.close(); + } catch { + pathFailed = true; + } + if (pathFailed) throw invalidState(invalidMessage); + } catch { + throw invalidState(invalidMessage); + } +} + +async function closeResources(resources: readonly FileHandle[]): Promise { + let failed = false; + for (const handle of resources.toReversed()) { + try { + await handle.close(); + } catch { + failed = true; + } + } + return !failed; +} + +/** + * Reads one exact, stable artifact pair for every reviewed migration id. + * @param requestedDirectory Canonical migration graph root selected by composition. + * @param manifestIds Ordered reviewed migration ids. + * @param testHooks Deterministic mutation boundaries used only by security tests. + * @returns Stable artifact bytes aligned with manifest order. + */ +export async function readStableMigrationArtifactGraph( + requestedDirectory: string, + manifestIds: readonly string[], + testHooks?: MigrationArtifactVerificationTestHooks +): Promise { + const resources: FileHandle[] = []; + let result: readonly StableMigrationArtifacts[] | undefined; + let failure: unknown; + + try { + const root = await openRootDirectory(requestedDirectory, resources); + const requestedRoot = path.resolve(requestedDirectory); + await readExactDirectoryInventory( + root, + manifestIds, + migrationDirectoryMismatchError + ); + await testHooks?.afterStage?.("root-inventory"); + + const openedNodes: OpenedMigrationNode[] = []; + let graphBytes = 0n; + for (const id of manifestIds) { + const directory = await openChildDirectory(root, id, resources); + await readExactDirectoryInventory( + directory, + reviewedMigrationArtifactNames, + migrationArtifactInventoryError + ); + await testHooks?.afterStage?.("node-inventory"); + + const migrationSql = await openArtifact( + directory, + "migration.sql", + migrationArtifactByteLimits.migrationSql, + resources + ); + await testHooks?.afterStage?.("migration-sql-initial-stat"); + const snapshot = await openArtifact( + directory, + "snapshot.json", + migrationArtifactByteLimits.snapshot, + resources + ); + await testHooks?.afterStage?.("snapshot-initial-stat"); + graphBytes += migrationSql.snapshot.size + snapshot.snapshot.size; + if (graphBytes > BigInt(migrationArtifactByteLimits.graph)) { + throw invalidState(migrationArtifactByteLimitError); + } + openedNodes.push({ + artifacts: [migrationSql, snapshot], + directory, + id, + }); + } + + const artifacts: StableMigrationArtifacts[] = []; + for (const node of openedNodes) { + const [migrationSql, snapshot] = node.artifacts; + artifacts.push({ + migrationSql: await readStableArtifact(migrationSql), + snapshot: await readStableArtifact(snapshot), + }); + } + for (const node of openedNodes) { + await revalidateDirectory( + node.directory, + reviewedMigrationArtifactNames, + { + directChild: { + name: node.id, + parentCanonicalPath: root.canonicalPath, + }, + requestedPath: path.join(root.descriptorPath, node.id), + }, + migrationArtifactStateError + ); + } + await revalidateDirectory( + root, + manifestIds, + { requestedPath: requestedRoot }, + migrationDirectoryMismatchError + ); + result = Object.freeze(artifacts); + } catch (error) { + failure = error; + } + + const closed = await closeResources(resources); + if (failure !== undefined) { + throw failure instanceof Error + ? failure + : invalidState(migrationArtifactStateError); + } + if (!closed || !result) throw invalidState(migrationArtifactStateError); + return result; +} diff --git a/greenfield/src/server/database/migrations/migrationGraph.test.ts b/greenfield/src/server/database/migrations/migrationGraph.test.ts index 6b2c7a716..693456a6d 100644 --- a/greenfield/src/server/database/migrations/migrationGraph.test.ts +++ b/greenfield/src/server/database/migrations/migrationGraph.test.ts @@ -74,6 +74,9 @@ describe("database migration graph", () => { "incidents_validate_details_update", "incident_observations_validate_details_insert", "incident_observations_validate_details_update", + "schema_migrations_reject_replace", + "schema_migrations_reject_update", + "schema_migrations_reject_delete", ]) { expect(foundationSql).toContain(`CREATE TRIGGER ${trigger}`); } @@ -157,6 +160,7 @@ describe("database migration graph", () => { const database = await openFreshMigratedDatabase(); try { + database.sqlite.run("DROP TRIGGER schema_migrations_reject_update"); database.sqlite.run("UPDATE schema_migrations SET checksum = ?", [ "f".repeat(64), ]); @@ -229,6 +233,7 @@ describe("database migration graph", () => { const database = await openFreshMigratedDatabase(); try { + database.sqlite.run("DROP TRIGGER schema_migrations_reject_delete"); database.sqlite.run("DELETE FROM schema_migrations"); expect(() => diff --git a/greenfield/src/server/database/migrations/migrationLedgerValidation.test.ts b/greenfield/src/server/database/migrations/migrationLedgerValidation.test.ts index fe5e51e18..10548f6a4 100644 --- a/greenfield/src/server/database/migrations/migrationLedgerValidation.test.ts +++ b/greenfield/src/server/database/migrations/migrationLedgerValidation.test.ts @@ -7,7 +7,108 @@ import { import { applyVerifiedMigrations } from "./applyVerifiedMigrations.ts"; import { loadVerifiedMigrations } from "./loadVerifiedMigrations.ts"; -test("validates every raw field in the durable migration ledger", async () => { +const insertMigration = ` + INSERT INTO schema_migrations ( + applied_at, + checksum, + id, + release_id + ) VALUES (?, ?, ?, ?) +`; + +test("enforces every migration ledger field at the storage boundary", async () => { + const invalidRows = [ + { + expectedConstraint: "schema_migrations_applied_at_check", + values: [-1, "a".repeat(64), "20260805000000_direct", "1".repeat(40)], + }, + { + expectedConstraint: "schema_migrations_applied_at_check", + values: [ + 8_640_000_000_000_001, + "a".repeat(64), + "20260805000000_direct", + "1".repeat(40), + ], + }, + { + expectedConstraint: "schema_migrations_checksum_check", + values: [1, "A".repeat(64), "20260805000000_direct", "1".repeat(40)], + }, + { + expectedConstraint: "schema_migrations_checksum_check", + values: [1, "a".repeat(63), "20260805000000_direct", "1".repeat(40)], + }, + { + expectedConstraint: "schema_migrations_id_check", + values: [1, "a".repeat(64), "20260805000000_Invalid", "1".repeat(40)], + }, + { + expectedConstraint: "schema_migrations_id_check", + values: [1, "a".repeat(64), "20260805000000_direct/child", "1".repeat(40)], + }, + { + expectedConstraint: "schema_migrations_id_check", + values: [ + 1, + "a".repeat(64), + `20260805000000_${"a".repeat(114)}`, + "1".repeat(40), + ], + }, + { + expectedConstraint: "schema_migrations_release_id_check", + values: [1, "a".repeat(64), "20260805000000_direct", "A".repeat(40)], + }, + { + expectedConstraint: "schema_migrations_release_id_check", + values: [1, "a".repeat(64), "20260805000000_direct", "1".repeat(39)], + }, + ] as const; + + for (const invalidRow of invalidRows) { + const database = await openFreshMigratedDatabase(); + try { + expect(() => + database.sqlite.run(insertMigration, [...invalidRow.values]) + ).toThrow(`CHECK constraint failed: ${invalidRow.expectedConstraint}`); + } finally { + database.sqlite.close(true); + } + } +}); + +test("rejects updates, deletes, and replacements in the append-only ledger", async () => { + const database = await openFreshMigratedDatabase(); + + try { + const migration = database.sqlite + .query<{ id: string }, []>("SELECT id FROM schema_migrations") + .get(); + if (!migration) throw new Error("Expected one applied migration"); + + for (const statement of [ + "UPDATE schema_migrations SET release_id = release_id WHERE id = ?", + "DELETE FROM schema_migrations WHERE id = ?", + `INSERT OR REPLACE INTO schema_migrations ( + applied_at, + checksum, + id, + release_id + ) SELECT applied_at, checksum, id, release_id + FROM schema_migrations + WHERE id = ?`, + ]) { + expect(() => database.sqlite.run(statement, [migration.id])).toThrow( + "schema_migrations is append-only" + ); + } + } finally { + database.sqlite.close(true); + } +}); + +test("validates every raw field in a tampered durable migration ledger", async () => { const migrations = await loadVerifiedMigrations({ directory: migrationsDirectory }); const foundationMigration = migrations[0]; if (foundationMigration === undefined) { @@ -22,7 +123,11 @@ test("validates every raw field in the durable migration ledger", async () => { for (const corruption of corruptions) { const database = await openFreshMigratedDatabase(); try { + database.sqlite.run("DROP TRIGGER schema_migrations_reject_update"); + database.sqlite.run("PRAGMA ignore_check_constraints = ON"); database.sqlite.run(corruption, [foundationMigration.id]); + database.sqlite.run("PRAGMA ignore_check_constraints = OFF"); + expect(() => applyVerifiedMigrations(database.sqlite, migrations, { releaseId: "1".repeat(40), diff --git a/greenfield/src/server/database/migrations/validation.ts b/greenfield/src/server/database/migrations/validation.ts index eb1cc144b..e74dbfed6 100644 --- a/greenfield/src/server/database/migrations/validation.ts +++ b/greenfield/src/server/database/migrations/validation.ts @@ -1,6 +1,7 @@ import * as v from "valibot"; const migrationIdPattern = /^\d{14}_[a-z\d][a-z\d_-]*$/u; +export const migrationIdMaximumLength = 128; /** * Builds the canonical tracked migration-folder identifier schema. @@ -8,7 +9,11 @@ const migrationIdPattern = /^\d{14}_[a-z\d][a-z\d_-]*$/u; * @returns Valibot schema for a canonical migration identifier. */ export function migrationIdSchema(message: string) { - return v.pipe(v.string(message), v.regex(migrationIdPattern, message)); + return v.pipe( + v.string(message), + v.maxLength(migrationIdMaximumLength, message), + v.regex(migrationIdPattern, message) + ); } /** diff --git a/greenfield/src/server/database/runtime/databaseErrors.ts b/greenfield/src/server/database/runtime/databaseErrors.ts new file mode 100644 index 000000000..a8fc9b228 --- /dev/null +++ b/greenfield/src/server/database/runtime/databaseErrors.ts @@ -0,0 +1,93 @@ +import { Schema } from "effect"; + +export type DatabaseRuntimePathFailureReason = + | "database-file-invalid" + | "state-directory-invalid"; + +export type DatabaseRuntimeStartupFailureReason = + | "artifact-invalid" + | "database-empty" + | "database-history-invalid" + | "database-open-failed" + | "database-policy-invalid" + | "database-startup-failed" + | "options-invalid"; + +/** Expected failure when the retained database path violates its private-file policy. */ +export class DatabaseRuntimePathError extends Schema.TaggedErrorClass( + "mira-dashboard/server/database/runtime/DatabaseRuntimePathError" +)("DatabaseRuntimePathError", { + message: Schema.String, + reason: Schema.Literals([ + "database-file-invalid", + "state-directory-invalid", + ] satisfies readonly DatabaseRuntimePathFailureReason[]), +}) {} + +/** Expected, redacted startup failure that is safe to cross the runtime boundary. */ +export class DatabaseRuntimeStartupError extends Schema.TaggedErrorClass( + "mira-dashboard/server/database/runtime/DatabaseRuntimeStartupError" +)("DatabaseRuntimeStartupError", { + message: Schema.String, + reason: Schema.Literals([ + "artifact-invalid", + "database-empty", + "database-history-invalid", + "database-open-failed", + "database-policy-invalid", + "database-startup-failed", + "options-invalid", + ] satisfies readonly DatabaseRuntimeStartupFailureReason[]), +}) {} + +/** Expected startup failure while another process owns SQLite migration admission. */ +export class DatabaseRuntimeLockTimeoutError extends Schema.TaggedErrorClass( + "mira-dashboard/server/database/runtime/DatabaseRuntimeLockTimeoutError" +)("DatabaseRuntimeLockTimeoutError", { + message: Schema.String, + timeoutMs: Schema.Number, +}) {} + +/** Fail-closed signal that a published database needs a verified release snapshot. */ +export class DatabaseRuntimeSnapshotRequiredError extends Schema.TaggedErrorClass( + "mira-dashboard/server/database/runtime/DatabaseRuntimeSnapshotRequiredError" +)("DatabaseRuntimeSnapshotRequiredError", { + message: Schema.String, +}) {} + +/** Sanitized failure from a process-owned passive WAL checkpoint. */ +export class DatabaseRuntimeCheckpointError extends Schema.TaggedErrorClass( + "mira-dashboard/server/database/runtime/DatabaseRuntimeCheckpointError" +)("DatabaseRuntimeCheckpointError", { + message: Schema.String, +}) {} + +/** Sanitized failure while closing the process-owned native SQLite handle. */ +export class DatabaseRuntimeCloseError extends Schema.TaggedErrorClass( + "mira-dashboard/server/database/runtime/DatabaseRuntimeCloseError" +)("DatabaseRuntimeCloseError", { + message: Schema.String, +}) {} + +export type DatabaseRuntimeAcquisitionError = + | DatabaseRuntimeLockTimeoutError + | DatabaseRuntimePathError + | DatabaseRuntimeSnapshotRequiredError + | DatabaseRuntimeStartupError; + +const databaseRuntimeErrorSchema = Schema.Union([ + DatabaseRuntimeCheckpointError, + DatabaseRuntimeCloseError, + DatabaseRuntimeLockTimeoutError, + DatabaseRuntimePathError, + DatabaseRuntimeSnapshotRequiredError, + DatabaseRuntimeStartupError, +]); + +/** Runtime guard for failures crossing the database layer boundary. */ +export const isDatabaseRuntimeError = Schema.is(databaseRuntimeErrorSchema); + +export type DatabaseRuntimeError = + | DatabaseRuntimeAcquisitionError + | DatabaseRuntimeCheckpointError + | DatabaseRuntimeCloseError; diff --git a/greenfield/src/server/database/runtime/databasePath.test.ts b/greenfield/src/server/database/runtime/databasePath.test.ts new file mode 100644 index 000000000..129fb24d2 --- /dev/null +++ b/greenfield/src/server/database/runtime/databasePath.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, link, mkdtemp, rename, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { DatabaseRuntimePathError } from "./databaseErrors.ts"; +import { + assertDatabasePathStillValid, + dashboardDatabaseFileName, + prepareDatabasePath, +} from "./databasePath.ts"; + +const temporaryDirectories: string[] = []; + +async function privateTemporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), "dashboard-db-path-")); + temporaryDirectories.push(directory); + await chmod(directory, 0o700); + return directory; +} + +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + return new Error("Expected promise rejection"); + } catch (error) { + return error; + } +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe("database runtime path policy", () => { + test("creates the fixed private database file and revalidates its identity", async () => { + const directory = await privateTemporaryDirectory(); + const prepared = await prepareDatabasePath(directory, true); + + expect(prepared?.existed).toBe(false); + expect(prepared?.filePath).toBe(path.join(directory, dashboardDatabaseFileName)); + await assertDatabasePathStillValid(prepared!); + + const reopened = await prepareDatabasePath(directory, true); + expect(reopened?.existed).toBe(true); + expect(reopened?.identity).toEqual(prepared?.identity); + }); + + test("does not create a missing database for validate-only startup", async () => { + const directory = await privateTemporaryDirectory(); + expect(await prepareDatabasePath(directory, false)).toBeUndefined(); + }); + + test("rejects noncanonical, permissive, and symlinked state directories", async () => { + const directory = await privateTemporaryDirectory(); + expect( + await rejectionOf(prepareDatabasePath(`${directory}/.`, true)) + ).toBeInstanceOf(DatabaseRuntimePathError); + + await chmod(directory, 0o750); + expect(await rejectionOf(prepareDatabasePath(directory, true))).toBeInstanceOf( + DatabaseRuntimePathError + ); + + await chmod(directory, 0o700); + const linkPath = `${directory}-link`; + temporaryDirectories.push(linkPath); + await symlink(directory, linkPath, "dir"); + expect(await rejectionOf(prepareDatabasePath(linkPath, true))).toBeInstanceOf( + DatabaseRuntimePathError + ); + }); + + test("rejects symlinked, multiply linked, and permissive database files", async () => { + const symlinkDirectory = await privateTemporaryDirectory(); + const symlinkTarget = path.join(symlinkDirectory, "target.db"); + await writeFile(symlinkTarget, "", { mode: 0o600 }); + await symlink( + symlinkTarget, + path.join(symlinkDirectory, dashboardDatabaseFileName) + ); + expect( + await rejectionOf(prepareDatabasePath(symlinkDirectory, true)) + ).toBeInstanceOf(DatabaseRuntimePathError); + + const hardlinkDirectory = await privateTemporaryDirectory(); + const databasePath = path.join(hardlinkDirectory, dashboardDatabaseFileName); + await writeFile(databasePath, "", { mode: 0o600 }); + await link(databasePath, path.join(hardlinkDirectory, "second-link.db")); + expect( + await rejectionOf(prepareDatabasePath(hardlinkDirectory, true)) + ).toBeInstanceOf(DatabaseRuntimePathError); + + const modeDirectory = await privateTemporaryDirectory(); + const modePath = path.join(modeDirectory, dashboardDatabaseFileName); + await writeFile(modePath, "", { mode: 0o644 }); + await chmod(modePath, 0o644); + expect( + await rejectionOf(prepareDatabasePath(modeDirectory, true)) + ).toBeInstanceOf(DatabaseRuntimePathError); + }); + + test("rejects unsafe SQLite journal, shared-memory, and WAL sidecars", async () => { + for (const suffix of ["-journal", "-shm", "-wal"] as const) { + const directory = await privateTemporaryDirectory(); + const target = path.join(directory, `target${suffix}`); + await writeFile(target, "", { mode: 0o600 }); + await symlink( + target, + path.join(directory, `${dashboardDatabaseFileName}${suffix}`) + ); + + expect( + await rejectionOf(prepareDatabasePath(directory, true)) + ).toBeInstanceOf(DatabaseRuntimePathError); + } + }); + + test("detects database path replacement after preparation", async () => { + const directory = await privateTemporaryDirectory(); + const prepared = await prepareDatabasePath(directory, true); + if (!prepared) throw new Error("Expected a prepared database path"); + const replacement = path.join(directory, "replacement.db"); + await writeFile(replacement, "", { mode: 0o600 }); + await rename(replacement, prepared.filePath); + + expect(await rejectionOf(assertDatabasePathStillValid(prepared))).toBeInstanceOf( + DatabaseRuntimePathError + ); + }); + + test("detects an unsafe sidecar introduced after preparation", async () => { + const directory = await privateTemporaryDirectory(); + const prepared = await prepareDatabasePath(directory, true); + if (!prepared) throw new Error("Expected a prepared database path"); + const target = path.join(directory, "sidecar-target"); + await writeFile(target, "", { mode: 0o600 }); + await symlink(target, `${prepared.filePath}-wal`); + + expect(await rejectionOf(assertDatabasePathStillValid(prepared))).toBeInstanceOf( + DatabaseRuntimePathError + ); + }); + + test("detects state-directory policy drift after preparation", async () => { + const directory = await privateTemporaryDirectory(); + const prepared = await prepareDatabasePath(directory, true); + if (!prepared) throw new Error("Expected a prepared database path"); + await chmod(directory, 0o750); + + const failure = await rejectionOf(assertDatabasePathStillValid(prepared)); + expect(failure).toBeInstanceOf(DatabaseRuntimePathError); + expect(failure).toMatchObject({ reason: "state-directory-invalid" }); + }); +}); diff --git a/greenfield/src/server/database/runtime/databasePath.ts b/greenfield/src/server/database/runtime/databasePath.ts new file mode 100644 index 000000000..aa5ff9ee1 --- /dev/null +++ b/greenfield/src/server/database/runtime/databasePath.ts @@ -0,0 +1,250 @@ +import { constants, type BigIntStats } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import path from "node:path"; + +import { DatabaseRuntimePathError } from "./databaseErrors.ts"; + +export const dashboardDatabaseFileName = "mira-dashboard.db"; +const dashboardDatabaseSidecarSuffixes = Object.freeze([ + "-journal", + "-shm", + "-wal", +] as const); + +export interface PreparedDatabasePath { + readonly directoryIdentity: DatabasePathIdentity; + readonly existed: boolean; + readonly filePath: string; + readonly identity: DatabasePathIdentity; +} + +export interface DatabasePathIdentity { + readonly device: bigint; + readonly inode: bigint; +} + +function invalidStateDirectory(): DatabaseRuntimePathError { + return new DatabaseRuntimePathError({ + message: "Database state directory violates the private runtime policy", + reason: "state-directory-invalid", + }); +} + +function invalidDatabaseFile(): DatabaseRuntimePathError { + return new DatabaseRuntimePathError({ + message: "Database file violates the private runtime policy", + reason: "database-file-invalid", + }); +} + +function currentUserId(): number { + if (typeof process.getuid !== "function") throw invalidStateDirectory(); + return process.getuid(); +} + +function isMissingPathFailure(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + try { + const descriptor = Object.getOwnPropertyDescriptor(error, "code"); + return descriptor !== undefined && "value" in descriptor + ? descriptor.value === "ENOENT" + : false; + } catch { + return false; + } +} + +function matchesPrivateDirectoryPolicy(stat: BigIntStats, userId: number): boolean { + return ( + stat.isDirectory() && + !stat.isSymbolicLink() && + stat.uid === BigInt(userId) && + (stat.mode & 0o777n) === 0o700n + ); +} + +function matchesPrivateDatabaseFilePolicy(stat: BigIntStats, userId: number): boolean { + return ( + stat.isFile() && + !stat.isSymbolicLink() && + stat.nlink === 1n && + stat.uid === BigInt(userId) && + (stat.mode & 0o777n) === 0o600n + ); +} + +function identityOf(stat: BigIntStats): DatabasePathIdentity { + return Object.freeze({ device: stat.dev, inode: stat.ino }); +} + +function sameIdentity( + actual: DatabasePathIdentity, + expected: DatabasePathIdentity +): boolean { + return actual.device === expected.device && actual.inode === expected.inode; +} + +async function assertCanonicalPrivateStateDirectory(stateDirectory: string): Promise<{ + directory: string; + identity: DatabasePathIdentity; + userId: number; +}> { + if ( + !path.isAbsolute(stateDirectory) || + stateDirectory.includes("\0") || + path.resolve(stateDirectory) !== stateDirectory + ) { + throw invalidStateDirectory(); + } + + try { + const [canonicalDirectory, stat] = await Promise.all([ + realpath(stateDirectory), + lstat(stateDirectory, { bigint: true }), + ]); + const userId = currentUserId(); + if ( + canonicalDirectory !== stateDirectory || + !matchesPrivateDirectoryPolicy(stat, userId) + ) { + throw invalidStateDirectory(); + } + return { + directory: canonicalDirectory, + identity: identityOf(stat), + userId, + }; + } catch (error) { + if (error instanceof DatabaseRuntimePathError) throw error; + throw invalidStateDirectory(); + } +} + +async function privateDatabaseFileStat( + filePath: string, + userId: number +): Promise { + try { + const stat = await lstat(filePath, { bigint: true }); + if (!matchesPrivateDatabaseFilePolicy(stat, userId)) { + throw invalidDatabaseFile(); + } + return stat; + } catch (error) { + if (error instanceof DatabaseRuntimePathError) throw error; + if (isMissingPathFailure(error)) return undefined; + throw invalidDatabaseFile(); + } +} + +async function assertPrivateDatabaseSidecars( + filePath: string, + userId: number +): Promise { + for (const suffix of dashboardDatabaseSidecarSuffixes) { + await privateDatabaseFileStat(`${filePath}${suffix}`, userId); + } +} + +async function createPrivateDatabaseFile(filePath: string): Promise { + let file: Awaited> | undefined; + let created: boolean | undefined; + let failed = false; + try { + file = await open( + filePath, + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW | + constants.O_WRONLY, + 0o600 + ); + created = true; + } catch (error) { + if (isMissingPathFailure(error)) { + failed = true; + } + if ( + typeof error === "object" && + error !== null && + Object.getOwnPropertyDescriptor(error, "code")?.value === "EEXIST" + ) { + created = false; + } else { + failed = true; + } + } + if (file) { + try { + await file.close(); + } catch { + failed = true; + } + } + if (failed || created === undefined) throw invalidDatabaseFile(); + return created; +} + +/** + * Resolves one fixed database filename beneath a canonical, private state directory. + * New files are created with no-follow/exclusive semantics before SQLite opens them. + * @param stateDirectory Canonical private directory owned by the current process user. + * @param createIfMissing Whether an absent fixed database file may be created. + * @returns Validated file identity, or undefined for absent validate-only state. + */ +export async function prepareDatabasePath( + stateDirectory: string, + createIfMissing: boolean +): Promise { + const { + directory, + identity: directoryIdentity, + userId, + } = await assertCanonicalPrivateStateDirectory(stateDirectory); + const filePath = path.join(directory, dashboardDatabaseFileName); + await assertPrivateDatabaseSidecars(filePath, userId); + let stat = await privateDatabaseFileStat(filePath, userId); + let existed = stat !== undefined; + + if (!stat && !createIfMissing) return undefined; + if (!stat) { + const created = await createPrivateDatabaseFile(filePath); + existed = !created; + stat = await privateDatabaseFileStat(filePath, userId); + } + if (!stat) throw invalidDatabaseFile(); + + return Object.freeze({ + directoryIdentity, + existed, + filePath, + identity: identityOf(stat), + }); +} + +/** Revalidates the requested file identity after the native SQLite path open. */ +export async function assertDatabasePathStillValid( + prepared: PreparedDatabasePath +): Promise { + try { + const stateDirectory = await assertCanonicalPrivateStateDirectory( + path.dirname(prepared.filePath) + ); + if ( + path.join(stateDirectory.directory, dashboardDatabaseFileName) !== + prepared.filePath || + !sameIdentity(stateDirectory.identity, prepared.directoryIdentity) + ) { + throw invalidStateDirectory(); + } + const userId = stateDirectory.userId; + const stat = await privateDatabaseFileStat(prepared.filePath, userId); + if (!stat || !sameIdentity(identityOf(stat), prepared.identity)) { + throw invalidDatabaseFile(); + } + await assertPrivateDatabaseSidecars(prepared.filePath, userId); + } catch (error) { + if (error instanceof DatabaseRuntimePathError) throw error; + throw invalidDatabaseFile(); + } +} diff --git a/greenfield/src/server/database/runtime/databasePolicy.ts b/greenfield/src/server/database/runtime/databasePolicy.ts new file mode 100644 index 000000000..e98e1807a --- /dev/null +++ b/greenfield/src/server/database/runtime/databasePolicy.ts @@ -0,0 +1,303 @@ +import type { Database } from "bun:sqlite"; + +import { Data, Duration, Effect, Predicate, Schedule } from "effect"; +import * as v from "valibot"; + +import { + DatabaseRuntimeCheckpointError, + DatabaseRuntimeLockTimeoutError, + DatabaseRuntimePathError, + DatabaseRuntimeSnapshotRequiredError, + DatabaseRuntimeStartupError, +} from "./databaseErrors.ts"; + +export const databaseRuntimePolicy = Object.freeze({ + busyTimeoutMs: 0, + migrationLockRetryBaseDelayMs: 10, + migrationLockRetryMaximumDelayMs: 250, + migrationLockTimeoutMs: 5000, + synchronousLevel: 2, + walAutoCheckpointPages: 1000, +}); + +export interface DatabaseConnectionDiagnostics { + readonly busyTimeoutMs: number; + readonly checksEnforced: true; + readonly foreignKeysEnabled: true; + readonly journalMode: "wal"; + readonly synchronousLevel: 2; + readonly trustedSchemaEnabled: false; + readonly walAutoCheckpointPages: number; +} + +export interface DatabaseCheckpointDiagnostics { + readonly busy: number; + readonly checkpointedFrames: number; + readonly logFrames: number; +} + +class DatabaseRuntimeBusyError extends Data.TaggedError("DatabaseRuntimeBusyError")<{ + readonly message: string; +}> {} + +const integerSchema = v.pipe(v.number(), v.safeInteger()); +const nonnegativeIntegerSchema = v.pipe(integerSchema, v.minValue(0)); +const foreignKeysRowSchema = v.strictObject({ foreign_keys: nonnegativeIntegerSchema }); +const ignoredChecksRowSchema = v.strictObject({ + ignore_check_constraints: nonnegativeIntegerSchema, +}); +const journalModeRowSchema = v.strictObject({ journal_mode: v.string() }); +const synchronousRowSchema = v.strictObject({ synchronous: nonnegativeIntegerSchema }); +const busyTimeoutRowSchema = v.strictObject({ timeout: nonnegativeIntegerSchema }); +const walAutoCheckpointRowSchema = v.strictObject({ + wal_autocheckpoint: nonnegativeIntegerSchema, +}); +const trustedSchemaRowSchema = v.strictObject({ + trusted_schema: nonnegativeIntegerSchema, +}); +const checkpointRowSchema = v.strictObject({ + busy: v.pipe(nonnegativeIntegerSchema, v.maxValue(1)), + checkpointed: integerSchema, + log: integerSchema, +}); + +function ownDataProperty(value: object, property: string): unknown { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, property); + return descriptor !== undefined && "value" in descriptor + ? descriptor.value + : undefined; + } catch { + return undefined; + } +} + +function sqliteErrorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null) return undefined; + const code = ownDataProperty(error, "code"); + return typeof code === "string" && code.startsWith("SQLITE_") ? code : undefined; +} + +function isBusyOrLockedCode(code: string | undefined): boolean { + return ( + code === "SQLITE_BUSY" || + code?.startsWith("SQLITE_BUSY_") === true || + code === "SQLITE_LOCKED" || + code?.startsWith("SQLITE_LOCKED_") === true + ); +} + +function invalidDatabasePolicy(): DatabaseRuntimeStartupError { + return new DatabaseRuntimeStartupError({ + message: "Database connection policy validation failed", + reason: "database-policy-invalid", + }); +} + +function parsePolicyRow< + TSchema extends v.BaseSchema>, +>(schema: TSchema, row: unknown): v.InferOutput { + const result = v.safeParse(schema, row, { abortEarly: true }); + if (!result.success) throw invalidDatabasePolicy(); + return result.output; +} + +function readConnectionDiagnostics(database: Database): DatabaseConnectionDiagnostics { + const foreignKeys = parsePolicyRow( + foreignKeysRowSchema, + database.query("PRAGMA foreign_keys").get() + ); + const ignoredChecks = parsePolicyRow( + ignoredChecksRowSchema, + database.query("PRAGMA ignore_check_constraints").get() + ); + const journalMode = parsePolicyRow( + journalModeRowSchema, + database.query("PRAGMA journal_mode").get() + ); + const synchronous = parsePolicyRow( + synchronousRowSchema, + database.query("PRAGMA synchronous").get() + ); + const busyTimeout = parsePolicyRow( + busyTimeoutRowSchema, + database.query("PRAGMA busy_timeout").get() + ); + const walAutoCheckpoint = parsePolicyRow( + walAutoCheckpointRowSchema, + database.query("PRAGMA wal_autocheckpoint").get() + ); + const trustedSchema = parsePolicyRow( + trustedSchemaRowSchema, + database.query("PRAGMA trusted_schema").get() + ); + + if ( + foreignKeys.foreign_keys !== 1 || + ignoredChecks.ignore_check_constraints !== 0 || + journalMode.journal_mode.toLowerCase() !== "wal" || + synchronous.synchronous !== databaseRuntimePolicy.synchronousLevel || + busyTimeout.timeout !== databaseRuntimePolicy.busyTimeoutMs || + walAutoCheckpoint.wal_autocheckpoint !== + databaseRuntimePolicy.walAutoCheckpointPages || + trustedSchema.trusted_schema !== 0 + ) { + throw invalidDatabasePolicy(); + } + + return Object.freeze({ + busyTimeoutMs: busyTimeout.timeout, + checksEnforced: true, + foreignKeysEnabled: true, + journalMode: "wal", + synchronousLevel: 2, + trustedSchemaEnabled: false, + walAutoCheckpointPages: walAutoCheckpoint.wal_autocheckpoint, + }); +} + +/** + * Applies and then verifies the fixed, security-first production connection policy. + * @param database Retained process-owned native connection. + * @returns Immutable verified connection diagnostics. + */ +export function configureDatabaseConnection( + database: Database +): DatabaseConnectionDiagnostics { + database.run("PRAGMA busy_timeout = 0"); + database.run("PRAGMA foreign_keys = ON"); + database.run("PRAGMA ignore_check_constraints = OFF"); + database.run("PRAGMA trusted_schema = OFF"); + database.run("PRAGMA journal_mode = WAL"); + database.run("PRAGMA synchronous = FULL"); + database.run("PRAGMA wal_autocheckpoint = 1000"); + return readConnectionDiagnostics(database); +} + +const isBusyError = Predicate.isTagged("DatabaseRuntimeBusyError"); + +function busyRetrySchedule(): Schedule.Schedule { + const baseDelay = Duration.millis( + databaseRuntimePolicy.migrationLockRetryBaseDelayMs + ); + return Schedule.exponential(baseDelay).pipe( + Schedule.modifyDelay(({ duration }) => { + const delayMs = Math.min( + Duration.toMillis(duration), + databaseRuntimePolicy.migrationLockRetryMaximumDelayMs + ); + return Effect.succeed(Duration.millis(delayMs)); + }), + Schedule.while(({ input }) => isBusyError(input)) + ); +} + +function classifyStartupFailure( + error: unknown +): DatabaseRuntimeBusyError | DatabaseRuntimeStartupError { + if (error instanceof DatabaseRuntimeStartupError) return error; + if (isBusyOrLockedCode(sqliteErrorCode(error))) { + return new DatabaseRuntimeBusyError({ + message: "Database startup is waiting for migration admission", + }); + } + return new DatabaseRuntimeStartupError({ + message: "Database startup validation failed", + reason: "database-startup-failed", + }); +} + +/** + * Runs synchronous startup work with Effect-owned retry, cancellation and deadline. + * A synchronous SQLite transaction is never interrupted mid-callback. + * @param operation One synchronous and idempotent database startup attempt. + * @returns The operation result or a sanitized operational failure. + */ +export function retryDatabaseStartupOperation( + operation: () => A +): Effect.Effect< + A, + | DatabaseRuntimeLockTimeoutError + | DatabaseRuntimePathError + | DatabaseRuntimeSnapshotRequiredError + | DatabaseRuntimeStartupError +> { + const attempt: Effect.Effect< + A, + | DatabaseRuntimeBusyError + | DatabaseRuntimePathError + | DatabaseRuntimeSnapshotRequiredError + | DatabaseRuntimeStartupError + > = Effect.suspend< + A, + | DatabaseRuntimeBusyError + | DatabaseRuntimePathError + | DatabaseRuntimeSnapshotRequiredError + | DatabaseRuntimeStartupError, + never + >(() => { + try { + return Effect.succeed(operation()); + } catch (error) { + if ( + error instanceof DatabaseRuntimePathError || + error instanceof DatabaseRuntimeSnapshotRequiredError + ) { + return Effect.fail(error); + } + return Effect.fail(classifyStartupFailure(error)); + } + }); + + return attempt.pipe( + Effect.retry({ schedule: busyRetrySchedule() }), + Effect.timeoutOrElse({ + duration: databaseRuntimePolicy.migrationLockTimeoutMs, + orElse: () => + Effect.fail( + new DatabaseRuntimeLockTimeoutError({ + message: "Database migration admission timed out", + timeoutMs: databaseRuntimePolicy.migrationLockTimeoutMs, + }) + ), + }), + Effect.catchTag("DatabaseRuntimeBusyError", () => + Effect.fail( + new DatabaseRuntimeLockTimeoutError({ + message: "Database migration admission timed out", + timeoutMs: databaseRuntimePolicy.migrationLockTimeoutMs, + }) + ) + ) + ); +} + +/** + * Runs one non-blocking passive checkpoint and validates its bounded diagnostics. + * @param database Retained process-owned native connection. + * @returns Validated WAL checkpoint counters. + */ +export function checkpointDatabasePassive( + database: Database +): Effect.Effect { + return Effect.try({ + catch: () => + new DatabaseRuntimeCheckpointError({ + message: "Database passive checkpoint failed", + }), + try: () => { + const row = parsePolicyRow( + checkpointRowSchema, + database.query("PRAGMA wal_checkpoint(PASSIVE)").get() + ); + if (row.log < 0 || row.checkpointed < 0 || row.checkpointed > row.log) { + throw new Error("Invalid checkpoint diagnostics"); + } + return Object.freeze({ + busy: row.busy, + checkpointedFrames: row.checkpointed, + logFrames: row.log, + }); + }, + }); +} diff --git a/greenfield/src/server/database/runtime/databaseService.test.ts b/greenfield/src/server/database/runtime/databaseService.test.ts new file mode 100644 index 000000000..2308b6d4b --- /dev/null +++ b/greenfield/src/server/database/runtime/databaseService.test.ts @@ -0,0 +1,300 @@ +import { Database } from "bun:sqlite"; +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { Effect, Fiber, ManagedRuntime } from "effect"; +import { TestClock } from "effect/testing"; + +import { + DatabaseRuntimeLockTimeoutError, + DatabaseRuntimeStartupError, +} from "./databaseErrors.ts"; +import { + databaseRuntimePolicy, + retryDatabaseStartupOperation, +} from "./databasePolicy.ts"; +import { + DatabaseRuntimeService, + databaseRuntimeLayer, + type DatabaseRuntimeLayerOptions, +} from "./databaseService.ts"; + +const migrationsDirectory = path.resolve(import.meta.dir, "../../../../migrations"); +const releaseId = "0".repeat(40); +const temporaryDirectories: string[] = []; +const runtimes: Array<{ dispose(): Promise }> = []; + +async function privateTemporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), "dashboard-db-runtime-")); + temporaryDirectories.push(directory); + await chmod(directory, 0o700); + return directory; +} + +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + return new Error("Expected promise rejection"); + } catch (error) { + return error; + } +} + +function options( + stateDirectory: string, + startupMode: DatabaseRuntimeLayerOptions["startupMode"] = "initialize-empty" +): DatabaseRuntimeLayerOptions { + return { + migrationsDirectory, + releaseId, + startupMode, + stateDirectory, + }; +} + +async function buildRuntime(runtimeOptions: DatabaseRuntimeLayerOptions) { + const runtime = ManagedRuntime.make(databaseRuntimeLayer(runtimeOptions)); + runtimes.push(runtime); + await runtime.context(); + const service = await runtime.runPromise(DatabaseRuntimeService); + return { runtime, service }; +} + +afterEach(async () => { + await Promise.allSettled(runtimes.splice(0).map((runtime) => runtime.dispose())); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe("database runtime service", () => { + test("initializes a fresh strict WAL database through one native Drizzle handle", async () => { + const stateDirectory = await privateTemporaryDirectory(); + const { service } = await buildRuntime(options(stateDirectory)); + + expect(service.diagnostics).toEqual({ + appliedMigrations: 1, + connection: { + busyTimeoutMs: 0, + checksEnforced: true, + foreignKeysEnabled: true, + journalMode: "wal", + synchronousLevel: 2, + trustedSchemaEnabled: false, + walAutoCheckpointPages: 1000, + }, + databaseFileName: "mira-dashboard.db", + migrationCount: 1, + startupMode: "initialize-empty", + }); + expect(service.orm.$client).toBeInstanceOf(Database); + expect(service.orm.$client.filename).toBe( + path.join(stateDirectory, "mira-dashboard.db") + ); + const databaseStat = await stat(service.orm.$client.filename); + expect(databaseStat.mode & 0o777).toBe(0o600); + expect(service.orm.$client.query("PRAGMA foreign_keys").get()).toEqual({ + foreign_keys: 1, + }); + expect( + service.orm.$client.query("PRAGMA ignore_check_constraints").get() + ).toEqual({ ignore_check_constraints: 0 }); + expect(service.orm.$client.query("PRAGMA journal_mode").get()).toEqual({ + journal_mode: "wal", + }); + expect(service.orm.$client.query("PRAGMA synchronous").get()).toEqual({ + synchronous: 2, + }); + expect(service.orm.$client.query("PRAGMA trusted_schema").get()).toEqual({ + trusted_schema: 0, + }); + }); + + test("validates an already-current database without applying migrations", async () => { + const stateDirectory = await privateTemporaryDirectory(); + const first = await buildRuntime(options(stateDirectory)); + await first.runtime.dispose(); + + const second = await buildRuntime(options(stateDirectory, "validate-only")); + expect(second.service.diagnostics.appliedMigrations).toBe(0); + expect(second.service.diagnostics.startupMode).toBe("validate-only"); + expect( + second.service.orm.$client + .query<{ count: number }, []>( + "SELECT COUNT(*) AS count FROM schema_migrations" + ) + .get() + ).toEqual({ count: 1 }); + }); + + test("serializes two concurrent empty-database startups through SQLite admission", async () => { + const stateDirectory = await privateTemporaryDirectory(); + const [first, second] = await Promise.all([ + buildRuntime(options(stateDirectory)), + buildRuntime(options(stateDirectory)), + ]); + + expect( + [ + first.service.diagnostics.appliedMigrations, + second.service.diagnostics.appliedMigrations, + ].toSorted((left, right) => left - right) + ).toEqual([0, 1]); + expect( + first.service.orm.$client + .query<{ count: number }, []>( + "SELECT COUNT(*) AS count FROM schema_migrations" + ) + .get() + ).toEqual({ count: 1 }); + expect(first.service.orm.$client.filename).toBe( + second.service.orm.$client.filename + ); + }); + + test("validate-only rejects an absent database without creating it", async () => { + const stateDirectory = await privateTemporaryDirectory(); + const runtime = ManagedRuntime.make( + databaseRuntimeLayer(options(stateDirectory, "validate-only")) + ); + runtimes.push(runtime); + + expect(await rejectionOf(runtime.context())).toMatchObject({ + _tag: "DatabaseRuntimeStartupError", + reason: "database-empty", + }); + const databasePath = path.join(stateDirectory, "mira-dashboard.db"); + const statFailure = await rejectionOf(stat(databasePath)); + expect(statFailure).toMatchObject({ code: "ENOENT" }); + }); + + test("rejects artifact and schema tampering with redacted tagged failures", async () => { + const artifactDirectory = await privateTemporaryDirectory(); + const invalidArtifactRuntime = ManagedRuntime.make( + databaseRuntimeLayer({ + ...options(artifactDirectory), + migrationsDirectory: path.join(artifactDirectory, "missing"), + }) + ); + runtimes.push(invalidArtifactRuntime); + expect(await rejectionOf(invalidArtifactRuntime.context())).toMatchObject({ + _tag: "DatabaseRuntimeStartupError", + reason: "artifact-invalid", + }); + + const stateDirectory = await privateTemporaryDirectory(); + const initialized = await buildRuntime(options(stateDirectory)); + const databasePath = initialized.service.orm.$client.filename; + await initialized.runtime.dispose(); + const tamper = new Database(databasePath, { strict: true }); + tamper.run("CREATE TABLE unreviewed_runtime_table (id INTEGER PRIMARY KEY)"); + tamper.close(true); + + const validation = ManagedRuntime.make( + databaseRuntimeLayer(options(stateDirectory, "validate-only")) + ); + runtimes.push(validation); + expect(await rejectionOf(validation.context())).toBeInstanceOf( + DatabaseRuntimeStartupError + ); + }); + + test("preserves a startup failure before checkpoint policy is established", async () => { + const stateDirectory = await privateTemporaryDirectory(); + await writeFile( + path.join(stateDirectory, "mira-dashboard.db"), + "not a SQLite database", + { mode: 0o600 } + ); + const runtime = ManagedRuntime.make( + databaseRuntimeLayer(options(stateDirectory, "validate-only")) + ); + runtimes.push(runtime); + + const failure = await rejectionOf(runtime.context()); + expect(failure).toBeInstanceOf(DatabaseRuntimeStartupError); + expect(failure).toMatchObject({ reason: "database-startup-failed" }); + }); + + test("runs a passive checkpoint while a second WAL connection remains open", async () => { + const stateDirectory = await privateTemporaryDirectory(); + const { runtime, service } = await buildRuntime(options(stateDirectory)); + const second = new Database(service.orm.$client.filename, { strict: true }); + second.run("PRAGMA journal_mode = WAL"); + second.run("CREATE TABLE checkpoint_probe (id INTEGER PRIMARY KEY)"); + second.run("INSERT INTO checkpoint_probe (id) VALUES (1)"); + + const diagnostics = await runtime.runPromise(service.checkpointPassive); + expect([0, 1]).toContain(diagnostics.busy); + expect(diagnostics.logFrames).toBeGreaterThanOrEqual(1); + expect(diagnostics.checkpointedFrames).toBeGreaterThanOrEqual(0); + expect(second.query("SELECT id FROM checkpoint_probe").all()).toEqual([ + { id: 1 }, + ]); + second.close(true); + }); + + test("checkpoints before strict close and makes the retained handle unusable", async () => { + const stateDirectory = await privateTemporaryDirectory(); + const { runtime, service } = await buildRuntime(options(stateDirectory)); + const retainedHandle = service.orm.$client; + retainedHandle.run("CREATE TABLE finalizer_probe (id INTEGER PRIMARY KEY)"); + retainedHandle.run("INSERT INTO finalizer_probe (id) VALUES (1)"); + + await runtime.dispose(); + expect(() => retainedHandle.query("SELECT 1").get()).toThrow(); + + const verification = new Database(retainedHandle.filename, { + readonly: true, + strict: true, + }); + expect(verification.query("PRAGMA integrity_check").get()).toEqual({ + integrity_check: "ok", + }); + verification.close(true); + }); +}); + +describe("database startup retry policy", () => { + test("times out persistent SQLITE_BUSY using the Effect clock", async () => { + let attempts = 0; + const program = Effect.gen(function* () { + yield* TestClock.setTime(0); + const fiber = yield* retryDatabaseStartupOperation(() => { + attempts += 1; + throw Object.assign(new Error("not exposed"), { code: "SQLITE_BUSY" }); + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust(databaseRuntimePolicy.migrationLockTimeoutMs); + return yield* Fiber.join(fiber).pipe(Effect.flip); + }).pipe(Effect.provide(TestClock.layer())); + + const failure = await Effect.runPromise(program); + expect(failure).toBeInstanceOf(DatabaseRuntimeLockTimeoutError); + expect(attempts).toBeGreaterThan(1); + }); + + test("interrupts queued retry sleep without running further attempts", async () => { + let attempts = 0; + const program = Effect.gen(function* () { + yield* TestClock.setTime(0); + const fiber = yield* retryDatabaseStartupOperation(() => { + attempts += 1; + throw Object.assign(new Error("not exposed"), { code: "SQLITE_LOCKED" }); + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + const attemptsBeforeInterruption = attempts; + yield* Fiber.interrupt(fiber); + yield* TestClock.adjust(databaseRuntimePolicy.migrationLockTimeoutMs * 2); + return attemptsBeforeInterruption; + }).pipe(Effect.provide(TestClock.layer())); + + const attemptsBeforeInterruption = await Effect.runPromise(program); + expect(attemptsBeforeInterruption).toBeGreaterThanOrEqual(1); + expect(attempts).toBe(attemptsBeforeInterruption); + }); +}); diff --git a/greenfield/src/server/database/runtime/databaseService.ts b/greenfield/src/server/database/runtime/databaseService.ts new file mode 100644 index 000000000..08a7d07cd --- /dev/null +++ b/greenfield/src/server/database/runtime/databaseService.ts @@ -0,0 +1,226 @@ +import { Database } from "bun:sqlite"; + +import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; +import { Context, Effect, Layer, Result } from "effect"; + +import { + type DatabaseRuntimeAcquisitionError, + DatabaseRuntimeCheckpointError, + DatabaseRuntimeCloseError, + DatabaseRuntimePathError, + DatabaseRuntimeStartupError, +} from "./databaseErrors.ts"; +import { + assertDatabasePathStillValid, + dashboardDatabaseFileName, + prepareDatabasePath, + type PreparedDatabasePath, +} from "./databasePath.ts"; +import { + checkpointDatabasePassive, + type DatabaseCheckpointDiagnostics, +} from "./databasePolicy.ts"; +import { + initializeDatabaseRuntime, + loadDatabaseRuntimeMigrations, + normalizeDatabaseRuntimeOptions, + type DatabaseRuntimeLayerOptions, + type DatabaseStartupDiagnostics, + type NormalizedDatabaseRuntimeOptions, +} from "./databaseStartup.ts"; + +export * from "./databaseErrors.ts"; +export { + databaseRuntimePolicy, + type DatabaseCheckpointDiagnostics, + type DatabaseConnectionDiagnostics, +} from "./databasePolicy.ts"; +export type { + DatabaseRuntimeLayerOptions, + DatabaseRuntimeStartupMode, +} from "./databaseStartup.ts"; + +export type RuntimeOwnedDatabase = SQLiteBunDatabase & { readonly $client: Database }; + +export interface DatabaseRuntimeDiagnostics extends DatabaseStartupDiagnostics { + readonly databaseFileName: typeof dashboardDatabaseFileName; +} + +interface DatabaseRuntimeServiceShape { + readonly checkpointPassive: Effect.Effect< + DatabaseCheckpointDiagnostics, + DatabaseRuntimeCheckpointError + >; + readonly diagnostics: DatabaseRuntimeDiagnostics; + readonly orm: RuntimeOwnedDatabase; +} + +/** Process-scoped, migration-verified SQLite and Drizzle ownership boundary. */ +export class DatabaseRuntimeService extends Context.Service< + DatabaseRuntimeService, + DatabaseRuntimeServiceShape +>()("mira-dashboard/server/database/runtime/DatabaseRuntimeService") {} + +function emptyDatabaseFailure(): DatabaseRuntimeStartupError { + return new DatabaseRuntimeStartupError({ + message: "Database validation requires an initialized database", + reason: "database-empty", + }); +} + +function prepareRuntimeDatabasePath( + options: NormalizedDatabaseRuntimeOptions +): Effect.Effect< + PreparedDatabasePath, + DatabaseRuntimePathError | DatabaseRuntimeStartupError +> { + return Effect.tryPromise({ + catch: (error) => + error instanceof DatabaseRuntimePathError + ? error + : new DatabaseRuntimePathError({ + message: "Database path validation failed", + reason: "database-file-invalid", + }), + try: () => + prepareDatabasePath( + options.stateDirectory, + options.startupMode === "initialize-empty" + ), + }).pipe( + Effect.flatMap((prepared) => + prepared === undefined + ? Effect.fail(emptyDatabaseFailure()) + : Effect.succeed(prepared) + ) + ); +} + +function openRuntimeDatabase( + prepared: PreparedDatabasePath +): Effect.Effect { + return Effect.try({ + catch: () => + new DatabaseRuntimeStartupError({ + message: "Database could not be opened", + reason: "database-open-failed", + }), + try: () => { + const database = new Database(prepared.filePath, { + create: false, + readwrite: true, + strict: true, + }); + if (database.filename !== prepared.filePath) { + database.close(true); + throw new Error("Database filename changed during open"); + } + return database; + }, + }); +} + +function verifyOpenDatabasePath( + prepared: PreparedDatabasePath +): Effect.Effect { + return Effect.tryPromise({ + catch: (error) => + error instanceof DatabaseRuntimePathError + ? error + : new DatabaseRuntimePathError({ + message: "Database file identity validation failed", + reason: "database-file-invalid", + }), + try: () => assertDatabasePathStillValid(prepared), + }); +} + +function closeRuntimeDatabase( + database: Database +): Effect.Effect { + return Effect.try({ + catch: () => + new DatabaseRuntimeCloseError({ + message: "Database native handle failed to close", + }), + try: () => database.close(true), + }); +} + +function releaseRuntimeDatabase( + database: Database, + checkpointBeforeClose: boolean +): Effect.Effect { + if (!checkpointBeforeClose) { + // Preserve the sanitized acquisition failure. A strict-close double fault + // cannot be recovered in-process and must not replace its initiating cause. + return closeRuntimeDatabase(database).pipe(Effect.ignore); + } + + return Effect.gen(function* () { + const checkpointResult = yield* Effect.result( + checkpointDatabasePassive(database) + ); + const closeResult = yield* Effect.result(closeRuntimeDatabase(database)); + + if (Result.isFailure(closeResult)) { + return yield* Effect.die(closeResult.failure); + } + if (Result.isFailure(checkpointResult)) { + return yield* Effect.die(checkpointResult.failure); + } + }); +} + +function acquireDatabaseRuntime(unverifiedOptions: DatabaseRuntimeLayerOptions) { + return Effect.gen(function* () { + const options = yield* Effect.try({ + catch: (error) => + error instanceof DatabaseRuntimeStartupError + ? error + : new DatabaseRuntimeStartupError({ + message: "Database runtime options are invalid", + reason: "options-invalid", + }), + try: () => normalizeDatabaseRuntimeOptions(unverifiedOptions), + }); + const migrations = yield* loadDatabaseRuntimeMigrations( + options.migrationsDirectory + ); + const prepared = yield* prepareRuntimeDatabasePath(options); + let checkpointOnRelease = false; + const database = yield* Effect.acquireRelease( + openRuntimeDatabase(prepared), + (openedDatabase) => + releaseRuntimeDatabase(openedDatabase, checkpointOnRelease) + ); + yield* verifyOpenDatabasePath(prepared); + const startup = yield* initializeDatabaseRuntime(database, migrations, options); + yield* verifyOpenDatabasePath(prepared); + + const orm = drizzle({ client: database }); + const diagnostics: DatabaseRuntimeDiagnostics = Object.freeze({ + ...startup, + databaseFileName: dashboardDatabaseFileName, + }); + const service = Object.freeze({ + checkpointPassive: checkpointDatabasePassive(database), + diagnostics, + orm, + }); + checkpointOnRelease = true; + return service; + }); +} + +/** + * Creates one memoized database layer for the composition root's existing ManagedRuntime. + * Layer scope owns passive checkpoint and strict native-handle closure. + * @param options Explicit state, migration, release, and startup-mode inputs. + * @returns Scoped database service layer for the process runtime. + */ +export function databaseRuntimeLayer( + options: DatabaseRuntimeLayerOptions +): Layer.Layer { + return Layer.effect(DatabaseRuntimeService, acquireDatabaseRuntime(options)); +} diff --git a/greenfield/src/server/database/runtime/databaseStartup.ts b/greenfield/src/server/database/runtime/databaseStartup.ts new file mode 100644 index 000000000..9676cfb7d --- /dev/null +++ b/greenfield/src/server/database/runtime/databaseStartup.ts @@ -0,0 +1,255 @@ +import type { Database } from "bun:sqlite"; + +import { Effect } from "effect"; +import * as v from "valibot"; + +import { + fullCommitShaSchema, + lowercaseSha256Schema, +} from "../../../shared/validation.ts"; +import { applyVerifiedMigrations } from "../migrations/applyVerifiedMigrations.ts"; +import { + loadVerifiedMigrations, + type VerifiedMigration, +} from "../migrations/loadVerifiedMigrations.ts"; +import { migrationIdSchema } from "../migrations/validation.ts"; +import { assertDatabaseIntegrity } from "../migrations/verifyDatabaseIntegrity.ts"; +import { + DatabaseRuntimeSnapshotRequiredError, + DatabaseRuntimeStartupError, +} from "./databaseErrors.ts"; +import { + configureDatabaseConnection, + retryDatabaseStartupOperation, + type DatabaseConnectionDiagnostics, +} from "./databasePolicy.ts"; + +export type DatabaseRuntimeStartupMode = "initialize-empty" | "validate-only"; + +export interface DatabaseRuntimeLayerOptions { + readonly migrationsDirectory: string; + readonly releaseId: string; + readonly startupMode: DatabaseRuntimeStartupMode; + readonly stateDirectory: string; +} + +export type NormalizedDatabaseRuntimeOptions = Readonly; + +export interface DatabaseStartupDiagnostics { + readonly appliedMigrations: number; + readonly connection: DatabaseConnectionDiagnostics; + readonly migrationCount: number; + readonly startupMode: DatabaseRuntimeStartupMode; +} + +const startupModeSchema = v.picklist( + ["initialize-empty", "validate-only"] as const, + "Database startup mode is invalid" +); +const absolutePathSchema = v.pipe( + v.string("Database runtime path must be a string"), + v.maxLength(4096, "Database runtime path is too long"), + v.check( + (value) => value.startsWith("/") && !value.includes("\0"), + "Database runtime path must be absolute and NUL-free" + ) +); +const runtimeOptionsSchema = v.pipe( + v.strictObject({ + migrationsDirectory: absolutePathSchema, + releaseId: fullCommitShaSchema("Database release identity is invalid"), + startupMode: startupModeSchema, + stateDirectory: absolutePathSchema, + }), + v.readonly() +); +const migrationHistoryRowSchema = v.strictObject({ + checksum: lowercaseSha256Schema("Database migration history is invalid"), + id: migrationIdSchema("Database migration history is invalid"), +}); +const migrationHistoryRowsSchema = v.array(migrationHistoryRowSchema); +const schemaObjectRowSchema = v.strictObject({ name: v.string(), type: v.string() }); +const schemaObjectRowsSchema = v.array(schemaObjectRowSchema); + +type MigrationHistoryRow = v.InferOutput; + +function invalidOptions(): DatabaseRuntimeStartupError { + return new DatabaseRuntimeStartupError({ + message: "Database runtime options are invalid", + reason: "options-invalid", + }); +} + +function invalidHistory(): DatabaseRuntimeStartupError { + return new DatabaseRuntimeStartupError({ + message: "Database history does not match the reviewed migration graph", + reason: "database-history-invalid", + }); +} + +function emptyDatabase(): DatabaseRuntimeStartupError { + return new DatabaseRuntimeStartupError({ + message: "Database validation requires an initialized database", + reason: "database-empty", + }); +} + +/** + * Validates external composition inputs before any filesystem mutation. + * @param options Untrusted runtime composition options. + * @returns Immutable validated runtime options. + */ +export function normalizeDatabaseRuntimeOptions( + options: DatabaseRuntimeLayerOptions +): NormalizedDatabaseRuntimeOptions { + const validation = v.safeParse(runtimeOptionsSchema, options, { abortEarly: true }); + if (!validation.success) throw invalidOptions(); + return validation.output; +} + +/** + * Loads the reviewed migration graph before the database write boundary is opened. + * @param migrationsDirectory Canonical release-owned migration directory. + * @returns Checksum-verified migration artifacts. + */ +export function loadDatabaseRuntimeMigrations( + migrationsDirectory: string +): Effect.Effect { + return Effect.tryPromise({ + catch: () => + new DatabaseRuntimeStartupError({ + message: "Database migration artifacts failed verification", + reason: "artifact-invalid", + }), + try: () => loadVerifiedMigrations({ directory: migrationsDirectory }), + }); +} + +function readSchemaObjects( + database: Database +): readonly { name: string; type: string }[] { + const rows: unknown = database + .query(` + SELECT name, type + FROM sqlite_schema + WHERE name NOT GLOB 'sqlite_*' + ORDER BY type, name + `) + .all(); + const validation = v.safeParse(schemaObjectRowsSchema, rows, { abortEarly: true }); + if (!validation.success) throw invalidHistory(); + return validation.output; +} + +function readMigrationHistory(database: Database): readonly MigrationHistoryRow[] { + const rows: unknown = database + .query(` + SELECT checksum, id + FROM schema_migrations + ORDER BY id + `) + .all(); + const validation = v.safeParse(migrationHistoryRowsSchema, rows, { + abortEarly: true, + }); + if (!validation.success) throw invalidHistory(); + return validation.output; +} + +function isReviewedPrefix( + history: readonly MigrationHistoryRow[], + migrations: readonly VerifiedMigration[] +): boolean { + return history.every((applied, index) => { + const expected = migrations[index]; + return ( + expected !== undefined && + applied.id === expected.id && + applied.checksum === expected.migrationSha256 + ); + }); +} + +function inspectMigrationState( + database: Database, + migrations: readonly VerifiedMigration[] +): "current" | "empty" | "pending" { + const schemaObjects = readSchemaObjects(database); + if (schemaObjects.length === 0) return "empty"; + if ( + !schemaObjects.some( + (object) => object.type === "table" && object.name === "schema_migrations" + ) + ) { + throw invalidHistory(); + } + + const history = readMigrationHistory(database); + if ( + history.length === 0 || + history.length > migrations.length || + !isReviewedPrefix(history, migrations) + ) { + throw invalidHistory(); + } + return history.length === migrations.length ? "current" : "pending"; +} + +function startOrValidateDatabase( + database: Database, + migrations: readonly VerifiedMigration[], + options: NormalizedDatabaseRuntimeOptions +): DatabaseStartupDiagnostics { + const connection = configureDatabaseConnection(database); + let state = inspectMigrationState(database, migrations); + + if (state === "empty" && options.startupMode === "validate-only") { + const recheck = database.transaction(() => + inspectMigrationState(database, migrations) + ); + state = recheck.immediate(); + if (state === "empty") throw emptyDatabase(); + } + if (state === "pending") { + assertDatabaseIntegrity(database); + throw new DatabaseRuntimeSnapshotRequiredError({ + message: "Database migration requires a verified release snapshot", + }); + } + + const appliedMigrations = applyVerifiedMigrations(database, migrations, { + releaseId: options.releaseId, + }); + if (state === "current" && appliedMigrations !== 0) throw invalidHistory(); + if ( + state === "empty" && + appliedMigrations !== 0 && + appliedMigrations !== migrations.length + ) { + throw invalidHistory(); + } + + return Object.freeze({ + appliedMigrations, + connection, + migrationCount: migrations.length, + startupMode: options.startupMode, + }); +} + +/** + * Initializes an empty file or validates an already-current reviewed database. + * @param database Retained process-owned native connection. + * @param migrations Checksum-verified canonical migration graph. + * @param options Validated startup policy and release identity. + * @returns Immutable startup and connection diagnostics. + */ +export function initializeDatabaseRuntime( + database: Database, + migrations: readonly VerifiedMigration[], + options: NormalizedDatabaseRuntimeOptions +) { + return retryDatabaseStartupOperation(() => + startOrValidateDatabase(database, migrations, options) + ); +} diff --git a/greenfield/src/server/database/schema/schemaMigrations.ts b/greenfield/src/server/database/schema/schemaMigrations.ts index 77d080fe3..e98bef99e 100644 --- a/greenfield/src/server/database/schema/schemaMigrations.ts +++ b/greenfield/src/server/database/schema/schemaMigrations.ts @@ -1,9 +1,33 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; +import { check, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -/** Immutable migration history verified by Dashboard's future migration runner. */ -export const schemaMigrations = sqliteTable("schema_migrations", { - appliedAt: integer("applied_at", { mode: "timestamp_ms" }).notNull(), - checksum: text("checksum").notNull(), - id: text("id").notNull().primaryKey(), - releaseId: text("release_id").notNull(), -}); +import { lowercaseHexTextCheck, timestampMillisecondsCheck } from "./checks.ts"; + +/** Immutable migration history verified by Dashboard's migration runner. */ +export const schemaMigrations = sqliteTable( + "schema_migrations", + { + appliedAt: integer("applied_at", { mode: "timestamp_ms" }).notNull(), + checksum: text("checksum").notNull(), + id: text("id").notNull().primaryKey(), + releaseId: text("release_id").notNull(), + }, + (table) => [ + check( + "schema_migrations_applied_at_check", + timestampMillisecondsCheck(table.appliedAt) + ), + check( + "schema_migrations_checksum_check", + lowercaseHexTextCheck(table.checksum, 64) + ), + check( + "schema_migrations_id_check", + sql`length(${table.id}) BETWEEN 16 AND 128 AND instr(${table.id}, char(0)) = 0 AND substr(${table.id}, 1, 14) NOT GLOB '*[^0-9]*' AND substr(${table.id}, 15, 1) = '_' AND substr(${table.id}, 16, 1) GLOB '[a-z0-9]' AND substr(${table.id}, 16) NOT GLOB '*[^a-z0-9_-]*'` + ), + check( + "schema_migrations_release_id_check", + sql`length(${table.releaseId}) = 40 AND instr(${table.releaseId}, char(0)) = 0 AND ${table.releaseId} NOT GLOB '*[^0-9a-f]*'` + ), + ] +); diff --git a/greenfield/src/server/database/validation/rowSchemas.test.ts b/greenfield/src/server/database/validation/rowSchemas.test.ts index ed9edb3ec..1f87ba8d9 100644 --- a/greenfield/src/server/database/validation/rowSchemas.test.ts +++ b/greenfield/src/server/database/validation/rowSchemas.test.ts @@ -320,5 +320,13 @@ describe("Drizzle-generated Valibot row schemas", () => { releaseId: "b".repeat(40), }) ).toThrow(); + expect(() => + v.parse(schemaMigrationInsertSchema, { + appliedAt: observedAt, + checksum: "a".repeat(64), + id: `20260804022252_${"a".repeat(114)}`, + releaseId: "b".repeat(40), + }) + ).toThrow(); }); }); diff --git a/greenfield/src/server/database/validation/schemaMigrations.ts b/greenfield/src/server/database/validation/schemaMigrations.ts index a437f2451..18ddf0c55 100644 --- a/greenfield/src/server/database/validation/schemaMigrations.ts +++ b/greenfield/src/server/database/validation/schemaMigrations.ts @@ -6,7 +6,7 @@ import { fullCommitShaAction, lowercaseSha256Action, } from "../../../shared/validation.ts"; -import { migrationIdAction } from "../migrations/validation.ts"; +import { migrationIdAction, migrationIdMaximumLength } from "../migrations/validation.ts"; import { schemaMigrations } from "../schema/schemaMigrations.ts"; const migrationRefinements = { @@ -18,7 +18,11 @@ const migrationRefinements = { checksum: (schema: v.StringSchema) => v.pipe(schema, lowercaseSha256Action()), id: (schema: v.StringSchema) => - v.pipe(schema, migrationIdAction("Expected a canonical migration id.")), + v.pipe( + schema, + v.maxLength(migrationIdMaximumLength, "Expected a canonical migration id."), + migrationIdAction("Expected a canonical migration id.") + ), releaseId: (schema: v.StringSchema) => v.pipe(schema, fullCommitShaAction()), }; diff --git a/greenfield/src/server/domains/security/testSupport/authentication.ts b/greenfield/src/server/domains/security/testSupport/authentication.ts index f3beb5f73..79ce452a1 100644 --- a/greenfield/src/server/domains/security/testSupport/authentication.ts +++ b/greenfield/src/server/domains/security/testSupport/authentication.ts @@ -1,4 +1,5 @@ import { addDays, parseISO } from "date-fns"; +import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; import * as v from "valibot"; import { authSessions } from "../../../database/schema/authSessions.ts"; @@ -30,6 +31,93 @@ export const testTotpSecretCipher: TotpSecretCipher = Object.freeze({ hasKey: () => false, }); +/** + * Inserts the canonical persisted security identities into an already-migrated test ORM. + * @param database Migrated test database owned by the calling fixture or runtime. + * @param now Timestamp used for all persisted authentication records. + * @returns Generated session and automation tokens plus their shared expiry. + */ +export function seedAuthenticationTestDatabase( + database: SQLiteBunDatabase, + now = authenticationTestNow +) { + const session = generateOpaqueToken("session"); + const automation = generateOpaqueToken("automation"); + const expiresAt = addDays(now, 30); + + database + .insert(users) + .values( + v.parse(userInsertSchema, { + createdAt: now, + disabledAt: null, + id: authenticationTestUserId, + passwordHash: testDashboardPasswordHash, + updatedAt: now, + username: "raymond", + }) + ) + .run(); + database + .insert(authSessions) + .values( + v.parse(authSessionInsertSchema, { + authenticatedAt: now, + authenticationVersion: 1, + authMethod: "password", + createdAt: now, + expiresAt, + id: session.prefix, + lastSeenAt: now, + mfaVerifiedAt: null, + passwordVerifiedAt: now, + userAgent: null, + userId: authenticationTestUserId, + validatorHash: session.validatorHash, + }) + ) + .run(); + database + .insert(automationPrincipals) + .values( + v.parse(automationPrincipalInsertSchema, { + createdAt: now, + disabledAt: null, + id: authenticationTestPrincipalId, + label: "OpenClaw task tracking", + updatedAt: now, + }) + ) + .run(); + database + .insert(automationPrincipalCapabilities) + .values( + v.parse(automationPrincipalCapabilityInsertSchema, { + capability: "reports:read", + grantedAt: now, + principalId: authenticationTestPrincipalId, + }) + ) + .run(); + database + .insert(automationCredentials) + .values( + v.parse(automationCredentialInsertSchema, { + createdAt: now, + expiresAt, + id: authenticationTestCredentialId, + label: "Primary credential", + prefix: automation.prefix, + principalId: authenticationTestPrincipalId, + revokedAt: null, + validatorHash: automation.validatorHash, + }) + ) + .run(); + + return Object.freeze({ automation, expiresAt, session }); +} + /** * Opens a fresh database containing one session and one automation credential. * @param now Timestamp used for the persisted authentication records. @@ -37,87 +125,14 @@ export const testTotpSecretCipher: TotpSecretCipher = Object.freeze({ */ export async function openAuthenticationTestDatabase(now = authenticationTestNow) { const database = await openFreshMigratedDatabase(); - const session = generateOpaqueToken("session"); - const automation = generateOpaqueToken("automation"); - const expiresAt = addDays(now, 30); try { - database.orm - .insert(users) - .values( - v.parse(userInsertSchema, { - createdAt: now, - disabledAt: null, - id: authenticationTestUserId, - passwordHash: testDashboardPasswordHash, - updatedAt: now, - username: "raymond", - }) - ) - .run(); - database.orm - .insert(authSessions) - .values( - v.parse(authSessionInsertSchema, { - authenticatedAt: now, - authenticationVersion: 1, - authMethod: "password", - createdAt: now, - expiresAt, - id: session.prefix, - lastSeenAt: now, - mfaVerifiedAt: null, - passwordVerifiedAt: now, - userAgent: null, - userId: authenticationTestUserId, - validatorHash: session.validatorHash, - }) - ) - .run(); - database.orm - .insert(automationPrincipals) - .values( - v.parse(automationPrincipalInsertSchema, { - createdAt: now, - disabledAt: null, - id: authenticationTestPrincipalId, - label: "OpenClaw task tracking", - updatedAt: now, - }) - ) - .run(); - database.orm - .insert(automationPrincipalCapabilities) - .values( - v.parse(automationPrincipalCapabilityInsertSchema, { - capability: "reports:read", - grantedAt: now, - principalId: authenticationTestPrincipalId, - }) - ) - .run(); - database.orm - .insert(automationCredentials) - .values( - v.parse(automationCredentialInsertSchema, { - createdAt: now, - expiresAt, - id: authenticationTestCredentialId, - label: "Primary credential", - prefix: automation.prefix, - principalId: authenticationTestPrincipalId, - revokedAt: null, - validatorHash: automation.validatorHash, - }) - ) - .run(); + const seeded = seedAuthenticationTestDatabase(database.orm, now); return { - automation, + ...seeded, database, - expiresAt, repository: createRequestAuthenticationRepository(database.orm), - session, }; } catch (error) { database.sqlite.close(true); diff --git a/greenfield/src/server/platform/errors/safeFailure.test.ts b/greenfield/src/server/platform/errors/safeFailure.test.ts index 70d718c75..5e1d7d7b8 100644 --- a/greenfield/src/server/platform/errors/safeFailure.test.ts +++ b/greenfield/src/server/platform/errors/safeFailure.test.ts @@ -2,6 +2,14 @@ import { expect, test } from "bun:test"; import { Cause, Data } from "effect"; +import { + DatabaseRuntimeCheckpointError, + DatabaseRuntimeCloseError, + DatabaseRuntimeLockTimeoutError, + DatabaseRuntimePathError, + DatabaseRuntimeSnapshotRequiredError, + DatabaseRuntimeStartupError, +} from "../../database/runtime/databaseErrors.ts"; import { describeSafeFailure } from "./safeFailure.ts"; class ExpectedFailure extends Data.TaggedError("ApplicationListenerStopError")<{ @@ -109,3 +117,32 @@ test("fails closed for hostile proxy traps", () => { expect(JSON.stringify(descriptor)).not.toContain("trap secret"); expect(trapCalls).toBe(0); }); + +test("recognizes every redacted database-runtime failure tag", () => { + const failures = [ + new DatabaseRuntimeCheckpointError({ message: "checkpoint failed" }), + new DatabaseRuntimeCloseError({ message: "close failed" }), + new DatabaseRuntimeLockTimeoutError({ + message: "lock timed out", + timeoutMs: 5000, + }), + new DatabaseRuntimePathError({ + message: "path invalid", + reason: "database-file-invalid", + }), + new DatabaseRuntimeSnapshotRequiredError({ message: "snapshot required" }), + new DatabaseRuntimeStartupError({ + message: "startup failed", + reason: "database-startup-failed", + }), + ]; + + expect(failures.map((failure) => describeSafeFailure(failure).tag)).toEqual([ + "DatabaseRuntimeCheckpointError", + "DatabaseRuntimeCloseError", + "DatabaseRuntimeLockTimeoutError", + "DatabaseRuntimePathError", + "DatabaseRuntimeSnapshotRequiredError", + "DatabaseRuntimeStartupError", + ]); +}); diff --git a/greenfield/src/server/platform/errors/safeFailure.ts b/greenfield/src/server/platform/errors/safeFailure.ts index 8420dbd1b..97e9cfbe0 100644 --- a/greenfield/src/server/platform/errors/safeFailure.ts +++ b/greenfield/src/server/platform/errors/safeFailure.ts @@ -8,6 +8,12 @@ const knownFailureTags = new Set([ "AuthenticationUpstreamUnavailableError", "AuthenticationWorkCapacityError", "AuthenticationWorkTimeoutError", + "DatabaseRuntimeCheckpointError", + "DatabaseRuntimeCloseError", + "DatabaseRuntimeLockTimeoutError", + "DatabaseRuntimePathError", + "DatabaseRuntimeSnapshotRequiredError", + "DatabaseRuntimeStartupError", "MonitoringRunConflictError", "MonitoringSnapshotValidationError", "RealtimeEventCursorStreamError", diff --git a/greenfield/src/server/platform/runtime/applicationRuntime.ts b/greenfield/src/server/platform/runtime/applicationRuntime.ts index 27ae69efa..3a4f96e92 100644 --- a/greenfield/src/server/platform/runtime/applicationRuntime.ts +++ b/greenfield/src/server/platform/runtime/applicationRuntime.ts @@ -1,5 +1,11 @@ +import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; import { Data, Effect, Exit, Fiber, Layer, ManagedRuntime, Stream } from "effect"; +import { + databaseRuntimeLayer, + DatabaseRuntimeService, + type DatabaseRuntimeLayerOptions, +} from "../../database/runtime/databaseService.ts"; import { type AuthenticationWorkLayerOptions, type AuthenticationWorkRuntimeService, @@ -10,9 +16,13 @@ import { } from "../../domains/security/authenticationWorkGate.ts"; import { createEffectLoggerLayer } from "../observability/effectLogger.ts"; import type { StructuredLogger } from "../observability/structuredLogger.ts"; -import type { RealtimeEventDelivery } from "../realtime/eventPump.ts"; -import type { RealtimeEventStreamOptions } from "../realtime/eventPumpService.ts"; -import { RealtimeEventPumpService } from "../realtime/eventPumpService.ts"; +import { RealtimeEventPump, type RealtimeEventDelivery } from "../realtime/eventPump.ts"; +import { + realtimeEventPumpLayer, + type RealtimeEventStreamOptions, + RealtimeEventPumpService, +} from "../realtime/eventPumpService.ts"; +import { createRealtimeEventStore } from "../realtime/eventStore.ts"; import { type RenewableStreamLease, withRenewableStreamLease, @@ -76,6 +86,16 @@ export interface ApplicationRuntime { shutdownListener(options: ApplicationListenerShutdownOptions): Promise; } +/** Runtime-owned database access exposed only to the Dashboard composition root. */ +export interface DashboardDatabaseRuntimeService { + readonly orm: () => Promise; +} + +/** Application runtime whose database and realtime pump share one process scope. */ +export interface DashboardApplicationRuntime extends ApplicationRuntime { + readonly database: DashboardDatabaseRuntimeService; +} + /** Scoped layers owned by one composition root for the full process lifetime. */ export interface ApplicationRuntimeOptions { readonly authenticationWork?: AuthenticationWorkLayerOptions; @@ -83,6 +103,14 @@ export interface ApplicationRuntimeOptions { readonly realtimeEventPumpLayer: Layer.Layer; } +/** Production Dashboard runtime inputs with explicit state and release identity. */ +export interface DashboardApplicationRuntimeOptions extends Omit< + ApplicationRuntimeOptions, + "realtimeEventPumpLayer" +> { + readonly database: DatabaseRuntimeLayerOptions; +} + function authenticationAbortReason(signal: AbortSignal): unknown { return ( signal.reason ?? new DOMException("Authentication request aborted", "AbortError") @@ -203,19 +231,17 @@ function coordinatedListenerShutdown( * Creates one reusable Effect runtime whose scope is owned by the current process. * `initialize` eagerly prewarms the otherwise lazy layer before the listener opens; * `dispose` releases it after active HTTP and SSE requests have stopped. - * @param options Scoped application layers. + * @param runtime Scoped process runtime. + * @param logger Exact process logger installed on the runtime. * @returns One reusable and explicitly disposable application runtime. */ -export function createApplicationRuntime( - options: ApplicationRuntimeOptions +function createApplicationRuntimeFromManagedRuntime( + runtime: ManagedRuntime.ManagedRuntime< + AuthenticationWorkService | RealtimeEventPumpService, + RuntimeError + >, + logger: StructuredLogger ): ApplicationRuntime { - const runtime = ManagedRuntime.make( - Layer.mergeAll( - options.realtimeEventPumpLayer, - authenticationWorkLayer(options.authenticationWork), - createEffectLoggerLayer(options.logger) - ) - ); let disposePromise: Promise | undefined; const runAuthenticationEffect = async ( effect: Effect.Effect, @@ -343,10 +369,85 @@ export function createApplicationRuntime( async initialize() { await runtime.context(); }, - logger: options.logger, + logger, services, shutdownListener(options: ApplicationListenerShutdownOptions) { return runtime.runPromise(coordinatedListenerShutdown(options)); }, }); } + +function databaseBackedRealtimeEventPumpLayer(): Layer.Layer< + RealtimeEventPumpService, + never, + DatabaseRuntimeService +> { + return Layer.unwrap( + DatabaseRuntimeService.pipe( + Effect.map((database) => + realtimeEventPumpLayer({ + makePump: (pumpRuntime) => + new RealtimeEventPump({ + ...pumpRuntime, + store: createRealtimeEventStore(database.orm), + }), + }) + ) + ) + ); +} + +/** + * Creates the generic process runtime used by focused transport and service tests. + * @param options Explicit logger, authentication policy, and realtime layer. + * @returns One reusable and explicitly disposable application runtime. + */ +export function createApplicationRuntime( + options: ApplicationRuntimeOptions +): ApplicationRuntime { + const runtime = ManagedRuntime.make( + Layer.mergeAll( + options.realtimeEventPumpLayer, + authenticationWorkLayer(options.authenticationWork), + createEffectLoggerLayer(options.logger) + ) + ); + return createApplicationRuntimeFromManagedRuntime(runtime, options.logger); +} + +/** + * Creates the production Dashboard runtime with SQLite and realtime in one Layer graph. + * The realtime layer depends on and therefore finalizes before the retained database. + * @param options Explicit database, logger, and authentication composition inputs. + * @returns One process runtime exposing only the owned ORM to Dashboard composition. + */ +export function createDashboardApplicationRuntime( + options: DashboardApplicationRuntimeOptions +): DashboardApplicationRuntime { + const databaseLayer = databaseRuntimeLayer(options.database); + const databaseAndRealtimeLayer = databaseBackedRealtimeEventPumpLayer().pipe( + Layer.provideMerge(databaseLayer) + ); + const runtime = ManagedRuntime.make( + Layer.mergeAll( + databaseAndRealtimeLayer, + authenticationWorkLayer(options.authenticationWork), + createEffectLoggerLayer(options.logger) + ) + ); + const applicationRuntime = createApplicationRuntimeFromManagedRuntime( + runtime, + options.logger + ); + const database: DashboardDatabaseRuntimeService = Object.freeze({ + orm: () => + runtime.runPromise( + DatabaseRuntimeService.pipe(Effect.map((service) => service.orm)) + ), + }); + + return Object.freeze({ + ...applicationRuntime, + database, + }); +} diff --git a/greenfield/src/server/platform/runtime/dashboardApplicationRuntime.test.ts b/greenfield/src/server/platform/runtime/dashboardApplicationRuntime.test.ts new file mode 100644 index 000000000..9a19b26c8 --- /dev/null +++ b/greenfield/src/server/platform/runtime/dashboardApplicationRuntime.test.ts @@ -0,0 +1,142 @@ +import { Database } from "bun:sqlite"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { chmod, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { maxTime } from "date-fns/constants"; + +import { migrationsDirectory } from "../../test/support/freshDatabase.ts"; +import { withTestTimeout } from "../../test/support/promise.ts"; +import { createTestStructuredLogger } from "../../test/support/requestContext.ts"; +import { RealtimeEventPump } from "../realtime/eventPump.ts"; +import { insertEvent } from "../realtime/testSupport/eventPump.ts"; +import { createDashboardApplicationRuntime } from "./applicationRuntime.ts"; + +const releaseId = "0".repeat(40); +const testTimeoutMs = 2000; +const temporaryDirectories: string[] = []; + +const stableLease = { + expiresAtMs: maxTime, + renew: () => Promise.resolve(stableLease), +}; + +async function privateTemporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), "dashboard-app-runtime-")); + temporaryDirectories.push(directory); + await chmod(directory, 0o700); + return directory; +} + +async function createTestDashboardRuntime() { + const stateDirectory = await privateTemporaryDirectory(); + return createDashboardApplicationRuntime({ + database: { + migrationsDirectory, + releaseId, + startupMode: "initialize-empty", + stateDirectory, + }, + logger: createTestStructuredLogger(), + }); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe("Dashboard application runtime", () => { + test("shares one runtime-owned ORM with the database-backed realtime pump", async () => { + const runtime = await createTestDashboardRuntime(); + let iterator: AsyncIterator | undefined; + + try { + await runtime.initialize(); + const firstOrm = await runtime.database.orm(); + const secondOrm = await runtime.database.orm(); + expect(firstOrm).toBe(secondOrm); + + const eventId = insertEvent( + { orm: firstOrm }, + { occurredAtMs: 1000, topic: "monitoring.reports" } + ); + const deliveries = await runtime.services.realtimeEvents.stream( + { afterId: "0", topics: ["monitoring.reports"] }, + stableLease + ); + iterator = deliveries[Symbol.asyncIterator](); + + expect( + await withTestTimeout( + iterator.next(), + testTimeoutMs, + "Runtime-owned realtime store did not read the inserted event" + ) + ).toMatchObject({ + done: false, + value: { id: String(eventId), kind: "change" }, + }); + } finally { + try { + await iterator?.return?.(); + } finally { + await runtime.dispose(); + } + } + }); + + test("finalizes the realtime layer before closing its database dependency", async () => { + const order: string[] = []; + const stateDirectory = await privateTemporaryDirectory(); + const databaseFilePath = path.join(stateDirectory, "mira-dashboard.db"); + const originalPumpClose = Object.getOwnPropertyDescriptor( + RealtimeEventPump.prototype, + "close" + )?.value as (this: RealtimeEventPump) => void; + const originalDatabaseClose = Object.getOwnPropertyDescriptor( + Database.prototype, + "close" + )?.value as (this: Database, throwOnError?: boolean) => void; + const pumpCloseSpy = spyOn( + RealtimeEventPump.prototype, + "close" + ).mockImplementation(function (this: RealtimeEventPump) { + order.push("realtime-close"); + return originalPumpClose.call(this); + }); + const databaseCloseSpy = spyOn(Database.prototype, "close").mockImplementation( + function (this: Database, throwOnError?: boolean) { + if (this.filename === databaseFilePath) order.push("database-close"); + return originalDatabaseClose.call(this, throwOnError); + } + ); + const runtime = createDashboardApplicationRuntime({ + database: { + migrationsDirectory, + releaseId, + startupMode: "initialize-empty", + stateDirectory, + }, + logger: createTestStructuredLogger(), + }); + + try { + await runtime.initialize(); + await runtime.dispose(); + + expect(order).toEqual(["realtime-close", "database-close"]); + } finally { + try { + await runtime.dispose(); + } finally { + databaseCloseSpy.mockRestore(); + pumpCloseSpy.mockRestore(); + } + } + }); +}); diff --git a/greenfield/src/server/test/support/automationHttpSystem.ts b/greenfield/src/server/test/support/automationHttpSystem.ts index 752149be6..5dc22156b 100644 --- a/greenfield/src/server/test/support/automationHttpSystem.ts +++ b/greenfield/src/server/test/support/automationHttpSystem.ts @@ -21,6 +21,7 @@ import { dashboardSessionCookieName } from "../../rawHttp/authenticationCredenti import type { AppRouter } from "../../trpc/appRouter.ts"; import { CookieJar, postTrpcMutation, trpcData } from "./mfaHttpSystem.ts"; import { withTestTimeout } from "./promise.ts"; +import { withTestDashboardDatabase } from "./requestContext.ts"; export const automationHttpSystemBrowserOrigin = "https://dashboard.example"; export const automationHttpSystemPrincipalId = "system-automation-reader"; @@ -134,7 +135,10 @@ export async function openAutomationHttpSystem(options: AutomationHttpSystemOpti try { server = await createDashboardServer({ - applicationRuntime: options.applicationRuntime, + applicationRuntime: withTestDashboardDatabase( + options.applicationRuntime, + fixture.database.orm + ), ...(options.authenticationLeaseDurationMs === undefined ? {} : { @@ -142,7 +146,6 @@ export async function openAutomationHttpSystem(options: AutomationHttpSystemOpti options.authenticationLeaseDurationMs, }), browserOrigin: automationHttpSystemBrowserOrigin, - database: fixture.database.orm, gatewayUrl: "ws://127.0.0.1:1", now: () => new Date(), port: 0, diff --git a/greenfield/src/server/test/support/mfaHttpSystem.ts b/greenfield/src/server/test/support/mfaHttpSystem.ts index 2ef899406..09f92cd77 100644 --- a/greenfield/src/server/test/support/mfaHttpSystem.ts +++ b/greenfield/src/server/test/support/mfaHttpSystem.ts @@ -16,7 +16,7 @@ import { createReadinessController } from "../../platform/readiness/readinessSta import { dashboardSessionCookieName } from "../../rawHttp/authenticationCredentials.ts"; import { openFreshMigratedDatabase } from "./freshDatabase.ts"; import { startGatewayCredentialVerifierFixture } from "./gatewayCredentialVerifier.ts"; -import { createTestApplicationRuntime } from "./requestContext.ts"; +import { createTestDashboardApplicationRuntime } from "./requestContext.ts"; export const mfaHttpSystemBrowserOrigin = "https://dashboard.example"; export const mfaHttpSystemPassword = "correct-horse-battery"; @@ -297,9 +297,8 @@ export async function openEnrolledMfaHttpSystem(): Promise Promise.resolve(database) }), + }); +} + +/** + * Creates an inert Dashboard runtime around a migrated test database. + * @param database Migrated test database retained by the fixture. + * @param overrides Runtime methods exercised by the current test. + * @returns A complete Dashboard runtime stub. + */ +export function createTestDashboardApplicationRuntime( + database: SQLiteBunDatabase, + overrides: TestApplicationRuntimeOverrides = {} +): DashboardApplicationRuntime { + return withTestDashboardDatabase(createTestApplicationRuntime(overrides), database); +} + /** * Creates an explicitly authenticated or anonymous request context for tests. * @param authentication Validated identity state supplied to request context creation. diff --git a/greenfield/src/server/test/system/serverAutomationSecurity.test.ts b/greenfield/src/server/test/system/serverAutomationSecurity.test.ts index 222e4f54f..d8ea78f5c 100644 --- a/greenfield/src/server/test/system/serverAutomationSecurity.test.ts +++ b/greenfield/src/server/test/system/serverAutomationSecurity.test.ts @@ -37,8 +37,9 @@ import { import { CookieJar, postTrpcMutation, trpcData } from "../support/mfaHttpSystem.ts"; import { withTestTimeout } from "../support/promise.ts"; import { - createTestApplicationRuntime, + createTestDashboardApplicationRuntime, createTestStructuredLogger, + withTestDashboardDatabase, } from "../support/requestContext.ts"; const leaseInvalidationTimeoutMs = secondsToMilliseconds(5); @@ -70,14 +71,16 @@ describe("real HTTP automation credential lifecycle", () => { try { server = await createDashboardServer({ - applicationRuntime: createTestApplicationRuntime({ - stream: (options) => - Promise.resolve( - oneAutomationDeliveryForRequestedTopic(options.topics) - ), - }), + applicationRuntime: createTestDashboardApplicationRuntime( + fixture.database.orm, + { + stream: (options) => + Promise.resolve( + oneAutomationDeliveryForRequestedTopic(options.topics) + ), + } + ), browserOrigin: automationHttpSystemBrowserOrigin, - database: fixture.database.orm, gatewayUrl: "ws://127.0.0.1:1", now: () => new Date(), port: 0, @@ -333,10 +336,12 @@ describe("real HTTP automation credential lifecycle", () => { try { server = await createDashboardServer({ - applicationRuntime: runtime, + applicationRuntime: withTestDashboardDatabase( + runtime, + fixture.database.orm + ), authenticationLeaseDurationMs: secondsToMilliseconds(1), browserOrigin: automationHttpSystemBrowserOrigin, - database: fixture.database.orm, gatewayUrl: "ws://127.0.0.1:1", now: () => new Date(), port: 0, diff --git a/greenfield/src/server/test/system/serverGatewayCredentialVerification.test.ts b/greenfield/src/server/test/system/serverGatewayCredentialVerification.test.ts index 799fd80f5..7446b01fc 100644 --- a/greenfield/src/server/test/system/serverGatewayCredentialVerification.test.ts +++ b/greenfield/src/server/test/system/serverGatewayCredentialVerification.test.ts @@ -19,7 +19,10 @@ import { postTrpcMutation, } from "../support/mfaHttpSystem.ts"; import { captureFailure } from "../support/promise.ts"; -import { createTestStructuredLogger } from "../support/requestContext.ts"; +import { + createTestStructuredLogger, + withTestDashboardDatabase, +} from "../support/requestContext.ts"; const validGatewayCredential = "valid-gateway-token"; @@ -52,9 +55,11 @@ async function openGatewayVerificationSystem( let server: ApplicationServer | undefined; try { const startedServer = await createDashboardServer({ - applicationRuntime: createGatewayVerificationRuntime(), + applicationRuntime: withTestDashboardDatabase( + createGatewayVerificationRuntime(), + database.orm + ), browserOrigin: mfaHttpSystemBrowserOrigin, - database: database.orm, gatewayUrl: gateway.url, gatewayVerificationTimeoutMs, port: 0, diff --git a/greenfield/src/server/test/system/serverRealtime.test.ts b/greenfield/src/server/test/system/serverRealtime.test.ts index 4abf705c2..f443e72ed 100644 --- a/greenfield/src/server/test/system/serverRealtime.test.ts +++ b/greenfield/src/server/test/system/serverRealtime.test.ts @@ -23,6 +23,7 @@ import { withTestTimeout } from "../support/promise.ts"; import { createTestApplicationRuntime, createTestAuthenticationResolution, + createTestDashboardApplicationRuntime, createTestServerSecurityServices, createTestSessionAuthentication, } from "../support/requestContext.ts"; @@ -232,7 +233,7 @@ describe("application server realtime transport", () => { let server: ApplicationServer | undefined; try { - const runtime = createTestApplicationRuntime({ + const runtime = createTestDashboardApplicationRuntime(fixture.database.orm, { stream: () => Promise.resolve( (async function* () { @@ -243,7 +244,6 @@ describe("application server realtime transport", () => { server = await createDashboardServer({ applicationRuntime: runtime, browserOrigin: "https://dashboard.example", - database: fixture.database.orm, gatewayUrl: "ws://127.0.0.1:1", port: 0, readiness: createReadinessController(), diff --git a/greenfield/src/server/test/system/serverShutdown.test.ts b/greenfield/src/server/test/system/serverShutdown.test.ts index 0557e8f17..4dd442b66 100644 --- a/greenfield/src/server/test/system/serverShutdown.test.ts +++ b/greenfield/src/server/test/system/serverShutdown.test.ts @@ -1,4 +1,8 @@ +import { Database } from "bun:sqlite"; import { describe, expect, spyOn, test } from "bun:test"; +import { chmod, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { secondsToMilliseconds } from "date-fns"; import { Effect, Layer, Stream } from "effect"; @@ -6,11 +10,14 @@ import { Effect, Layer, Stream } from "effect"; import { createServer } from "../../../app/server.ts"; import { createStructuredLogger } from "../../platform/observability/structuredLogger.ts"; import { createReadinessController } from "../../platform/readiness/readinessState.ts"; +import { RealtimeEventPump } from "../../platform/realtime/eventPump.ts"; import { RealtimeEventPumpService } from "../../platform/realtime/eventPumpService.ts"; import { ApplicationListenerStopTimeoutError, createApplicationRuntime, + createDashboardApplicationRuntime, } from "../../platform/runtime/applicationRuntime.ts"; +import { migrationsDirectory } from "../support/freshDatabase.ts"; import { captureFailure } from "../support/promise.ts"; import { createTestApplicationRuntime, @@ -60,6 +67,42 @@ function createShutdownTestRuntime(onDispose: () => void) { } describe("application server shutdown", () => { + test("withdraws readiness before listener drain begins", async () => { + const fake = createPendingBunServer(); + const readiness = createReadinessController(); + readiness.markReady(); + const observedReadiness: boolean[] = []; + const originalStop = fake.server.stop.bind(fake.server); + const serverWithObservedStop = { + ...fake.server, + stop(force = false) { + observedReadiness.push(readiness.isReady()); + return originalStop(force); + }, + } as ReturnType; + const serveSpy = spyOn(Bun, "serve").mockReturnValue(serverWithObservedStop); + + try { + const server = await createServer({ + ...createTestServerSecurityServices(), + applicationRuntime: createShutdownTestRuntime(() => {}), + port: 3100, + readiness, + }); + + const gracefulStop = server.stop(); + await fake.gracefulStarted; + + expect(readiness.isReady()).toBe(false); + expect(observedReadiness).toEqual([false]); + + expect(server.stop(true)).toBe(gracefulStop); + await gracefulStop; + } finally { + serveSpy.mockRestore(); + } + }); + test("flushes the process logger after runtime disposal", async () => { const fake = createPendingBunServer(); const serveSpy = spyOn(Bun, "serve").mockReturnValue(fake.server); @@ -102,6 +145,96 @@ describe("application server shutdown", () => { } }); + test("closes listener, realtime, database, and logger in dependency order", async () => { + const stateDirectory = await mkdtemp( + path.join(os.tmpdir(), "dashboard-shutdown-order-") + ); + await chmod(stateDirectory, 0o700); + const order: string[] = []; + const fake = createPendingBunServer(); + const originalStop = fake.server.stop.bind(fake.server); + const serverWithObservedStop = { + ...fake.server, + stop(force = false) { + order.push("listener-stop"); + return originalStop(force); + }, + } as ReturnType; + const serveSpy = spyOn(Bun, "serve").mockReturnValue(serverWithObservedStop); + const originalPumpClose = Object.getOwnPropertyDescriptor( + RealtimeEventPump.prototype, + "close" + )?.value as (this: RealtimeEventPump) => void; + const originalDatabaseClose = Object.getOwnPropertyDescriptor( + Database.prototype, + "close" + )?.value as (this: Database, throwOnError?: boolean) => void; + const databaseFilePath = path.join(stateDirectory, "mira-dashboard.db"); + const pumpCloseSpy = spyOn( + RealtimeEventPump.prototype, + "close" + ).mockImplementation(function (this: RealtimeEventPump) { + order.push("realtime-close"); + return originalPumpClose.call(this); + }); + const databaseCloseSpy = spyOn(Database.prototype, "close").mockImplementation( + function (this: Database, throwOnError?: boolean) { + if (this.filename === databaseFilePath) order.push("database-close"); + return originalDatabaseClose.call(this, throwOnError); + } + ); + const logger = createStructuredLogger({ + identity: { + bun: "1.4.0-test", + pid: 123, + processRole: "web", + release: "server-database-shutdown-test", + service: "mira-dashboard", + }, + sink: { + flush() { + order.push("logger-flush"); + }, + write() {}, + }, + }); + const applicationRuntime = createDashboardApplicationRuntime({ + database: { + migrationsDirectory, + releaseId: "0".repeat(40), + startupMode: "initialize-empty", + stateDirectory, + }, + logger, + }); + + try { + const server = await createServer({ + ...createTestServerSecurityServices(), + applicationRuntime, + port: 3100, + readiness: createReadinessController(), + }); + await server.stop(true); + + expect(order).toEqual([ + "listener-stop", + "realtime-close", + "database-close", + "logger-flush", + ]); + } finally { + try { + await applicationRuntime.dispose(); + } finally { + databaseCloseSpy.mockRestore(); + pumpCloseSpy.mockRestore(); + serveSpy.mockRestore(); + await rm(stateDirectory, { force: true, recursive: true }); + } + } + }); + test("forces immediately when the first stop request is forced", async () => { const fake = createPendingBunServer(); const serveSpy = spyOn(Bun, "serve").mockReturnValue(fake.server); From 8adc5307a8ab4d7bedd581ce0f258badab20663c Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Thu, 6 Aug 2026 14:47:20 +0200 Subject: [PATCH 3/6] docs(rewrite): format progress matrix after rebase --- .../architecture/greenfield-rewrite/progress.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md index 09eb482d0..beb4cd5d4 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -7,15 +7,15 @@ This matrix is the living phase status. Update it in the same change that materially advances or closes a phase; dated entries below provide the evidence, not a second status source. -| Phase | Status | Current evidence and remaining gate | -| ----------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | +| Phase | Status | Current evidence and remaining gate | +| ----------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | | 1 — Foundation | In progress | Server composition, migrations, contracts, raw HTTP/realtime foundations, process-owned database runtime, source-boundary enforcement, staged typed configuration, generated configuration reference, structured logging/request correlation, and procedure error policy exist; executable web/worker roots, browser shell, complete generated references, and release/rollback closure remain. | -| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | -| 3 — Core operator domains | Started | Monitoring transaction/schema foundations exist; task, agent, report, incident, notification, schedule/job, cache/metrics procedures and browser parity are not complete. | -| 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. | -| 5 — Privileged and external domains | Not started | Worker-owned file/media, Docker, database, OpenClaw, GitHub, deployment, backup, and other privileged adapters remain open. | -| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | +| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | +| 3 — Core operator domains | Started | Monitoring transaction/schema foundations exist; task, agent, report, incident, notification, schedule/job, cache/metrics procedures and browser parity are not complete. | +| 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. | +| 5 — Privileged and external domains | Not started | Worker-owned file/media, Docker, database, OpenClaw, GitHub, deployment, backup, and other privileged adapters remain open. | +| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | ### 2026-08-03 — Phase 0 started From 290bbcea5bdc11170ebaa5efe3e44c9bf8d1b0cc Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Thu, 6 Aug 2026 17:54:28 +0200 Subject: [PATCH 4/6] fix(database): harden write admission and shutdown --- greenfield/docs/generated/procedures.md | 26 +- greenfield/src/app/dashboardServer.test.ts | 42 +- greenfield/src/app/dashboardServer.ts | 90 ++-- greenfield/src/contracts/accountSecurity.ts | 42 +- greenfield/src/contracts/auth.ts | 14 +- .../src/contracts/automationSecurity.ts | 1 + .../database/immediateWriteAdmission.ts | 12 + .../migrations/applyVerifiedMigrations.ts | 91 +++- .../server/database/runtime/databaseErrors.ts | 39 +- .../database/runtime/databasePath.test.ts | 35 +- .../server/database/runtime/databasePath.ts | 39 ++ .../server/database/runtime/databasePolicy.ts | 83 +++- .../database/runtime/databaseService.test.ts | 209 +++++++++ .../database/runtime/databaseService.ts | 5 + .../database/runtime/databaseStartup.ts | 92 ++-- .../server/domains/monitoring/repository.ts | 23 +- .../src/server/domains/monitoring/service.ts | 217 +++++---- .../monitoring/serviceBoundary.test.ts | 52 +-- .../monitoring/serviceLifecycle.test.ts | 16 +- .../monitoring/serviceOrdering.test.ts | 16 +- .../testSupport/monitoringService.ts | 12 +- .../domains/security/authPublicRoutes.ts | 6 +- .../domains/security/authSessionRoutes.ts | 10 +- .../authenticationLifecycle.sessions.test.ts | 14 +- .../authenticationLifecycleBootstrap.ts | 50 ++- .../authenticationLifecycleContext.ts | 6 +- .../security/authenticationLifecycleLogin.ts | 8 +- .../authenticationLifecyclePassword.ts | 4 +- .../authenticationLifecycleRepository.test.ts | 20 +- .../authenticationLifecycleRepository.ts | 29 +- .../authenticationLifecycleSessions.ts | 12 +- .../security/authenticationLifecycleTypes.ts | 8 +- .../security/authenticationWorkGate.test.ts | 2 +- .../security/authenticationWorkGate.ts | 235 ++++++---- .../authenticationWorkGate.webAuthn.test.ts | 414 +++++++++++++++++- .../automation/credentialOperations.ts | 12 +- .../automation/lifecycleCredential.test.ts | 138 +++--- .../automation/lifecyclePrincipal.test.ts | 127 +++--- .../automation/lifecycleRepository.test.ts | 29 +- .../automation/lifecycleRepository.ts | 30 +- .../automation/lifecycleRepositoryTypes.ts | 2 +- .../security/automation/lifecycleTypes.ts | 30 +- .../automation/principalOperations.ts | 12 +- .../security/automation/procedures.test.ts | 68 +-- .../domains/security/automation/routes.ts | 24 +- .../automation/testSupport/lifecycle.ts | 8 +- .../security/mfa/accountFactorRoutes.ts | 4 +- .../mfa/accountLifecycle.factors.test.ts | 4 +- .../mfa/accountLifecycle.maintenance.test.ts | 2 +- .../mfa/accountLifecycle.proofs.test.ts | 2 +- .../mfa/accountLifecycle.webAuthn.test.ts | 39 +- .../security/mfa/accountLifecycleCrypto.ts | 8 +- .../security/mfa/accountLifecycleTypes.ts | 4 +- .../domains/security/mfa/accountMfaDisable.ts | 6 +- .../mfa/accountPasswordReauthentication.ts | 6 +- .../mfa/accountRecoveryCodeRotation.ts | 2 +- .../security/mfa/accountRecoveryStepUp.ts | 6 +- .../mfa/accountTotpEnrollmentBegin.ts | 2 +- .../mfa/accountTotpEnrollmentConfirmation.ts | 18 +- .../security/mfa/accountTotpFactorRemoval.ts | 4 +- .../domains/security/mfa/accountTotpStepUp.ts | 6 +- .../mfa/accountWebAuthnCredentialRemoval.ts | 4 +- .../mfa/accountWebAuthnEnrollmentBegin.ts | 2 +- .../accountWebAuthnEnrollmentConfirmation.ts | 34 +- .../accountWebAuthnEnrollmentSettlement.ts | 32 +- .../security/mfa/accountWebAuthnRoutes.ts | 4 +- .../security/mfa/accountWebAuthnStepUp.ts | 32 +- .../mfa/accountWebAuthnStepUpBegin.ts | 2 +- .../mfa/accountWebAuthnStepUpSettlement.ts | 26 +- .../security/mfa/lifecycleRepository.test.ts | 48 +- .../security/mfa/lifecycleRepository.ts | 21 +- .../mfa/lifecycleRepository.webAuthn.test.ts | 114 ++--- .../security/mfa/lifecycleRepositoryTypes.ts | 2 +- .../domains/security/mfa/loginCoordinator.ts | 87 ++-- .../mfa/loginLifecycle.pending.test.ts | 4 +- .../security/mfa/loginLifecycle.totp.test.ts | 2 +- .../mfa/loginLifecycle.webAuthn.test.ts | 49 +-- .../security/mfa/loginLifecycleTypes.ts | 4 +- .../security/mfa/loginPendingLifecycle.ts | 8 +- .../security/mfa/loginWebAuthnProof.ts | 24 +- .../mfa/testSupport/accountLifecycle.ts | 12 +- .../mfa/testSupport/loginLifecycle.ts | 17 +- .../domains/security/procedures.test.ts | 12 +- .../testSupport/authenticationLifecycle.ts | 8 +- .../platform/errors/safeFailure.test.ts | 25 ++ .../src/server/platform/errors/safeFailure.ts | 3 + .../platform/runtime/applicationRuntime.ts | 73 ++- .../dashboardApplicationRuntime.test.ts | 77 ++++ .../server/test/contracts/trpcErrors.test.ts | 25 ++ .../test/support/authenticationWorkGate.ts | 6 +- .../test/support/databaseWriteAdmission.ts | 25 ++ .../src/server/test/support/requestContext.ts | 46 +- .../serverAuthenticationTransport.test.ts | 2 +- .../server/trpc/procedureErrorPolicy.test.ts | 47 +- .../src/server/trpc/procedureErrorPolicy.ts | 61 ++- 95 files changed, 2495 insertions(+), 1010 deletions(-) create mode 100644 greenfield/src/server/database/immediateWriteAdmission.ts create mode 100644 greenfield/src/server/test/support/databaseWriteAdmission.ts diff --git a/greenfield/docs/generated/procedures.md b/greenfield/docs/generated/procedures.md index 0b8fb9d41..f15d215f4 100644 --- a/greenfield/docs/generated/procedures.md +++ b/greenfield/docs/generated/procedures.md @@ -9,34 +9,34 @@ | `accountSecurity.beginWebAuthnStepUp` | mutation | account-security | Authenticated browser session | [input](./schemas/accountSecurity.beginWebAuthnStepUp.input.schema.json) | [output](./schemas/accountSecurity.beginWebAuthnStepUp.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required` | Creates one session-bound WebAuthn step-up challenge. | | `accountSecurity.confirmTotpEnrollment` | mutation | account-security | Recent password when MFA is disabled; recent MFA when enabled | [input](./schemas/accountSecurity.confirmTotpEnrollment.input.schema.json) | [output](./schemas/accountSecurity.confirmTotpEnrollment.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `step_up_required` | Confirms a TOTP factor and atomically enables MFA when it is first. | | `accountSecurity.confirmWebAuthnEnrollment` | mutation | account-security | Recent password when MFA is disabled; recent MFA when enabled | [input](./schemas/accountSecurity.confirmWebAuthnEnrollment.input.schema.json) | [output](./schemas/accountSecurity.confirmWebAuthnEnrollment.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `step_up_required` | Verifies and stores a WebAuthn credential, enabling MFA when it is first. | -| `accountSecurity.disableMfa` | mutation | account-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/accountSecurity.disableMfa.input.schema.json) | [output](./schemas/accountSecurity.disableMfa.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Disables MFA after recent MFA and current-password verification. | -| `accountSecurity.reauthenticatePassword` | mutation | account-security | Authenticated browser session | [input](./schemas/accountSecurity.reauthenticatePassword.input.schema.json) | [output](./schemas/accountSecurity.reauthenticatePassword.output.schema.json) | `FORBIDDEN`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Rotates the session after refreshing recent password verification. | -| `accountSecurity.removeTotpFactor` | mutation | account-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/accountSecurity.removeTotpFactor.input.schema.json) | [output](./schemas/accountSecurity.removeTotpFactor.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Removes a TOTP factor without removing the final possession factor. | -| `accountSecurity.removeWebAuthnCredential` | mutation | account-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/accountSecurity.removeWebAuthnCredential.input.schema.json) | [output](./schemas/accountSecurity.removeWebAuthnCredential.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Removes a WebAuthn credential without removing the final possession factor. | -| `accountSecurity.rotateRecoveryCodes` | mutation | account-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/accountSecurity.rotateRecoveryCodes.input.schema.json) | [output](./schemas/accountSecurity.rotateRecoveryCodes.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Replaces all recovery codes and returns the plaintext set once. | -| `accountSecurity.stepUpRecovery` | mutation | account-security | Authenticated browser session | [input](./schemas/accountSecurity.stepUpRecovery.input.schema.json) | [output](./schemas/accountSecurity.stepUpRecovery.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `mfa_enrollment_required` | Consumes a recovery code and rotates the recently verified session. | +| `accountSecurity.disableMfa` | mutation | account-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/accountSecurity.disableMfa.input.schema.json) | [output](./schemas/accountSecurity.disableMfa.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Disables MFA after recent MFA and current-password verification. | +| `accountSecurity.reauthenticatePassword` | mutation | account-security | Authenticated browser session | [input](./schemas/accountSecurity.reauthenticatePassword.input.schema.json) | [output](./schemas/accountSecurity.reauthenticatePassword.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Rotates the session after refreshing recent password verification. | +| `accountSecurity.removeTotpFactor` | mutation | account-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/accountSecurity.removeTotpFactor.input.schema.json) | [output](./schemas/accountSecurity.removeTotpFactor.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Removes a TOTP factor without removing the final possession factor. | +| `accountSecurity.removeWebAuthnCredential` | mutation | account-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/accountSecurity.removeWebAuthnCredential.input.schema.json) | [output](./schemas/accountSecurity.removeWebAuthnCredential.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Removes a WebAuthn credential without removing the final possession factor. | +| `accountSecurity.rotateRecoveryCodes` | mutation | account-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/accountSecurity.rotateRecoveryCodes.input.schema.json) | [output](./schemas/accountSecurity.rotateRecoveryCodes.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Replaces all recovery codes and returns the plaintext set once. | +| `accountSecurity.stepUpRecovery` | mutation | account-security | Authenticated browser session | [input](./schemas/accountSecurity.stepUpRecovery.input.schema.json) | [output](./schemas/accountSecurity.stepUpRecovery.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `mfa_enrollment_required` | Consumes a recovery code and rotates the recently verified session. | | `accountSecurity.stepUpTotp` | mutation | account-security | Authenticated browser session | [input](./schemas/accountSecurity.stepUpTotp.input.schema.json) | [output](./schemas/accountSecurity.stepUpTotp.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `mfa_enrollment_required` | Rotates the session after a fresh TOTP proof. | | `accountSecurity.stepUpWebAuthn` | mutation | account-security | Authenticated browser session | [input](./schemas/accountSecurity.stepUpWebAuthn.input.schema.json) | [output](./schemas/accountSecurity.stepUpWebAuthn.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `mfa_enrollment_required` | Consumes a WebAuthn challenge and rotates the verified session. | | `accountSecurity.summary` | query | account-security | Authenticated browser session | [input](./schemas/accountSecurity.summary.input.schema.json) | [output](./schemas/accountSecurity.summary.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Returns MFA inventory and server-relative recent-auth state. | | `auth.beginWebAuthnLogin` | mutation | auth | Pending MFA login | [input](./schemas/auth.beginWebAuthnLogin.input.schema.json) | [output](./schemas/auth.beginWebAuthnLogin.output.schema.json) | `CONFLICT`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Creates one pending-login-bound WebAuthn assertion challenge. | | `auth.bootstrap` | mutation | auth | Public | [input](./schemas/auth.bootstrap.input.schema.json) | [output](./schemas/auth.bootstrap.output.schema.json) | `CONFLICT`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Verifies the Gateway credential and creates the sole first user. | -| `auth.changePassword` | mutation | auth | Browser session when MFA is disabled; recent MFA when enabled | [input](./schemas/auth.changePassword.input.schema.json) | [output](./schemas/auth.changePassword.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `step_up_required` | Changes the password, rotates the current session, and revokes the rest. | +| `auth.changePassword` | mutation | auth | Browser session when MFA is disabled; recent MFA when enabled | [input](./schemas/auth.changePassword.input.schema.json) | [output](./schemas/auth.changePassword.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `step_up_required` | Changes the password, rotates the current session, and revokes the rest. | | `auth.login` | mutation | auth | Public | [input](./schemas/auth.login.input.schema.json) | [output](./schemas/auth.login.output.schema.json) | `CONFLICT`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Creates a session or a five-minute pending MFA login after password verification. | | `auth.loginRecovery` | mutation | auth | Pending MFA login | [input](./schemas/auth.loginRecovery.input.schema.json) | [output](./schemas/auth.loginRecovery.output.schema.json) | `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Consumes a pending login and one recovery code to create the browser session. | | `auth.loginTotp` | mutation | auth | Pending MFA login | [input](./schemas/auth.loginTotp.input.schema.json) | [output](./schemas/auth.loginTotp.output.schema.json) | `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Consumes a pending login with a TOTP proof and creates the browser session. | | `auth.loginWebAuthn` | mutation | auth | Pending MFA login | [input](./schemas/auth.loginWebAuthn.input.schema.json) | [output](./schemas/auth.loginWebAuthn.output.schema.json) | `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Consumes a pending login and WebAuthn challenge to create the browser session. | -| `auth.logout` | mutation | auth | Public | [input](./schemas/auth.logout.input.schema.json) | [output](./schemas/auth.logout.output.schema.json) | None | None | Revokes current session and pending-login state and clears both cookies. | -| `auth.revokeSession` | mutation | auth | Recent password when MFA is disabled; recent MFA when enabled | [input](./schemas/auth.revokeSession.input.schema.json) | [output](./schemas/auth.revokeSession.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | `step_up_required` | Revokes one browser session owned by the current user. | +| `auth.logout` | mutation | auth | Public | [input](./schemas/auth.logout.input.schema.json) | [output](./schemas/auth.logout.output.schema.json) | `SERVICE_UNAVAILABLE` | None | Revokes current session and pending-login state and clears both cookies. | +| `auth.revokeSession` | mutation | auth | Recent password when MFA is disabled; recent MFA when enabled | [input](./schemas/auth.revokeSession.input.schema.json) | [output](./schemas/auth.revokeSession.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `step_up_required` | Revokes one browser session owned by the current user. | | `auth.sessions` | query | auth | Authenticated browser session | [input](./schemas/auth.sessions.input.schema.json) | [output](./schemas/auth.sessions.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Lists the current user's browser sessions without validators. | | `auth.status` | query | auth | Public | [input](./schemas/auth.status.input.schema.json) | [output](./schemas/auth.status.output.schema.json) | None | None | Returns bootstrap, pending MFA, and current browser-session state. | -| `auth.touch` | mutation | auth | Authenticated browser session | [input](./schemas/auth.touch.input.schema.json) | [output](./schemas/auth.touch.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Records explicit browser activity for the current session. | +| `auth.touch` | mutation | auth | Authenticated browser session | [input](./schemas/auth.touch.input.schema.json) | [output](./schemas/auth.touch.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Records explicit browser activity for the current session. | | `automationSecurity.createCredential` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.createCredential.input.schema.json) | [output](./schemas/automationSecurity.createCredential.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `PRECONDITION_FAILED`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Creates one credential and reveals its token once. | | `automationSecurity.createPrincipal` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.createPrincipal.input.schema.json) | [output](./schemas/automationSecurity.createPrincipal.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `PRECONDITION_FAILED`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Creates one named principal and reveals its initial token once. | -| `automationSecurity.disablePrincipal` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.disablePrincipal.input.schema.json) | [output](./schemas/automationSecurity.disablePrincipal.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Disables one principal and revokes its active credentials. | +| `automationSecurity.disablePrincipal` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.disablePrincipal.input.schema.json) | [output](./schemas/automationSecurity.disablePrincipal.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Disables one principal and revokes its active credentials. | | `automationSecurity.listCredentials` | query | automation-security | Authenticated browser session | [input](./schemas/automationSecurity.listCredentials.input.schema.json) | [output](./schemas/automationSecurity.listCredentials.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `UNAUTHORIZED` | None | Lists one stable page of non-secret credential history. | | `automationSecurity.listPrincipals` | query | automation-security | Authenticated browser session | [input](./schemas/automationSecurity.listPrincipals.input.schema.json) | [output](./schemas/automationSecurity.listPrincipals.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Lists one stable page of automation principals and credential counts. | -| `automationSecurity.replaceCapabilities` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.replaceCapabilities.input.schema.json) | [output](./schemas/automationSecurity.replaceCapabilities.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Atomically replaces a principal's least-privilege capability set. | -| `automationSecurity.revokeCredential` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.revokeCredential.input.schema.json) | [output](./schemas/automationSecurity.revokeCredential.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Explicitly revokes one automation credential after client cutover. | +| `automationSecurity.replaceCapabilities` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.replaceCapabilities.input.schema.json) | [output](./schemas/automationSecurity.replaceCapabilities.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Atomically replaces a principal's least-privilege capability set. | +| `automationSecurity.revokeCredential` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.revokeCredential.input.schema.json) | [output](./schemas/automationSecurity.revokeCredential.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Explicitly revokes one automation credential after client cutover. | | `automationSecurity.rotateCredential` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.rotateCredential.input.schema.json) | [output](./schemas/automationSecurity.rotateCredential.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `PRECONDITION_FAILED`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Stages a linked replacement credential without revoking its predecessor. | | `events.stream` | subscription | events | Authenticated; per-topic: notifications:read, reports:read | [input](./schemas/events.stream.input.schema.json) | [output](./schemas/events.stream.output.schema.json) | `BAD_REQUEST`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Streams authorized durable changes with tracked resume cursors. | | `system.runtimeIdentity` | query | system | Public | [input](./schemas/system.runtimeIdentity.input.schema.json) | [output](./schemas/system.runtimeIdentity.output.schema.json) | None | None | Returns the Bun runtime identity of the serving process. | diff --git a/greenfield/src/app/dashboardServer.test.ts b/greenfield/src/app/dashboardServer.test.ts index a950f4086..bfbd38019 100644 --- a/greenfield/src/app/dashboardServer.test.ts +++ b/greenfield/src/app/dashboardServer.test.ts @@ -16,8 +16,12 @@ import { import { createReadinessController } from "../server/platform/readiness/readinessState.ts"; import { createDashboardApplicationRuntime } from "../server/platform/runtime/applicationRuntime.ts"; import { dashboardSessionCookieName } from "../server/rawHttp/authenticationCredentials.ts"; +import { runTestImmediateDatabaseWrite } from "../server/test/support/databaseWriteAdmission.ts"; import { migrationsDirectory } from "../server/test/support/freshDatabase.ts"; -import { createTestStructuredLogger } from "../server/test/support/requestContext.ts"; +import { + createTestApplicationRuntime, + createTestStructuredLogger, +} from "../server/test/support/requestContext.ts"; import { createDashboardServer, validateDashboardWebAuthnBrowserOrigin, @@ -47,6 +51,42 @@ describe("Dashboard security composition", () => { ); }); + test("releases an already-initialized runtime when composition preflight fails", async () => { + let disposeCalls = 0; + let initializeCalls = 0; + const applicationRuntime = Object.freeze({ + ...createTestApplicationRuntime({ + dispose: () => { + disposeCalls += 1; + return Promise.resolve(); + }, + initialize: () => { + initializeCalls += 1; + return Promise.resolve(); + }, + }), + database: Object.freeze({ + orm: () => Promise.reject(new Error("Database must not be reached")), + run: runTestImmediateDatabaseWrite, + }), + }); + await applicationRuntime.initialize(); + + expect( + createDashboardServer({ + applicationRuntime, + browserOrigin: "not-an-origin", + gatewayUrl: "ws://127.0.0.1:1", + port: 0, + readiness: createReadinessController(), + totpSecretCipher: testTotpSecretCipher, + }) + ).rejects.toBeInstanceOf(TypeError); + + expect(initializeCalls).toBe(1); + expect(disposeCalls).toBe(1); + }); + test("wires the persisted automation lifecycle through the production server", async () => { const stateDirectory = await mkdtemp( path.join(os.tmpdir(), "dashboard-server-composition-") diff --git a/greenfield/src/app/dashboardServer.ts b/greenfield/src/app/dashboardServer.ts index 383033983..298849588 100644 --- a/greenfield/src/app/dashboardServer.ts +++ b/greenfield/src/app/dashboardServer.ts @@ -84,45 +84,47 @@ export function validateDashboardWebAuthnBrowserOrigin( export async function createDashboardServer( options: DashboardServerOptions ): Promise { - const browserOrigin = validateDashboardWebAuthnBrowserOrigin( - options.browserOrigin, - options.webAuthnRelyingParty - ); - const verifyGatewayCredential = createGatewayCredentialVerifier({ - url: options.gatewayUrl, - }); - const authenticationWork = options.applicationRuntime.services.authentication; - const passwordWorkGate = authenticationWork.passwordWorkGate; - const passwordWorkBudget = createAuthenticationWorkBudget( - authenticationWorkBudgetMaximumUnits, - authenticationWorkBudgetWindowMs - ); - const totpWorkBudget = createAuthenticationWorkBudget( - totpWorkBudgetMaximumUnits, - totpWorkBudgetWindowMs - ); - const webAuthnWorkBudget = createAuthenticationWorkBudget( - webAuthnWorkBudgetMaximumUnits, - webAuthnWorkBudgetWindowMs - ); - const webAuthn = - options.webAuthnRelyingParty === undefined - ? undefined - : Object.freeze({ - adapter: createWebAuthnAdapter(options.webAuthnRelyingParty), - relyingParty: options.webAuthnRelyingParty, - ...(options.webAuthnVerificationTimeoutMs === undefined - ? {} - : { - verificationTimeoutMs: options.webAuthnVerificationTimeoutMs, - }), - workBudget: webAuthnWorkBudget, - workRuntime: authenticationWork, - }); let serverOwnsRuntimeCleanup = false; try { + const browserOrigin = validateDashboardWebAuthnBrowserOrigin( + options.browserOrigin, + options.webAuthnRelyingParty + ); + const verifyGatewayCredential = createGatewayCredentialVerifier({ + url: options.gatewayUrl, + }); + const authenticationWork = options.applicationRuntime.services.authentication; + const passwordWorkGate = authenticationWork.passwordWorkGate; + const passwordWorkBudget = createAuthenticationWorkBudget( + authenticationWorkBudgetMaximumUnits, + authenticationWorkBudgetWindowMs + ); + const totpWorkBudget = createAuthenticationWorkBudget( + totpWorkBudgetMaximumUnits, + totpWorkBudgetWindowMs + ); + const webAuthnWorkBudget = createAuthenticationWorkBudget( + webAuthnWorkBudgetMaximumUnits, + webAuthnWorkBudgetWindowMs + ); + const webAuthn = + options.webAuthnRelyingParty === undefined + ? undefined + : Object.freeze({ + adapter: createWebAuthnAdapter(options.webAuthnRelyingParty), + relyingParty: options.webAuthnRelyingParty, + ...(options.webAuthnVerificationTimeoutMs === undefined + ? {} + : { + verificationTimeoutMs: + options.webAuthnVerificationTimeoutMs, + }), + workBudget: webAuthnWorkBudget, + workRuntime: authenticationWork, + }); await options.applicationRuntime.initialize(); - const database = await options.applicationRuntime.database.orm(); + const databaseRuntime = options.applicationRuntime.database; + const database = await databaseRuntime.orm(); const repository = createRequestAuthenticationRepository(database); const authenticator = createRequestAuthenticator({ authenticationLeaseDurationMs: options.authenticationLeaseDurationMs, @@ -130,7 +132,7 @@ export async function createDashboardServer( repository, sessionIdleDurationMs: options.sessionIdleDurationMs, }); - const mfaRepository = createMfaLifecycleRepository(database); + const mfaRepository = createMfaLifecycleRepository(database, databaseRuntime); const mfaLoginLifecycle = createMfaLoginLifecycleService({ ...(options.now !== undefined && { now: options.now }), passwordWorkBudget, @@ -175,18 +177,20 @@ export async function createDashboardServer( passwordWorkBudget, passwordWorkGate, recentAuthenticationWindowMs: options.recentAuthenticationWindowMs, - repository: createAuthenticationLifecycleRepository(database), + repository: createAuthenticationLifecycleRepository( + database, + databaseRuntime + ), sessionIdleDurationMs: options.sessionIdleDurationMs, verifyGatewayCredential, }); const automationSecurityLifecycle = createAutomationSecurityLifecycleService({ ...(options.now !== undefined && { now: options.now }), recentAuthenticationWindowMs: options.recentAuthenticationWindowMs, - repository: createAutomationLifecycleRepository(database), + repository: createAutomationLifecycleRepository(database, databaseRuntime), sessionIdleDurationMs: options.sessionIdleDurationMs, }); - serverOwnsRuntimeCleanup = true; - return await createServer({ + const serverOptions: ServerOptions = { applicationRuntime: options.applicationRuntime, authenticateCredential: (credential) => authenticator.authenticate(credential), @@ -200,7 +204,9 @@ export async function createDashboardServer( port: options.port, readiness: options.readiness, trustedProxyAddresses: options.trustedProxyAddresses, - }); + }; + serverOwnsRuntimeCleanup = true; + return await createServer(serverOptions); } catch (error) { if (!serverOwnsRuntimeCleanup) { try { diff --git a/greenfield/src/contracts/accountSecurity.ts b/greenfield/src/contracts/accountSecurity.ts index 20a7b3cfc..08c330ed1 100644 --- a/greenfield/src/contracts/accountSecurity.ts +++ b/greenfield/src/contracts/accountSecurity.ts @@ -440,7 +440,7 @@ export const accountSecurityProcedureContracts = [ { access: sessionAccess, domain: "account-security", - errors: ["FORBIDDEN", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], + errors: ["FORBIDDEN", "SERVICE_UNAVAILABLE", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], input: passwordReauthenticationInputSchema, inputSchemaId: "accountSecurity.reauthenticatePassword.input", kind: "mutation", @@ -474,7 +474,13 @@ export const accountSecurityProcedureContracts = [ access: sessionAccess, domain: "account-security", errorReasons: ["mfa_enrollment_required"], - errors: ["CONFLICT", "FORBIDDEN", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], + errors: [ + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], input: recoveryStepUpInputSchema, inputSchemaId: "accountSecurity.stepUpRecovery.input", kind: "mutation", @@ -591,7 +597,13 @@ export const accountSecurityProcedureContracts = [ access: recentMfaAccess, domain: "account-security", errorReasons: recentMfaErrorReasons, - errors: ["CONFLICT", "FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED"], + errors: [ + "CONFLICT", + "FORBIDDEN", + "NOT_FOUND", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], input: removeTotpFactorInputSchema, inputSchemaId: "accountSecurity.removeTotpFactor.input", kind: "mutation", @@ -605,7 +617,13 @@ export const accountSecurityProcedureContracts = [ access: recentMfaAccess, domain: "account-security", errorReasons: recentMfaErrorReasons, - errors: ["CONFLICT", "FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED"], + errors: [ + "CONFLICT", + "FORBIDDEN", + "NOT_FOUND", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], input: removeWebAuthnCredentialInputSchema, inputSchemaId: "accountSecurity.removeWebAuthnCredential.input", kind: "mutation", @@ -620,7 +638,13 @@ export const accountSecurityProcedureContracts = [ access: recentMfaAccess, domain: "account-security", errorReasons: recentMfaErrorReasons, - errors: ["CONFLICT", "FORBIDDEN", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], + errors: [ + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], input: emptyInputSchema, inputSchemaId: "accountSecurity.rotateRecoveryCodes.input", kind: "mutation", @@ -634,7 +658,13 @@ export const accountSecurityProcedureContracts = [ access: recentMfaAccess, domain: "account-security", errorReasons: recentMfaErrorReasons, - errors: ["CONFLICT", "FORBIDDEN", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], + errors: [ + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], input: disableMfaInputSchema, inputSchemaId: "accountSecurity.disableMfa.input", kind: "mutation", diff --git a/greenfield/src/contracts/auth.ts b/greenfield/src/contracts/auth.ts index 19a4aefc2..077650b59 100644 --- a/greenfield/src/contracts/auth.ts +++ b/greenfield/src/contracts/auth.ts @@ -391,7 +391,7 @@ export const authProcedureContracts = [ { access: publicAccess, domain: "auth", - errors: [], + errors: ["SERVICE_UNAVAILABLE"], input: emptyInputSchema, inputSchemaId: "auth.logout.input", kind: "mutation", @@ -418,7 +418,7 @@ export const authProcedureContracts = [ { access: sessionAccess, domain: "auth", - errors: ["FORBIDDEN", "UNAUTHORIZED"], + errors: ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], input: emptyInputSchema, inputSchemaId: "auth.touch.input", kind: "mutation", @@ -432,7 +432,7 @@ export const authProcedureContracts = [ access: sessionMutationAccess, domain: "auth", errorReasons: ["step_up_required"], - errors: ["FORBIDDEN", "UNAUTHORIZED"], + errors: ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], input: sessionRevokeInputSchema, inputSchemaId: "auth.revokeSession.input", kind: "mutation", @@ -446,7 +446,13 @@ export const authProcedureContracts = [ access: passwordChangeAccess, domain: "auth", errorReasons: ["step_up_required"], - errors: ["CONFLICT", "FORBIDDEN", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], + errors: [ + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], input: passwordChangeInputSchema, inputSchemaId: "auth.changePassword.input", kind: "mutation", diff --git a/greenfield/src/contracts/automationSecurity.ts b/greenfield/src/contracts/automationSecurity.ts index 638cef64a..742d46e85 100644 --- a/greenfield/src/contracts/automationSecurity.ts +++ b/greenfield/src/contracts/automationSecurity.ts @@ -631,6 +631,7 @@ const existingPrincipalMutationErrors = [ "CONFLICT", "FORBIDDEN", "NOT_FOUND", + "SERVICE_UNAVAILABLE", "UNAUTHORIZED", ] as const; const credentialGenerationErrors = [ diff --git a/greenfield/src/server/database/immediateWriteAdmission.ts b/greenfield/src/server/database/immediateWriteAdmission.ts new file mode 100644 index 000000000..3c7c8cf19 --- /dev/null +++ b/greenfield/src/server/database/immediateWriteAdmission.ts @@ -0,0 +1,12 @@ +/** Marks the exact point at which SQLite has admitted an immediate transaction. */ +export type MarkDatabaseTransactionStarted = () => void; + +/** + * Process-owned asynchronous admission for synchronous SQLite write transactions. + * Implementations may retry only before `markTransactionStarted` has been called. + */ +export interface ImmediateDatabaseWriteAdmission { + run( + operation: (markTransactionStarted: MarkDatabaseTransactionStarted) => T + ): Promise; +} diff --git a/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts b/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts index 374dfc1ff..c9bc9567a 100644 --- a/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts +++ b/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts @@ -34,6 +34,7 @@ const nonAdvancingMigrationClockError = "Migration appliedAt must advance beyond stored migration history"; const schemaHistoryMismatchError = "Database schema does not match reviewed migration history"; +const maximumReviewedSchemaObjectCount = 4096; const migrationAppliedAtSchema = timestampMillisecondsSchema( "Migration appliedAt must be valid Date milliseconds" ); @@ -73,19 +74,23 @@ function runMigrationStatements(database: Database, statements: readonly string[ } } -function applicationSchemaObjects(database: Database): SchemaObjectRow[] { +function applicationSchemaObjects( + database: Database, + maximumRows: number +): SchemaObjectRow[] { const rows: unknown = database .query(` SELECT name, sql, tbl_name AS tableName, type FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*' ORDER BY type, name + LIMIT ? `) - .all(); + .all(maximumRows + 1); const validation = v.safeParse(schemaObjectRowsSchema, rows, { abortEarly: true, }); - if (!validation.success) { + if (!validation.success || validation.output.length > maximumRows) { throw new Error(schemaHistoryMismatchError); } return validation.output; @@ -100,7 +105,33 @@ function expectedSchemaObjects( for (const migration of migrations) { runMigrationStatements(expectedDatabase, migration.statements); } - return applicationSchemaObjects(expectedDatabase); + return applicationSchemaObjects( + expectedDatabase, + maximumReviewedSchemaObjectCount + ); + } finally { + expectedDatabase.close(true); + } +} + +function maximumExpectedSchemaObjectCount( + migrations: readonly VerifiedMigration[] +): number { + const expectedDatabase = new Database(":memory:", { strict: true }); + let maximumCount = 0; + + try { + for (const migration of migrations) { + runMigrationStatements(expectedDatabase, migration.statements); + maximumCount = Math.max( + maximumCount, + applicationSchemaObjects( + expectedDatabase, + maximumReviewedSchemaObjectCount + ).length + ); + } + return maximumCount; } finally { expectedDatabase.close(true); } @@ -111,15 +142,18 @@ function assertSchemaMatchesReviewedHistory( migrations: readonly VerifiedMigration[], appliedCount: number ): void { - const actual = applicationSchemaObjects(database); const expected = expectedSchemaObjects(migrations.slice(0, appliedCount)); + const actual = applicationSchemaObjects(database, expected.length); if (JSON.stringify(actual) !== JSON.stringify(expected)) { throw new Error("Database schema does not match reviewed migration history"); } } -function readAppliedMigrations(database: Database): AppliedMigrationRow[] { +function readAppliedMigrations( + database: Database, + maximumRows: number +): AppliedMigrationRow[] { const rows: unknown = database .query(` SELECT @@ -129,12 +163,13 @@ function readAppliedMigrations(database: Database): AppliedMigrationRow[] { release_id AS releaseId FROM schema_migrations ORDER BY id + LIMIT ? `) - .all(); + .all(maximumRows + 1); const validation = v.safeParse(appliedMigrationRowsSchema, rows, { abortEarly: true, }); - if (!validation.success) { + if (!validation.success || validation.output.length > maximumRows) { throw new Error(migrationHistoryMismatchError); } return validation.output; @@ -255,9 +290,10 @@ export function applyVerifiedMigrations( throw new Error("Migration release id must be a full lowercase commit SHA"); } assertConstraintEnforcement(database); + const maximumSchemaObjects = maximumExpectedSchemaObjectCount(migrations); const apply = database.transaction(() => { - const schemaObjects = applicationSchemaObjects(database); + const schemaObjects = applicationSchemaObjects(database, maximumSchemaObjects); const hasMigrationHistory = schemaObjects.some( (object) => object.type === "table" && object.name === "schema_migrations" ); @@ -265,7 +301,9 @@ export function applyVerifiedMigrations( throw new Error("Initialized database is missing migration history"); } - const applied = hasMigrationHistory ? readAppliedMigrations(database) : []; + const applied = hasMigrationHistory + ? readAppliedMigrations(database, migrations.length) + : []; if (hasMigrationHistory && applied.length === 0) { throw new Error("Initialized database has empty migration history"); } @@ -296,7 +334,7 @@ export function applyVerifiedMigrations( ); } - const finalApplied = readAppliedMigrations(database); + const finalApplied = readAppliedMigrations(database, migrations.length); assertAppliedPrefix(finalApplied, migrations); if (finalApplied.length !== migrations.length) { throw new Error("Database migration history is incomplete after application"); @@ -309,3 +347,34 @@ export function applyVerifiedMigrations( return apply.immediate(); } + +/** + * Validates one already-current database without taking SQLite's writer slot. + * The deferred read snapshot keeps history, schema, and integrity evidence coherent + * while WAL writers in the serving generation continue independently. + * @param database Retained native SQLite connection. + * @param migrations Complete checksum-verified canonical migration graph. + * @param checkedAt Application clock used to reject future ledger history. + */ +export function validateVerifiedMigrations( + database: Database, + migrations: readonly VerifiedMigration[], + checkedAt: Date = new Date() +): void { + assertCanonicalVerifiedGraph(migrations); + assertConstraintEnforcement(database); + + const validate = database.transaction(() => { + const applied = readAppliedMigrations(database, migrations.length); + assertAppliedPrefix(applied, migrations); + if (applied.length !== migrations.length) { + throw new Error("Database migration history is incomplete"); + } + planMigrationAppliedAtValues(getTime(checkedAt), 0, latestAppliedAt(applied)); + assertSchemaMatchesReviewedHistory(database, migrations, applied.length); + assertConstraintEnforcement(database); + assertDatabaseIntegrity(database); + }); + + validate.deferred(); +} diff --git a/greenfield/src/server/database/runtime/databaseErrors.ts b/greenfield/src/server/database/runtime/databaseErrors.ts index a8fc9b228..553f8886a 100644 --- a/greenfield/src/server/database/runtime/databaseErrors.ts +++ b/greenfield/src/server/database/runtime/databaseErrors.ts @@ -48,6 +48,21 @@ export class DatabaseRuntimeLockTimeoutError extends Schema.TaggedErrorClass( + "mira-dashboard/server/database/runtime/DatabaseRuntimeWriteAdmissionTimeoutError" +)("DatabaseRuntimeWriteAdmissionTimeoutError", { + message: Schema.String, + timeoutMs: Schema.Number, +}) {} + +/** Expected non-replayed contention after an immediate transaction was admitted. */ +export class DatabaseRuntimeWriteContentionError extends Schema.TaggedErrorClass( + "mira-dashboard/server/database/runtime/DatabaseRuntimeWriteContentionError" +)("DatabaseRuntimeWriteContentionError", { + message: Schema.String, +}) {} + /** Fail-closed signal that a published database needs a verified release snapshot. */ export class DatabaseRuntimeSnapshotRequiredError extends Schema.TaggedErrorClass( "mira-dashboard/server/database/runtime/DatabaseRuntimeSnapshotRequiredError" @@ -82,6 +97,8 @@ const databaseRuntimeErrorSchema = Schema.Union([ DatabaseRuntimePathError, DatabaseRuntimeSnapshotRequiredError, DatabaseRuntimeStartupError, + DatabaseRuntimeWriteAdmissionTimeoutError, + DatabaseRuntimeWriteContentionError, ]); /** Runtime guard for failures crossing the database layer boundary. */ @@ -90,4 +107,24 @@ export const isDatabaseRuntimeError = Schema.is(databaseRuntimeErrorSchema); export type DatabaseRuntimeError = | DatabaseRuntimeAcquisitionError | DatabaseRuntimeCheckpointError - | DatabaseRuntimeCloseError; + | DatabaseRuntimeCloseError + | DatabaseRuntimeWriteAdmissionTimeoutError + | DatabaseRuntimeWriteContentionError; + +export type DatabaseRuntimeWriteUnavailableError = + | DatabaseRuntimeWriteAdmissionTimeoutError + | DatabaseRuntimeWriteContentionError; + +/** + * Runtime guard for temporary write-contention failures safe to map to HTTP 503. + * @param error Unknown failure crossing the database boundary. + * @returns Whether the failure represents bounded write unavailability. + */ +export function isDatabaseRuntimeWriteUnavailableError( + error: unknown +): error is DatabaseRuntimeWriteUnavailableError { + return ( + error instanceof DatabaseRuntimeWriteAdmissionTimeoutError || + error instanceof DatabaseRuntimeWriteContentionError + ); +} diff --git a/greenfield/src/server/database/runtime/databasePath.test.ts b/greenfield/src/server/database/runtime/databasePath.test.ts index 129fb24d2..9f10da081 100644 --- a/greenfield/src/server/database/runtime/databasePath.test.ts +++ b/greenfield/src/server/database/runtime/databasePath.test.ts @@ -1,5 +1,14 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { chmod, link, mkdtemp, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { + chmod, + link, + mkdir, + mkdtemp, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -75,6 +84,17 @@ describe("database runtime path policy", () => { ); }); + test("rejects a replaceable state-directory entry beneath a writable parent", async () => { + const parentDirectory = await privateTemporaryDirectory(); + const stateDirectory = path.join(parentDirectory, "state"); + await mkdir(stateDirectory, { mode: 0o700 }); + await chmod(parentDirectory, 0o777); + + const failure = await rejectionOf(prepareDatabasePath(stateDirectory, true)); + expect(failure).toBeInstanceOf(DatabaseRuntimePathError); + expect(failure).toMatchObject({ reason: "state-directory-invalid" }); + }); + test("rejects symlinked, multiply linked, and permissive database files", async () => { const symlinkDirectory = await privateTemporaryDirectory(); const symlinkTarget = path.join(symlinkDirectory, "target.db"); @@ -156,4 +176,17 @@ describe("database runtime path policy", () => { expect(failure).toBeInstanceOf(DatabaseRuntimePathError); expect(failure).toMatchObject({ reason: "state-directory-invalid" }); }); + + test("detects parent-chain policy drift after preparation", async () => { + const parentDirectory = await privateTemporaryDirectory(); + const stateDirectory = path.join(parentDirectory, "state"); + await mkdir(stateDirectory, { mode: 0o700 }); + const prepared = await prepareDatabasePath(stateDirectory, true); + if (!prepared) throw new Error("Expected a prepared database path"); + await chmod(parentDirectory, 0o777); + + const failure = await rejectionOf(assertDatabasePathStillValid(prepared)); + expect(failure).toBeInstanceOf(DatabaseRuntimePathError); + expect(failure).toMatchObject({ reason: "state-directory-invalid" }); + }); }); diff --git a/greenfield/src/server/database/runtime/databasePath.ts b/greenfield/src/server/database/runtime/databasePath.ts index aa5ff9ee1..98509fef2 100644 --- a/greenfield/src/server/database/runtime/databasePath.ts +++ b/greenfield/src/server/database/runtime/databasePath.ts @@ -73,6 +73,23 @@ function matchesPrivateDatabaseFilePolicy(stat: BigIntStats, userId: number): bo ); } +function matchesProtectedAncestorPolicy( + stat: BigIntStats, + childOwnerId: bigint, + userId: number +): boolean { + const ownerId = stat.uid; + const trustedOwner = ownerId === 0n || ownerId === BigInt(userId); + if (!stat.isDirectory() || stat.isSymbolicLink() || !trustedOwner) return false; + + const writableByAnotherPrincipal = (stat.mode & 0o022n) !== 0n; + if (!writableByAnotherPrincipal) return true; + + const sticky = (stat.mode & 0o1000n) !== 0n; + const protectedChildOwner = childOwnerId === 0n || childOwnerId === BigInt(userId); + return sticky && protectedChildOwner; +} + function identityOf(stat: BigIntStats): DatabasePathIdentity { return Object.freeze({ device: stat.dev, inode: stat.ino }); } @@ -84,6 +101,27 @@ function sameIdentity( return actual.device === expected.device && actual.inode === expected.inode; } +async function assertProtectedStateDirectoryAncestors( + stateDirectory: string, + stateDirectoryStat: BigIntStats, + userId: number +): Promise { + let childPath = stateDirectory; + let childOwnerId = stateDirectoryStat.uid; + + while (true) { + const parentPath = path.dirname(childPath); + if (parentPath === childPath) return; + + const parentStat = await lstat(parentPath, { bigint: true }); + if (!matchesProtectedAncestorPolicy(parentStat, childOwnerId, userId)) { + throw invalidStateDirectory(); + } + childPath = parentPath; + childOwnerId = parentStat.uid; + } +} + async function assertCanonicalPrivateStateDirectory(stateDirectory: string): Promise<{ directory: string; identity: DatabasePathIdentity; @@ -109,6 +147,7 @@ async function assertCanonicalPrivateStateDirectory(stateDirectory: string): Pro ) { throw invalidStateDirectory(); } + await assertProtectedStateDirectoryAncestors(canonicalDirectory, stat, userId); return { directory: canonicalDirectory, identity: identityOf(stat), diff --git a/greenfield/src/server/database/runtime/databasePolicy.ts b/greenfield/src/server/database/runtime/databasePolicy.ts index e98e1807a..d33b67771 100644 --- a/greenfield/src/server/database/runtime/databasePolicy.ts +++ b/greenfield/src/server/database/runtime/databasePolicy.ts @@ -9,6 +9,8 @@ import { DatabaseRuntimePathError, DatabaseRuntimeSnapshotRequiredError, DatabaseRuntimeStartupError, + DatabaseRuntimeWriteAdmissionTimeoutError, + DatabaseRuntimeWriteContentionError, } from "./databaseErrors.ts"; export const databaseRuntimePolicy = Object.freeze({ @@ -18,6 +20,9 @@ export const databaseRuntimePolicy = Object.freeze({ migrationLockTimeoutMs: 5000, synchronousLevel: 2, walAutoCheckpointPages: 1000, + writeAdmissionRetryBaseDelayMs: 10, + writeAdmissionRetryMaximumDelayMs: 250, + writeAdmissionTimeoutMs: 5000, }); export interface DatabaseConnectionDiagnostics { @@ -176,16 +181,14 @@ export function configureDatabaseConnection( const isBusyError = Predicate.isTagged("DatabaseRuntimeBusyError"); -function busyRetrySchedule(): Schedule.Schedule { - const baseDelay = Duration.millis( - databaseRuntimePolicy.migrationLockRetryBaseDelayMs - ); +function busyRetrySchedule( + baseDelayMs: number, + maximumDelayMs: number +): Schedule.Schedule { + const baseDelay = Duration.millis(baseDelayMs); return Schedule.exponential(baseDelay).pipe( Schedule.modifyDelay(({ duration }) => { - const delayMs = Math.min( - Duration.toMillis(duration), - databaseRuntimePolicy.migrationLockRetryMaximumDelayMs - ); + const delayMs = Math.min(Duration.toMillis(duration), maximumDelayMs); return Effect.succeed(Duration.millis(delayMs)); }), Schedule.while(({ input }) => isBusyError(input)) @@ -250,7 +253,12 @@ export function retryDatabaseStartupOperation( }); return attempt.pipe( - Effect.retry({ schedule: busyRetrySchedule() }), + Effect.retry({ + schedule: busyRetrySchedule( + databaseRuntimePolicy.migrationLockRetryBaseDelayMs, + databaseRuntimePolicy.migrationLockRetryMaximumDelayMs + ), + }), Effect.timeoutOrElse({ duration: databaseRuntimePolicy.migrationLockTimeoutMs, orElse: () => @@ -272,6 +280,63 @@ export function retryDatabaseStartupOperation( ); } +/** + * Retries synchronous SQLite write admission with asynchronous Effect delays. + * Busy/locked failures are replayed only before the transaction callback begins; + * a busy completion is surfaced once and every other callback failure is preserved. + * @param operation One immediate-transaction attempt receiving its start marker. + * @returns The write result or a sanitized temporary-contention failure. + */ +export function retryDatabaseWriteOperation( + operation: (markTransactionStarted: () => void) => A +): Effect.Effect { + const attempt: Effect.Effect = Effect.suspend(() => { + let transactionStarted = false; + try { + return Effect.succeed( + operation(() => { + transactionStarted = true; + }) + ); + } catch (error) { + if (!isBusyOrLockedCode(sqliteErrorCode(error))) { + return Effect.fail(error); + } + if (transactionStarted) { + return Effect.fail( + new DatabaseRuntimeWriteContentionError({ + message: "Database write encountered contention after admission", + }) + ); + } + return Effect.fail( + new DatabaseRuntimeBusyError({ + message: "Database write is waiting for admission", + }) + ); + } + }); + const timeoutFailure = () => + new DatabaseRuntimeWriteAdmissionTimeoutError({ + message: "Database write admission timed out", + timeoutMs: databaseRuntimePolicy.writeAdmissionTimeoutMs, + }); + + return attempt.pipe( + Effect.retry({ + schedule: busyRetrySchedule( + databaseRuntimePolicy.writeAdmissionRetryBaseDelayMs, + databaseRuntimePolicy.writeAdmissionRetryMaximumDelayMs + ), + }), + Effect.timeoutOrElse({ + duration: databaseRuntimePolicy.writeAdmissionTimeoutMs, + orElse: () => Effect.fail(timeoutFailure()), + }), + Effect.catchIf(isBusyError, () => Effect.fail(timeoutFailure())) + ); +} + /** * Runs one non-blocking passive checkpoint and validates its bounded diagnostics. * @param database Retained process-owned native connection. diff --git a/greenfield/src/server/database/runtime/databaseService.test.ts b/greenfield/src/server/database/runtime/databaseService.test.ts index 2308b6d4b..3cf27573f 100644 --- a/greenfield/src/server/database/runtime/databaseService.test.ts +++ b/greenfield/src/server/database/runtime/databaseService.test.ts @@ -7,13 +7,18 @@ import path from "node:path"; import { Effect, Fiber, ManagedRuntime } from "effect"; import { TestClock } from "effect/testing"; +import { applyVerifiedMigrations } from "../migrations/applyVerifiedMigrations.ts"; +import { loadVerifiedMigrations } from "../migrations/loadVerifiedMigrations.ts"; import { DatabaseRuntimeLockTimeoutError, DatabaseRuntimeStartupError, + DatabaseRuntimeWriteAdmissionTimeoutError, + DatabaseRuntimeWriteContentionError, } from "./databaseErrors.ts"; import { databaseRuntimePolicy, retryDatabaseStartupOperation, + retryDatabaseWriteOperation, } from "./databasePolicy.ts"; import { DatabaseRuntimeService, @@ -131,6 +136,26 @@ describe("database runtime service", () => { ).toEqual({ count: 1 }); }); + test("validates an already-current database while another process owns the writer slot", async () => { + const stateDirectory = await privateTemporaryDirectory(); + const initialized = await buildRuntime(options(stateDirectory)); + const databasePath = initialized.service.orm.$client.filename; + await initialized.runtime.dispose(); + const competingWriter = new Database(databasePath, { strict: true }); + competingWriter.run("PRAGMA busy_timeout = 0"); + competingWriter.run("BEGIN IMMEDIATE"); + + try { + const validated = await buildRuntime( + options(stateDirectory, "validate-only") + ); + expect(validated.service.diagnostics.appliedMigrations).toBe(0); + } finally { + competingWriter.run("ROLLBACK"); + competingWriter.close(true); + } + }); + test("serializes two concurrent empty-database startups through SQLite admission", async () => { const stateDirectory = await privateTemporaryDirectory(); const [first, second] = await Promise.all([ @@ -156,6 +181,49 @@ describe("database runtime service", () => { ); }); + test("validate-only waits for a concurrent empty-database initializer", async () => { + const stateDirectory = await privateTemporaryDirectory(); + const databasePath = path.join(stateDirectory, "mira-dashboard.db"); + await writeFile(databasePath, "", { mode: 0o600 }); + const migrations = await loadVerifiedMigrations({ + directory: migrationsDirectory, + }); + const initializer = new Database(databasePath, { strict: true }); + initializer.run("PRAGMA foreign_keys = ON"); + initializer.run("PRAGMA ignore_check_constraints = OFF"); + initializer.run("PRAGMA journal_mode = WAL"); + initializer.run("PRAGMA busy_timeout = 0"); + initializer.run("BEGIN IMMEDIATE"); + let validationSettled = false; + + try { + const validation = buildRuntime(options(stateDirectory, "validate-only")); + void validation.then( + () => { + validationSettled = true; + return true; + }, + () => { + validationSettled = true; + return false; + } + ); + await Bun.sleep(25); + expect(validationSettled).toBeFalse(); + applyVerifiedMigrations(initializer, migrations, { releaseId }); + await Bun.sleep(10); + expect(validationSettled).toBeFalse(); + initializer.run("COMMIT"); + + const validated = await validation; + expect(validated.service.diagnostics.appliedMigrations).toBe(0); + expect(validated.service.diagnostics.startupMode).toBe("validate-only"); + } finally { + if (initializer.inTransaction) initializer.run("ROLLBACK"); + initializer.close(true); + } + }); + test("validate-only rejects an absent database without creating it", async () => { const stateDirectory = await privateTemporaryDirectory(); const runtime = ManagedRuntime.make( @@ -238,6 +306,45 @@ describe("database runtime service", () => { second.close(true); }); + test("retries real cross-connection write admission before entering the callback", async () => { + const stateDirectory = await privateTemporaryDirectory(); + const { runtime, service } = await buildRuntime(options(stateDirectory)); + const competingWriter = new Database(service.orm.$client.filename, { + strict: true, + }); + competingWriter.run("PRAGMA busy_timeout = 0"); + competingWriter.run("BEGIN IMMEDIATE"); + const firstAdmissionAttempt = Promise.withResolvers(); + let admissionAttempts = 0; + let callbackCalls = 0; + const admittedWrite = runtime.runPromise( + service.runImmediateWrite((markTransactionStarted) => { + admissionAttempts += 1; + firstAdmissionAttempt.resolve(); + return service.orm.$client + .transaction(() => { + markTransactionStarted(); + callbackCalls += 1; + return "committed" as const; + }) + .immediate(); + }) + ); + + try { + await firstAdmissionAttempt.promise; + expect(admissionAttempts).toBeGreaterThanOrEqual(1); + expect(callbackCalls).toBe(0); + competingWriter.run("ROLLBACK"); + + expect(await admittedWrite).toBe("committed"); + expect(callbackCalls).toBe(1); + } finally { + if (competingWriter.inTransaction) competingWriter.run("ROLLBACK"); + competingWriter.close(true); + } + }); + test("checkpoints before strict close and makes the retained handle unusable", async () => { const stateDirectory = await privateTemporaryDirectory(); const { runtime, service } = await buildRuntime(options(stateDirectory)); @@ -298,3 +405,105 @@ describe("database startup retry policy", () => { expect(attempts).toBe(attemptsBeforeInterruption); }); }); + +describe("database write admission policy", () => { + test("retries only pre-callback busy admission and runs the callback once", async () => { + let attempts = 0; + let callbackCalls = 0; + + const value = await Effect.runPromise( + retryDatabaseWriteOperation((markTransactionStarted) => { + attempts += 1; + if (attempts === 1) { + throw Object.assign(new Error("not exposed"), { + code: "SQLITE_BUSY", + }); + } + markTransactionStarted(); + callbackCalls += 1; + return 42; + }) + ); + + expect(value).toBe(42); + expect(attempts).toBe(2); + expect(callbackCalls).toBe(1); + }); + + test("times out persistent pre-callback contention with the Effect clock", async () => { + let attempts = 0; + const program = Effect.gen(function* () { + yield* TestClock.setTime(0); + const fiber = yield* retryDatabaseWriteOperation(() => { + attempts += 1; + throw Object.assign(new Error("not exposed"), { + code: "SQLITE_LOCKED_SHAREDCACHE", + }); + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust(databaseRuntimePolicy.writeAdmissionTimeoutMs); + return yield* Fiber.join(fiber).pipe(Effect.flip); + }).pipe(Effect.provide(TestClock.layer())); + + const failure = await Effect.runPromise(program); + expect(failure).toBeInstanceOf(DatabaseRuntimeWriteAdmissionTimeoutError); + expect(attempts).toBeGreaterThan(1); + }); + + test("interrupts queued write admission without running another attempt", async () => { + let attempts = 0; + const program = Effect.gen(function* () { + yield* TestClock.setTime(0); + const fiber = yield* retryDatabaseWriteOperation(() => { + attempts += 1; + throw Object.assign(new Error("not exposed"), { + code: "SQLITE_BUSY", + }); + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + const attemptsBeforeInterruption = attempts; + yield* Fiber.interrupt(fiber); + yield* TestClock.adjust(databaseRuntimePolicy.writeAdmissionTimeoutMs * 2); + return attemptsBeforeInterruption; + }).pipe(Effect.provide(TestClock.layer())); + + const attemptsBeforeInterruption = await Effect.runPromise(program); + expect(attemptsBeforeInterruption).toBeGreaterThanOrEqual(1); + expect(attempts).toBe(attemptsBeforeInterruption); + }); + + test("never replays contention after the transaction callback begins", async () => { + let attempts = 0; + const failure = await rejectionOf( + Effect.runPromise( + retryDatabaseWriteOperation((markTransactionStarted) => { + attempts += 1; + markTransactionStarted(); + throw Object.assign(new Error("not exposed"), { + code: "SQLITE_BUSY_SNAPSHOT", + }); + }) + ) + ); + + expect(failure).toBeInstanceOf(DatabaseRuntimeWriteContentionError); + expect(attempts).toBe(1); + }); + + test("preserves non-contention callback failures without replay", async () => { + const sentinel = new Error("domain failure"); + let attempts = 0; + const failure = await rejectionOf( + Effect.runPromise( + retryDatabaseWriteOperation((markTransactionStarted) => { + attempts += 1; + markTransactionStarted(); + throw sentinel; + }) + ) + ); + + expect(failure).toBe(sentinel); + expect(attempts).toBe(1); + }); +}); diff --git a/greenfield/src/server/database/runtime/databaseService.ts b/greenfield/src/server/database/runtime/databaseService.ts index 08a7d07cd..5ae53a41f 100644 --- a/greenfield/src/server/database/runtime/databaseService.ts +++ b/greenfield/src/server/database/runtime/databaseService.ts @@ -19,6 +19,7 @@ import { import { checkpointDatabasePassive, type DatabaseCheckpointDiagnostics, + retryDatabaseWriteOperation, } from "./databasePolicy.ts"; import { initializeDatabaseRuntime, @@ -53,6 +54,9 @@ interface DatabaseRuntimeServiceShape { >; readonly diagnostics: DatabaseRuntimeDiagnostics; readonly orm: RuntimeOwnedDatabase; + readonly runImmediateWrite: ( + operation: (markTransactionStarted: () => void) => A + ) => Effect.Effect; } /** Process-scoped, migration-verified SQLite and Drizzle ownership boundary. */ @@ -207,6 +211,7 @@ function acquireDatabaseRuntime(unverifiedOptions: DatabaseRuntimeLayerOptions) checkpointPassive: checkpointDatabasePassive(database), diagnostics, orm, + runImmediateWrite: retryDatabaseWriteOperation, }); checkpointOnRelease = true; return service; diff --git a/greenfield/src/server/database/runtime/databaseStartup.ts b/greenfield/src/server/database/runtime/databaseStartup.ts index 9676cfb7d..8b41b3413 100644 --- a/greenfield/src/server/database/runtime/databaseStartup.ts +++ b/greenfield/src/server/database/runtime/databaseStartup.ts @@ -7,7 +7,10 @@ import { fullCommitShaSchema, lowercaseSha256Schema, } from "../../../shared/validation.ts"; -import { applyVerifiedMigrations } from "../migrations/applyVerifiedMigrations.ts"; +import { + applyVerifiedMigrations, + validateVerifiedMigrations, +} from "../migrations/applyVerifiedMigrations.ts"; import { loadVerifiedMigrations, type VerifiedMigration, @@ -68,8 +71,12 @@ const migrationHistoryRowSchema = v.strictObject({ id: migrationIdSchema("Database migration history is invalid"), }); const migrationHistoryRowsSchema = v.array(migrationHistoryRowSchema); -const schemaObjectRowSchema = v.strictObject({ name: v.string(), type: v.string() }); -const schemaObjectRowsSchema = v.array(schemaObjectRowSchema); +const schemaObjectPresenceRowSchema = v.nullable( + v.strictObject({ present: v.literal(1) }) +); +const migrationTableRowSchema = v.nullable( + v.strictObject({ name: v.literal("schema_migrations"), type: v.literal("table") }) +); type MigrationHistoryRow = v.InferOutput; @@ -125,34 +132,56 @@ export function loadDatabaseRuntimeMigrations( }); } -function readSchemaObjects( - database: Database -): readonly { name: string; type: string }[] { - const rows: unknown = database +function hasApplicationSchemaObjects(database: Database): boolean { + const row: unknown = database .query(` - SELECT name, type + SELECT 1 AS present FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*' - ORDER BY type, name + LIMIT 1 `) - .all(); - const validation = v.safeParse(schemaObjectRowsSchema, rows, { abortEarly: true }); + .get(); + const validation = v.safeParse(schemaObjectPresenceRowSchema, row, { + abortEarly: true, + }); if (!validation.success) throw invalidHistory(); - return validation.output; + return validation.output !== null; +} + +function hasMigrationHistoryTable(database: Database): boolean { + const row: unknown = database + .query(` + SELECT name, type + FROM sqlite_schema + WHERE name = 'schema_migrations' + LIMIT 1 + `) + .get(); + const validation = v.safeParse(migrationTableRowSchema, row, { + abortEarly: true, + }); + if (!validation.success) throw invalidHistory(); + return validation.output !== null; } -function readMigrationHistory(database: Database): readonly MigrationHistoryRow[] { +function readMigrationHistory( + database: Database, + maximumRows: number +): readonly MigrationHistoryRow[] { const rows: unknown = database .query(` SELECT checksum, id FROM schema_migrations ORDER BY id + LIMIT ? `) - .all(); + .all(maximumRows + 1); const validation = v.safeParse(migrationHistoryRowsSchema, rows, { abortEarly: true, }); - if (!validation.success) throw invalidHistory(); + if (!validation.success || validation.output.length > maximumRows) { + throw invalidHistory(); + } return validation.output; } @@ -174,17 +203,10 @@ function inspectMigrationState( database: Database, migrations: readonly VerifiedMigration[] ): "current" | "empty" | "pending" { - const schemaObjects = readSchemaObjects(database); - if (schemaObjects.length === 0) return "empty"; - if ( - !schemaObjects.some( - (object) => object.type === "table" && object.name === "schema_migrations" - ) - ) { - throw invalidHistory(); - } + if (!hasApplicationSchemaObjects(database)) return "empty"; + if (!hasMigrationHistoryTable(database)) throw invalidHistory(); - const history = readMigrationHistory(database); + const history = readMigrationHistory(database, migrations.length); if ( history.length === 0 || history.length > migrations.length || @@ -217,16 +239,16 @@ function startOrValidateDatabase( }); } - const appliedMigrations = applyVerifiedMigrations(database, migrations, { - releaseId: options.releaseId, - }); - if (state === "current" && appliedMigrations !== 0) throw invalidHistory(); - if ( - state === "empty" && - appliedMigrations !== 0 && - appliedMigrations !== migrations.length - ) { - throw invalidHistory(); + let appliedMigrations = 0; + if (state === "current") { + validateVerifiedMigrations(database, migrations); + } else { + appliedMigrations = applyVerifiedMigrations(database, migrations, { + releaseId: options.releaseId, + }); + if (appliedMigrations !== 0 && appliedMigrations !== migrations.length) { + throw invalidHistory(); + } } return Object.freeze({ diff --git a/greenfield/src/server/domains/monitoring/repository.ts b/greenfield/src/server/domains/monitoring/repository.ts index 7340db373..e633b0a2f 100644 --- a/greenfield/src/server/domains/monitoring/repository.ts +++ b/greenfield/src/server/domains/monitoring/repository.ts @@ -2,6 +2,7 @@ import { and, desc, eq, inArray, isNotNull, isNull, or } from "drizzle-orm"; import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; import * as v from "valibot"; +import type { ImmediateDatabaseWriteAdmission } from "../../database/immediateWriteAdmission.ts"; import { incidentObservations } from "../../database/schema/incidentObservations.ts"; import { incidents } from "../../database/schema/incidents.ts"; import { monitorRuns } from "../../database/schema/monitorRuns.ts"; @@ -77,7 +78,7 @@ export interface MonitoringUnitOfWork { export interface MonitoringRepository { withImmediateTransaction( callback: (unit: MonitoringUnitOfWork) => SynchronousResult - ): T; + ): Promise; } function requiredRow(row: T | undefined, operation: string): T { @@ -235,10 +236,12 @@ class DrizzleMonitoringUnitOfWork implements MonitoringUnitOfWork { /** * Creates the SQLite-backed monitoring repository at the composition boundary. * @param database Typed Drizzle client backed by one Bun SQLite connection. - * @returns Repository that owns immediate monitoring transactions and SQL. + * @param writeAdmission Process-owned bounded immediate-write admission. + * @returns Repository that owns admitted async monitoring writes and SQL. */ export function createMonitoringRepository( - database: SQLiteBunDatabase + database: SQLiteBunDatabase, + writeAdmission: ImmediateDatabaseWriteAdmission ): MonitoringRepository { // SQLiteBunDatabase inherits a conditional async-driver signature even though the // concrete Bun session is synchronous. Preserve the public no-Promise callback type @@ -251,11 +254,15 @@ export function createMonitoringRepository( return { withImmediateTransaction( callback: (unit: MonitoringUnitOfWork) => SynchronousResult - ): T { - return runTransaction( - (transaction): T => - callback(new DrizzleMonitoringUnitOfWork(transaction)) as T, - { behavior: "immediate" } + ): Promise { + return writeAdmission.run((markTransactionStarted) => + runTransaction( + (transaction): T => { + markTransactionStarted(); + return callback(new DrizzleMonitoringUnitOfWork(transaction)); + }, + { behavior: "immediate" } + ) ); }, }; diff --git a/greenfield/src/server/domains/monitoring/service.ts b/greenfield/src/server/domains/monitoring/service.ts index 910810680..a21d96e01 100644 --- a/greenfield/src/server/domains/monitoring/service.ts +++ b/greenfield/src/server/domains/monitoring/service.ts @@ -7,13 +7,17 @@ import { minutesToMilliseconds, toDate, } from "date-fns"; -import { Context, Effect, Layer, Schema } from "effect"; +import { Context, Data, Effect, Layer, Schema } from "effect"; import { timestampMillisecondsSchema } from "../../../shared/dateTime.ts"; import { parseSchemaWithRangeError, positiveSafeIntegerSchema, } from "../../../shared/validation.ts"; +import { + isDatabaseRuntimeWriteUnavailableError, + type DatabaseRuntimeWriteUnavailableError, +} from "../../database/runtime/databaseErrors.ts"; import { MonitoringSnapshotValidationError, normalizeMonitoringSnapshot, @@ -66,6 +70,7 @@ export interface MonitoringServiceDependencies { } export type MonitoringSubmissionError = + | DatabaseRuntimeWriteUnavailableError | MonitoringRunConflictError | MonitoringSnapshotValidationError; @@ -88,6 +93,20 @@ export class MonitoringRunConflictError extends TaggedErrorClass {} + +function isMonitoringSubmissionError(error: unknown): error is MonitoringSubmissionError { + return ( + error instanceof MonitoringSnapshotValidationError || + error instanceof MonitoringRunConflictError || + isDatabaseRuntimeWriteUnavailableError(error) + ); +} + function emptyCounts(): MutableSubmissionCounts { return { createdIncidents: 0, @@ -125,7 +144,9 @@ export function createMonitoringService( dependencies.realtimeRetentionMs ?? defaultRealtimeRetentionMilliseconds ); - const commitCompleteSnapshot = (input: unknown): MonitoringSubmissionResult => { + const commitCompleteSnapshot = async ( + input: unknown + ): Promise => { const normalized = normalizeMonitoringSnapshot(input); const receivedAtMs = parseSchemaWithRangeError(clockMillisecondsSchema, nowMs()); if ( @@ -140,98 +161,102 @@ export function createMonitoringService( const outboxOccurredAt = toDate(receivedAtMs); const expiresAt = addMilliseconds(outboxOccurredAt, realtimeRetentionMs); parseSchemaWithRangeError(realtimeExpiryMillisecondsSchema, getTime(expiresAt)); - const committed = dependencies.repository.withImmediateTransaction((unit) => { - const existingRun = unit.findRun(normalized.snapshot.runId); - if (existingRun !== undefined) { - if (existingRun.submissionSha256 !== normalized.submissionSha256) { - throw new MonitoringRunConflictError({ - message: `Monitoring run ${normalized.snapshot.runId} was already submitted with different content`, - runId: normalized.snapshot.runId, - }); + const committed = await dependencies.repository.withImmediateTransaction( + (unit) => { + const existingRun = unit.findRun(normalized.snapshot.runId); + if (existingRun !== undefined) { + if (existingRun.submissionSha256 !== normalized.submissionSha256) { + throw new MonitoringRunConflictError({ + message: `Monitoring run ${normalized.snapshot.runId} was already submitted with different content`, + runId: normalized.snapshot.runId, + }); + } + return { + ...emptyCounts(), + duplicateRunId: true, + reportId: existingRun.reportId, + runId: existingRun.id, + status: "duplicate" as const, + }; } - return { - ...emptyCounts(), - duplicateRunId: true, - reportId: existingRun.reportId, - runId: existingRun.id, - status: "duplicate" as const, - }; - } - const counts = emptyCounts(); - const latestRun = unit.findLatestCompleteRun(normalized.snapshot.monitorKey); - const reportId = generateId(); - // The resource-scoped maintenance job owns bounded expiry deletion; - // request transactions only stamp the durable retention boundary. + const counts = emptyCounts(); + const latestRun = unit.findLatestCompleteRun( + normalized.snapshot.monitorKey + ); + const reportId = generateId(); + // The resource-scoped maintenance job owns bounded expiry deletion; + // request transactions only stamp the durable retention boundary. - unit.insertReport({ - bodyMarkdown: normalized.snapshot.report.bodyMarkdown, - id: reportId, - kind: normalized.snapshot.report.kind, - metadataJson: serializeMonitoringJsonObject( - normalized.snapshot.report.metadata - ), - occurredAt: snapshotOccurredAt, - source: normalized.snapshot.report.source, - sourceJobId: normalized.snapshot.report.sourceJobId, - title: normalized.snapshot.report.title, - }); - unit.insertMonitorRun({ - completedAt: snapshotOccurredAt, - completeSnapshot: true, - id: normalized.snapshot.runId, - monitorKey: normalized.snapshot.monitorKey, - reportId, - startedAt: toDate(normalized.snapshot.startedAtMs), - state: "succeeded", - submissionSha256: normalized.submissionSha256, - }); - insertRealtimeEvent(unit, counts, { - entityId: reportId, - entityType: "report", - expiresAt, - occurredAt: outboxOccurredAt, - operation: "created", - topic: monitoringRealtimeTopics.reports, - }); + unit.insertReport({ + bodyMarkdown: normalized.snapshot.report.bodyMarkdown, + id: reportId, + kind: normalized.snapshot.report.kind, + metadataJson: serializeMonitoringJsonObject( + normalized.snapshot.report.metadata + ), + occurredAt: snapshotOccurredAt, + source: normalized.snapshot.report.source, + sourceJobId: normalized.snapshot.report.sourceJobId, + title: normalized.snapshot.report.title, + }); + unit.insertMonitorRun({ + completedAt: snapshotOccurredAt, + completeSnapshot: true, + id: normalized.snapshot.runId, + monitorKey: normalized.snapshot.monitorKey, + reportId, + startedAt: toDate(normalized.snapshot.startedAtMs), + state: "succeeded", + submissionSha256: normalized.submissionSha256, + }); + insertRealtimeEvent(unit, counts, { + entityId: reportId, + entityType: "report", + expiresAt, + occurredAt: outboxOccurredAt, + operation: "created", + topic: monitoringRealtimeTopics.reports, + }); + + if ( + latestRun?.completedAt !== undefined && + latestRun.completedAt !== null && + !isNewerThanLatestRun( + normalized.snapshot.completedAtMs, + normalized.snapshot.runId, + latestRun.completedAt, + latestRun.id + ) + ) { + return { + ...counts, + duplicateRunId: false, + reportId, + runId: normalized.snapshot.runId, + status: "stale" as const, + }; + } + + applyMonitoringSnapshotLifecycle({ + counts, + expiresAt, + generateId, + outboxOccurredAt, + snapshot: normalized.snapshot, + snapshotOccurredAt, + unit, + }); - if ( - latestRun?.completedAt !== undefined && - latestRun.completedAt !== null && - !isNewerThanLatestRun( - normalized.snapshot.completedAtMs, - normalized.snapshot.runId, - latestRun.completedAt, - latestRun.id - ) - ) { return { ...counts, duplicateRunId: false, reportId, runId: normalized.snapshot.runId, - status: "stale" as const, + status: "accepted" as const, }; } - - applyMonitoringSnapshotLifecycle({ - counts, - expiresAt, - generateId, - outboxOccurredAt, - snapshot: normalized.snapshot, - snapshotOccurredAt, - unit, - }); - - return { - ...counts, - duplicateRunId: false, - reportId, - runId: normalized.snapshot.runId, - status: "accepted" as const, - }; - }); + ); return committed; }; @@ -240,19 +265,17 @@ export function createMonitoringService( function* ( input: unknown ): Effect.fn.Return { - const committed = yield* Effect.suspend(() => { - try { - return Effect.succeed(commitCompleteSnapshot(input)); - } catch (error) { - if ( - error instanceof MonitoringSnapshotValidationError || - error instanceof MonitoringRunConflictError - ) { - return Effect.fail(error); - } - return Effect.die(error); - } - }); + const committed = yield* Effect.tryPromise({ + catch: (error) => + isMonitoringSubmissionError(error) + ? error + : new MonitoringUnexpectedSubmissionError({ cause: error }), + try: () => commitCompleteSnapshot(input), + }).pipe( + Effect.catchTag("MonitoringUnexpectedSubmissionError", (error) => + Effect.die(error.cause) + ) + ); if (committed.realtimeEvents > 0 && dependencies.wakeEventPump) { yield* Effect.sync(() => { @@ -271,7 +294,7 @@ export function createMonitoringService( } /** - * Provides the monitoring application service from its synchronous repository boundary. + * Provides the monitoring application service from its asynchronous write boundary. * @param dependencies Repository plus replaceable clock, identity, and wakeup boundaries. * @returns A layer containing one monitoring application service. */ diff --git a/greenfield/src/server/domains/monitoring/serviceBoundary.test.ts b/greenfield/src/server/domains/monitoring/serviceBoundary.test.ts index b072a309c..75ee15e93 100644 --- a/greenfield/src/server/domains/monitoring/serviceBoundary.test.ts +++ b/greenfield/src/server/domains/monitoring/serviceBoundary.test.ts @@ -5,6 +5,7 @@ import { Cause, Effect, Exit } from "effect"; import { incidents } from "../../database/schema/incidents.ts"; import { notifications } from "../../database/schema/notifications.ts"; +import { testImmediateDatabaseWriteAdmission } from "../../test/support/databaseWriteAdmission.ts"; import { createMonitoringRepository, type MonitoringRepository } from "./repository.ts"; import { createMonitoringService, @@ -29,7 +30,7 @@ describe("monitoring service", () => { const service = serviceFor(database); try { - submitSnapshot(service, snapshot({ completedAtMs: 2000, run: 401 })); + await submitSnapshot(service, snapshot({ completedAtMs: 2000, run: 401 })); const before = allRowCounts(database); const existingNotificationId = database.orm .select({ id: notifications.id }) @@ -42,16 +43,13 @@ describe("monitoring service", () => { generateId: () => generatedIds.shift()!, }); - expect(() => - submitSnapshot( - failingService, - snapshot({ - completedAtMs: 3000, - problems: [problem("backup")], - run: 402, - }) - ) - ).toThrow(); + const failingSnapshot = snapshot({ + completedAtMs: 3000, + problems: [problem("backup")], + run: 402, + }); + const submission = submitSnapshot(failingService, failingSnapshot); + expect(submission).rejects.toThrow(); expect(allRowCounts(database)).toEqual(before); expect(database.orm.select().from(incidents).get()).toMatchObject({ occurrenceCount: 1, @@ -62,7 +60,7 @@ describe("monitoring service", () => { } }); - test("rejects malformed snapshots before entering the repository", () => { + test("rejects malformed snapshots before entering the repository", async () => { let repositoryEntries = 0; const repository: MonitoringRepository = { withImmediateTransaction() { @@ -72,7 +70,7 @@ describe("monitoring service", () => { }; const service = createMonitoringService({ repository }); - const failure = submitSnapshotFailure(service, { + const failure = await submitSnapshotFailure(service, { ...snapshot({ completedAtMs: 2000, run: 501 }), problems: [problem("filesystem"), problem("filesystem")], }); @@ -82,7 +80,7 @@ describe("monitoring service", () => { expect(repositoryEntries).toBe(0); }); - test("rejects a future watermark before entering the repository", () => { + test("rejects a future watermark before entering the repository", async () => { let repositoryEntries = 0; const repository: MonitoringRepository = { withImmediateTransaction() { @@ -95,7 +93,7 @@ describe("monitoring service", () => { repository, }); - const failure = submitSnapshotFailure( + const failure = await submitSnapshotFailure( service, snapshot({ completedAtMs: 310_001, run: 502 }) ); @@ -115,10 +113,11 @@ describe("monitoring service", () => { }); try { - expect( - submitSnapshot(service, snapshot({ completedAtMs: 2000, run: 601 })) - .status - ).toBe("accepted"); + const result = await submitSnapshot( + service, + snapshot({ completedAtMs: 2000, run: 601 }) + ); + expect(result.status).toBe("accepted"); expect(allRowCounts(database)).toMatchObject({ incidents: 1, realtimeEvents: 3, @@ -147,7 +146,7 @@ describe("monitoring service", () => { expect(repositoryEntries).toBe(0); }); - test("rejects a realtime expiry outside the Date range before repository work", () => { + test("rejects a realtime expiry outside the Date range before repository work", async () => { let repositoryEntries = 0; const repository: MonitoringRepository = { withImmediateTransaction() { @@ -161,7 +160,7 @@ describe("monitoring service", () => { repository, }); - const exit = Effect.runSyncExit( + const exit = await Effect.runPromiseExit( service.submitCompleteSnapshot(snapshot({ completedAtMs: 2000, run: 602 })) ); @@ -176,7 +175,7 @@ describe("monitoring service", () => { expect(repositoryEntries).toBe(0); }); - test("keeps unknown repository failures in the defect channel", () => { + test("keeps unknown repository failures in the defect channel", async () => { const repositoryFailure = new Error("repository unavailable"); const repository: MonitoringRepository = { withImmediateTransaction() { @@ -188,7 +187,7 @@ describe("monitoring service", () => { repository, }); - const exit = Effect.runSyncExit( + const exit = await Effect.runPromiseExit( service.submitCompleteSnapshot(snapshot({ completedAtMs: 2000, run: 701 })) ); @@ -206,7 +205,10 @@ describe("monitoring service", () => { generateId: () => uuid(70_000), nowMs: () => 10_000, realtimeRetentionMs: hoursToMilliseconds(24), - repository: createMonitoringRepository(database.orm), + repository: createMonitoringRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ), }); try { @@ -216,7 +218,7 @@ describe("monitoring service", () => { snapshot({ completedAtMs: 2000, run: 702 }) ); }); - const result = Effect.runSync(Effect.provide(program, layer)); + const result = await Effect.runPromise(Effect.provide(program, layer)); expect(result.status).toBe("accepted"); expect(allRowCounts(database)).toMatchObject({ diff --git a/greenfield/src/server/domains/monitoring/serviceLifecycle.test.ts b/greenfield/src/server/domains/monitoring/serviceLifecycle.test.ts index d0090f883..445917be1 100644 --- a/greenfield/src/server/domains/monitoring/serviceLifecycle.test.ts +++ b/greenfield/src/server/domains/monitoring/serviceLifecycle.test.ts @@ -31,7 +31,7 @@ describe("monitoring service", () => { try { expect( - submitSnapshot(service, snapshot({ completedAtMs: 2000, run: 101 })) + await submitSnapshot(service, snapshot({ completedAtMs: 2000, run: 101 })) ).toMatchObject({ createdIncidents: 1, observedIncidents: 1, @@ -58,7 +58,7 @@ describe("monitoring service", () => { title: "Root filesystem almost full", }); expect( - submitSnapshot( + await submitSnapshot( service, snapshot({ completedAtMs: 3000, @@ -92,7 +92,7 @@ describe("monitoring service", () => { const filesystemProblem = problem("filesystem"); const backupProblem = problem("backup"); expect( - submitSnapshot( + await submitSnapshot( service, snapshot({ completedAtMs: 4000, @@ -107,7 +107,7 @@ describe("monitoring service", () => { }); expect( - submitSnapshot( + await submitSnapshot( service, snapshot({ completedAtMs: 5000, @@ -138,7 +138,7 @@ describe("monitoring service", () => { ).toEqual(manuallyReadAt); expect( - submitSnapshot( + await submitSnapshot( service, snapshot({ completedAtMs: 6000, @@ -153,7 +153,7 @@ describe("monitoring service", () => { }); expect( - submitSnapshot( + await submitSnapshot( service, snapshot({ completedAtMs: 7000, @@ -238,11 +238,11 @@ describe("monitoring service", () => { const service = serviceFor(database); try { - submitSnapshot( + await submitSnapshot( service, snapshot({ completedAtMs: 2000, monitorKey: "stream:a", run: 201 }) ); - submitSnapshot( + await submitSnapshot( service, snapshot({ completedAtMs: 2100, monitorKey: "stream:b", run: 202 }) ); diff --git a/greenfield/src/server/domains/monitoring/serviceOrdering.test.ts b/greenfield/src/server/domains/monitoring/serviceOrdering.test.ts index 62c2861ce..20a6fd4f4 100644 --- a/greenfield/src/server/domains/monitoring/serviceOrdering.test.ts +++ b/greenfield/src/server/domains/monitoring/serviceOrdering.test.ts @@ -24,9 +24,9 @@ describe("monitoring service", () => { const first = snapshot({ completedAtMs: 2000, run: 310 }); try { - const accepted = submitSnapshot(service, first); + const accepted = await submitSnapshot(service, first); const afterAccepted = allRowCounts(database); - expect(submitSnapshot(service, first)).toMatchObject({ + expect(await submitSnapshot(service, first)).toMatchObject({ duplicateRunId: true, realtimeEvents: 0, reportId: accepted.reportId, @@ -35,7 +35,7 @@ describe("monitoring service", () => { expect(allRowCounts(database)).toEqual(afterAccepted); expect(wakeups).toBe(1); - const conflict = submitSnapshotFailure(service, { + const conflict = await submitSnapshotFailure(service, { ...first, report: { ...first.report, bodyMarkdown: "# Corrected" }, }); @@ -48,7 +48,7 @@ describe("monitoring service", () => { expect(wakeups).toBe(1); expect( - submitSnapshot( + await submitSnapshot( service, snapshot({ completedAtMs: 1500, problems: [], run: 309 }) ) @@ -69,7 +69,7 @@ describe("monitoring service", () => { }); expect( - submitSnapshot( + await submitSnapshot( service, snapshot({ completedAtMs: 2000, problems: [], run: 311 }) ) @@ -89,10 +89,10 @@ describe("monitoring service", () => { const service = serviceFor(database); try { - submitSnapshot(service, snapshot({ completedAtMs: 2000, run: 710 })); + await submitSnapshot(service, snapshot({ completedAtMs: 2000, run: 710 })); const backupProblem = problem("backup"); - const staleResult = submitSnapshot( + const staleResult = await submitSnapshot( service, snapshot({ completedAtMs: 2000, @@ -113,7 +113,7 @@ describe("monitoring service", () => { }); expect( - submitSnapshot( + await submitSnapshot( service, snapshot({ completedAtMs: 2000, problems: [], run: 711 }) ) diff --git a/greenfield/src/server/domains/monitoring/testSupport/monitoringService.ts b/greenfield/src/server/domains/monitoring/testSupport/monitoringService.ts index f40bdd262..9b32492c7 100644 --- a/greenfield/src/server/domains/monitoring/testSupport/monitoringService.ts +++ b/greenfield/src/server/domains/monitoring/testSupport/monitoringService.ts @@ -1,6 +1,7 @@ import { getTime, hoursToMilliseconds, subMilliseconds } from "date-fns"; import { Effect } from "effect"; +import { testImmediateDatabaseWriteAdmission } from "../../../test/support/databaseWriteAdmission.ts"; import type { openFreshMigratedDatabase } from "../../../test/support/freshDatabase.ts"; import { createMonitoringRepository } from "../repository.ts"; import { createMonitoringService, type MonitoringSubmissionError } from "../service.ts"; @@ -95,7 +96,10 @@ export function serviceFor( generateId: overrides.generateId ?? idGenerator(), nowMs: () => eventNowMs, realtimeRetentionMs: oneDayMs, - repository: createMonitoringRepository(database.orm), + repository: createMonitoringRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ), wakeEventPump: overrides.wakeEventPump, }); } @@ -103,14 +107,14 @@ export function serviceFor( export type TestMonitoringService = ReturnType; export function submitSnapshot(service: TestMonitoringService, input: unknown) { - return Effect.runSync(service.submitCompleteSnapshot(input)); + return Effect.runPromise(service.submitCompleteSnapshot(input)); } export function submitSnapshotFailure( service: TestMonitoringService, input: unknown -): MonitoringSubmissionError { - return Effect.runSync(Effect.flip(service.submitCompleteSnapshot(input))); +): Promise { + return Effect.runPromise(Effect.flip(service.submitCompleteSnapshot(input))); } export { openFreshMigratedDatabase } from "../../../test/support/freshDatabase.ts"; diff --git a/greenfield/src/server/domains/security/authPublicRoutes.ts b/greenfield/src/server/domains/security/authPublicRoutes.ts index 2d8c70c09..b99f72479 100644 --- a/greenfield/src/server/domains/security/authPublicRoutes.ts +++ b/greenfield/src/server/domains/security/authPublicRoutes.ts @@ -134,13 +134,13 @@ export const authPublicRoutes = { logout: publicProcedure .input(emptyInputSchema) .output(okResultSchema) - .mutation(({ ctx }) => { - ctx.authenticationLifecycle.logout( + .mutation(async ({ ctx }) => { + await ctx.authenticationLifecycle.logout( currentSessionIdentity(ctx), authenticationRequestMetadata(ctx, undefined) ); if (ctx.pendingLoginCredential.kind === "present") { - ctx.mfaLoginLifecycle.revokePendingLogin( + await ctx.mfaLoginLifecycle.revokePendingLogin( ctx.pendingLoginCredential.token, authenticationRequestMetadata(ctx, undefined) ); diff --git a/greenfield/src/server/domains/security/authSessionRoutes.ts b/greenfield/src/server/domains/security/authSessionRoutes.ts index 8abdffd05..60f8857e3 100644 --- a/greenfield/src/server/domains/security/authSessionRoutes.ts +++ b/greenfield/src/server/domains/security/authSessionRoutes.ts @@ -73,8 +73,8 @@ export const authSessionRoutes = { revokeSession: sessionProcedure .input(sessionRevokeInputSchema) .output(authSessionRevokeResultSchema) - .mutation(({ ctx, input }) => { - const result = ctx.authenticationLifecycle.revokeSession( + .mutation(async ({ ctx, input }) => { + const result = await ctx.authenticationLifecycle.revokeSession( ctx.sessionIdentity, input.sessionId, authenticationRequestMetadata(ctx, undefined) @@ -116,8 +116,10 @@ export const authSessionRoutes = { touch: sessionProcedure .input(emptyInputSchema) .output(authSessionTouchResultSchema) - .mutation(({ ctx }) => { - const result = ctx.authenticationLifecycle.touchSession(ctx.sessionIdentity); + .mutation(async ({ ctx }) => { + const result = await ctx.authenticationLifecycle.touchSession( + ctx.sessionIdentity + ); if (result === undefined) { appendClearedDashboardSessionCookie(ctx.responseHeaders); throw new TRPCError({ diff --git a/greenfield/src/server/domains/security/authenticationLifecycle.sessions.test.ts b/greenfield/src/server/domains/security/authenticationLifecycle.sessions.test.ts index 80d87d2da..194790484 100644 --- a/greenfield/src/server/domains/security/authenticationLifecycle.sessions.test.ts +++ b/greenfield/src/server/domains/security/authenticationLifecycle.sessions.test.ts @@ -97,7 +97,7 @@ describe("authentication lifecycle sessions", () => { isBootstrapRequired: false, }); expect(harness.service.listSessions(identity)).toBeUndefined(); - expect(harness.service.touchSession(identity)).toBeUndefined(); + expect(await harness.service.touchSession(identity)).toBeUndefined(); } finally { harness.database.sqlite.close(true); } @@ -128,7 +128,7 @@ describe("authentication lifecycle sessions", () => { const beforeNoop = auditCount(); expect( - harness.service.revokeSession(actorIdentity, "b".repeat(32), { + await harness.service.revokeSession(actorIdentity, "b".repeat(32), { clientSourceId: "client-source-1", requestId: "request-noop-revoke", }) @@ -141,7 +141,7 @@ describe("authentication lifecycle sessions", () => { expect(harness.service.listSessions(actorIdentity)).toBeUndefined(); const beforeStale = auditCount(); expect( - harness.service.revokeSession(actorIdentity, second.session.id, { + await harness.service.revokeSession(actorIdentity, second.session.id, { clientSourceId: "client-source-1", requestId: "request-stale-revoke", }) @@ -176,7 +176,7 @@ describe("authentication lifecycle sessions", () => { harness.advanceSeconds(61); expect( - harness.service.revokeSession( + await harness.service.revokeSession( { sessionId: created.session.id, userId: created.user.id }, second.session.id, { clientSourceId: "client-source-1", requestId: "request-stale" } @@ -205,13 +205,13 @@ describe("authentication lifecycle sessions", () => { }; expect( - harness.service.logout(identity, { + await harness.service.logout(identity, { clientSourceId: "client-source-1", requestId: "request-logout", }) ).toBeTrue(); expect( - harness.service.logout(identity, { + await harness.service.logout(identity, { clientSourceId: "client-source-1", requestId: "request-repeat-logout", }) @@ -239,7 +239,7 @@ describe("authentication lifecycle sessions", () => { }; harness.advanceSeconds(60); - expect(harness.service.touchSession(identity)).toEqual({ + expect(await harness.service.touchSession(identity)).toEqual({ lastSeenAtMs: new Date("2026-08-05T09:01:00.000Z").getTime(), }); } finally { diff --git a/greenfield/src/server/domains/security/authenticationLifecycleBootstrap.ts b/greenfield/src/server/domains/security/authenticationLifecycleBootstrap.ts index 33b809ca2..46f2ad0dc 100644 --- a/greenfield/src/server/domains/security/authenticationLifecycleBootstrap.ts +++ b/greenfield/src/server/domains/security/authenticationLifecycleBootstrap.ts @@ -68,7 +68,7 @@ function recordBootstrapFailure( } function mapBootstrapFailure( - failure: ReturnType, + failure: Awaited>, fallbackStatus: "gateway-unavailable" | "invalid-gateway" ) { if (failure.status === "closed" || failure.status === "rate-limited") { @@ -111,20 +111,24 @@ export function createAuthenticationBootstrapOperation( } let settledGatewayFailure: ReturnType | undefined; - const settleGatewayFailure = ( + let gatewayFailureSettlement: + | Promise> + | undefined; + const settleGatewayFailure = async ( reason: "gateway_unavailable" | "invalid_gateway", fallbackStatus: "gateway-unavailable" | "invalid-gateway" - ): ReturnType => { - settledGatewayFailure ??= mapBootstrapFailure( - recordBootstrapFailure( - context, - rateLimitTargets, - context.now(), - metadata, - reason - ), - fallbackStatus - ); + ): Promise> => { + if (settledGatewayFailure !== undefined) { + return settledGatewayFailure; + } + gatewayFailureSettlement ??= recordBootstrapFailure( + context, + rateLimitTargets, + context.now(), + metadata, + reason + ).then((failure) => mapBootstrapFailure(failure, fallbackStatus)); + settledGatewayFailure = await gatewayFailureSettlement; return settledGatewayFailure; }; @@ -152,11 +156,14 @@ export function createAuthenticationBootstrapOperation( }; return false; }, - onInvalid: () => { - settleGatewayFailure("invalid_gateway", "invalid-gateway"); + onInvalid: async () => { + await settleGatewayFailure( + "invalid_gateway", + "invalid-gateway" + ); }, - onUnavailable: () => { - settleGatewayFailure( + onUnavailable: async () => { + await settleGatewayFailure( "gateway_unavailable", "gateway-unavailable" ); @@ -179,7 +186,10 @@ export function createAuthenticationBootstrapOperation( ) { throw error; } - return settleGatewayFailure("gateway_unavailable", "gateway-unavailable"); + return await settleGatewayFailure( + "gateway_unavailable", + "gateway-unavailable" + ); } metadata.signal?.throwIfAborted(); @@ -187,7 +197,7 @@ export function createAuthenticationBootstrapOperation( return settledGatewayFailure; } if (!gatewayCredentialIsValid) { - return settleGatewayFailure("invalid_gateway", "invalid-gateway"); + return await settleGatewayFailure("invalid_gateway", "invalid-gateway"); } if (context.repository.countUsers() !== 0) { @@ -207,7 +217,7 @@ export function createAuthenticationBootstrapOperation( const passwordHash = await context.hashPassword(input.password); metadata.signal?.throwIfAborted(); const createdAt = context.now(); - return context.repository.withImmediateTransaction((unit) => { + return await context.repository.withImmediateTransaction((unit) => { if (unit.countUsers() !== 0) { return { status: "closed" } as const; } diff --git a/greenfield/src/server/domains/security/authenticationLifecycleContext.ts b/greenfield/src/server/domains/security/authenticationLifecycleContext.ts index 8725ea89b..417902e09 100644 --- a/greenfield/src/server/domains/security/authenticationLifecycleContext.ts +++ b/greenfield/src/server/domains/security/authenticationLifecycleContext.ts @@ -55,8 +55,8 @@ export class AuthenticationStateChangedError extends Error {} export interface GatewayCredentialSettlement { readonly shouldVerify: () => boolean; - readonly onInvalid: () => void; - readonly onUnavailable: (failure: GatewayAuthenticationWorkFailure) => void; + readonly onInvalid: () => Promise; + readonly onUnavailable: (failure: GatewayAuthenticationWorkFailure) => Promise; } export interface AuthenticationLifecycleContext { @@ -240,7 +240,7 @@ export function createAuthenticationLifecycleContext( settlement.onUnavailable(failure), onResultBeforeRelease: (valid) => { metadata.signal?.throwIfAborted(); - if (!valid) settlement.onInvalid(); + return valid ? undefined : settlement.onInvalid(); }, timeoutMs: gatewayVerificationTimeoutMs, } diff --git a/greenfield/src/server/domains/security/authenticationLifecycleLogin.ts b/greenfield/src/server/domains/security/authenticationLifecycleLogin.ts index 6c0c5bfa7..1fa48e918 100644 --- a/greenfield/src/server/domains/security/authenticationLifecycleLogin.ts +++ b/greenfield/src/server/domains/security/authenticationLifecycleLogin.ts @@ -67,7 +67,7 @@ export function createAuthenticationLoginOperation( metadata.signal?.throwIfAborted(); const verificationCompletedAt = context.now(); if (user === undefined || user.disabledAt !== null || !passwordIsValid) { - const failure = context.repository.withImmediateTransaction( + const failure = await context.repository.withImmediateTransaction( (unit) => { const recorded = recordAuthenticationFailures( unit, @@ -99,7 +99,7 @@ export function createAuthenticationLoginOperation( const sourceTarget = rateLimitTargets.find( (target) => target.sourceScoped === true ); - const pending = context.mfaLoginLifecycle.beginPendingLogin({ + const pending = await context.mfaLoginLifecycle.beginPendingLogin({ ...(sourceTarget !== undefined && { clearedPasswordRateLimitBucketKey: rateLimitBucketKey( sourceTarget.kind, @@ -121,7 +121,7 @@ export function createAuthenticationLoginOperation( if (pending.status === "mfa-unavailable") { return { status: "service-unavailable" } as const; } - const failure = context.repository.withImmediateTransaction( + const failure = await context.repository.withImmediateTransaction( (unit) => { const recorded = recordAuthenticationFailures( unit, @@ -149,7 +149,7 @@ export function createAuthenticationLoginOperation( } as const); } - return context.repository.withImmediateTransaction((unit) => { + return await context.repository.withImmediateTransaction((unit) => { const currentUser = unit.findUserById(user.id); if ( currentUser === undefined || diff --git a/greenfield/src/server/domains/security/authenticationLifecyclePassword.ts b/greenfield/src/server/domains/security/authenticationLifecyclePassword.ts index 96406b3e0..5008fe190 100644 --- a/greenfield/src/server/domains/security/authenticationLifecyclePassword.ts +++ b/greenfield/src/server/domains/security/authenticationLifecyclePassword.ts @@ -160,7 +160,7 @@ export function createAuthenticationPasswordOperation( metadata.signal?.throwIfAborted(); if (!isCurrentPassword) { const failedAt = context.now(); - const failure = context.repository.withImmediateTransaction( + const failure = await context.repository.withImmediateTransaction( (unit) => { const state = revalidatePasswordChange( context, @@ -210,7 +210,7 @@ export function createAuthenticationPasswordOperation( metadata.signal?.throwIfAborted(); const changedAt = context.now(); try { - return context.repository.withImmediateTransaction((unit) => { + return await context.repository.withImmediateTransaction((unit) => { const state = revalidatePasswordChange( context, unit, diff --git a/greenfield/src/server/domains/security/authenticationLifecycleRepository.test.ts b/greenfield/src/server/domains/security/authenticationLifecycleRepository.test.ts index d52502efa..95546e6db 100644 --- a/greenfield/src/server/domains/security/authenticationLifecycleRepository.test.ts +++ b/greenfield/src/server/domains/security/authenticationLifecycleRepository.test.ts @@ -13,6 +13,7 @@ import { validAuthSessionInsert, validUserInsert, } from "../../database/validation/testSupport/securityRows.ts"; +import { testImmediateDatabaseWriteAdmission } from "../../test/support/databaseWriteAdmission.ts"; import { openFreshMigratedDatabase } from "../../test/support/freshDatabase.ts"; import { createAuthenticationLifecycleRepository } from "./authenticationLifecycleRepository.ts"; @@ -24,7 +25,8 @@ describe("authentication lifecycle repository", () => { const competing = new Database(databasePath, { strict: true }); competing.run("PRAGMA busy_timeout = 0"); const repository = createAuthenticationLifecycleRepository( - drizzle({ client: primary }) + drizzle({ client: primary }), + testImmediateDatabaseWriteAdmission ); try { @@ -37,7 +39,7 @@ describe("authentication lifecycle repository", () => { expect(deferredCompetingWriterAcquired).toBeTrue(); let immediateCompetingWriterFailure: unknown; - repository.withImmediateTransaction(() => { + await repository.withImmediateTransaction(() => { try { competing.run("BEGIN IMMEDIATE"); competing.run("ROLLBACK"); @@ -61,11 +63,14 @@ describe("authentication lifecycle repository", () => { test("prunes stale and excess source rate-limit buckets transactionally", async () => { const database = await openFreshMigratedDatabase(); - const repository = createAuthenticationLifecycleRepository(database.orm); + const repository = createAuthenticationLifecycleRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ); const startedAt = new Date("2026-08-05T09:00:00.000Z"); try { - repository.withImmediateTransaction((unit) => { + await repository.withImmediateTransaction((unit) => { const staleAt = addMilliseconds(startedAt, -2); unit.upsertRateLimitBucket({ blockedUntil: null, @@ -112,10 +117,13 @@ describe("authentication lifecycle repository", () => { test("adapts shared session deletion to boolean lifecycle semantics", async () => { const database = await openFreshMigratedDatabase(); - const repository = createAuthenticationLifecycleRepository(database.orm); + const repository = createAuthenticationLifecycleRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ); try { - repository.withImmediateTransaction((unit) => { + await repository.withImmediateTransaction((unit) => { unit.insertUser(validUserInsert); unit.insertSession(validAuthSessionInsert); expect(unit.deleteSession(securityUserId, sessionSelector)).toBeTrue(); diff --git a/greenfield/src/server/domains/security/authenticationLifecycleRepository.ts b/greenfield/src/server/domains/security/authenticationLifecycleRepository.ts index c1640ae38..b0237229e 100644 --- a/greenfield/src/server/domains/security/authenticationLifecycleRepository.ts +++ b/greenfield/src/server/domains/security/authenticationLifecycleRepository.ts @@ -1,5 +1,6 @@ import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; +import type { ImmediateDatabaseWriteAdmission } from "../../database/immediateWriteAdmission.ts"; import type { AuthenticationRateLimitKind } from "../../database/schema/authRateLimitBuckets.ts"; import { DrizzleAuthenticationRateLimitStore } from "./authenticationRateLimitStore.ts"; import { DrizzleBrowserSessionStore } from "./browserSessionStore.ts"; @@ -76,7 +77,7 @@ export interface AuthenticationLifecycleRepository { ): T; withImmediateTransaction( callback: (unit: AuthenticationLifecycleUnitOfWork) => SynchronousResult - ): T; + ): Promise; } class DrizzleAuthenticationLifecycleUnitOfWork implements AuthenticationLifecycleUnitOfWork { @@ -173,12 +174,14 @@ class DrizzleAuthenticationLifecycleUnitOfWork implements AuthenticationLifecycl /** * Creates the validated SQLite boundary for mutable browser authentication state. * Deferred and immediate callbacks remain synchronous so no asynchronous work can - * retain a SQLite transaction lock. + * retain a SQLite transaction lock; immediate admission and completion are awaited. * @param database Process-owned Drizzle SQLite database. - * @returns Synchronous authentication lifecycle repository. + * @param writeAdmission Process-owned bounded immediate-write admission. + * @returns Authentication repository with synchronous callbacks and async writes. */ export function createAuthenticationLifecycleRepository( - database: SQLiteBunDatabase + database: SQLiteBunDatabase, + writeAdmission: ImmediateDatabaseWriteAdmission ): AuthenticationLifecycleRepository { const runTransaction = database.transaction.bind(database) as unknown as ( callback: (transaction: SecurityTransaction) => T, @@ -212,13 +215,17 @@ export function createAuthenticationLifecycleRepository( callback: ( unit: AuthenticationLifecycleUnitOfWork ) => SynchronousResult | never - ): T { - return runTransaction( - (transaction): T => - callback( - new DrizzleAuthenticationLifecycleUnitOfWork(transaction) - ) as T, - { behavior: "immediate" } + ): Promise { + return writeAdmission.run((markTransactionStarted) => + runTransaction( + (transaction): T => { + markTransactionStarted(); + return callback( + new DrizzleAuthenticationLifecycleUnitOfWork(transaction) + ); + }, + { behavior: "immediate" } + ) ); }, }); diff --git a/greenfield/src/server/domains/security/authenticationLifecycleSessions.ts b/greenfield/src/server/domains/security/authenticationLifecycleSessions.ts index 2d1cf392c..04e5136d1 100644 --- a/greenfield/src/server/domains/security/authenticationLifecycleSessions.ts +++ b/greenfield/src/server/domains/security/authenticationLifecycleSessions.ts @@ -80,9 +80,9 @@ export function createAuthenticationSessionOperations( }); }, - logout(identity, metadata) { + async logout(identity, metadata) { if (identity === undefined) return false; - return context.repository.withImmediateTransaction((unit) => { + return await context.repository.withImmediateTransaction((unit) => { const occurredAt = context.now(); const revoked = unit.deleteSession(identity.userId, identity.sessionId); if (!revoked) return false; @@ -99,8 +99,8 @@ export function createAuthenticationSessionOperations( }); }, - revokeSession(identity, sessionId, metadata) { - return context.repository.withImmediateTransaction((unit) => { + async revokeSession(identity, sessionId, metadata) { + return await context.repository.withImmediateTransaction((unit) => { const occurredAt = context.now(); const user = unit.findUserById(identity.userId); const actorSession = unit.findSession( @@ -178,7 +178,7 @@ export function createAuthenticationSessionOperations( }; }, - touchSession(identity) { + async touchSession(identity) { const touchedAt = context.now(); const user = context.repository.findUserById(identity.userId); const current = context.repository.findSession( @@ -200,7 +200,7 @@ export function createAuthenticationSessionOperations( ) { return { lastSeenAtMs: getTime(current.lastSeenAt) }; } - const updated = context.repository.withImmediateTransaction((unit) => { + const updated = await context.repository.withImmediateTransaction((unit) => { const currentUser = unit.findUserById(identity.userId); if ( currentUser === undefined || diff --git a/greenfield/src/server/domains/security/authenticationLifecycleTypes.ts b/greenfield/src/server/domains/security/authenticationLifecycleTypes.ts index 39e371544..c78d0e3f5 100644 --- a/greenfield/src/server/domains/security/authenticationLifecycleTypes.ts +++ b/greenfield/src/server/domains/security/authenticationLifecycleTypes.ts @@ -28,7 +28,7 @@ export type VerifyGatewayCredential = ( ) => Promise; export interface PendingLoginLifecyclePort { - beginPendingLogin(input: BeginPendingLoginInput): BeginPendingLoginResult; + beginPendingLogin(input: BeginPendingLoginInput): Promise; } export interface AuthenticationLifecycleDependencies { @@ -121,14 +121,14 @@ export interface AuthenticationLifecycleService { logout( identity: AuthenticatedBrowserIdentity | undefined, metadata: AuthenticationRequestMetadata - ): boolean; + ): Promise; revokeSession( identity: AuthenticatedBrowserIdentity, sessionId: string, metadata: AuthenticationRequestMetadata - ): RevokeSessionResult | undefined; + ): Promise; status(identity?: AuthenticatedBrowserIdentity): AuthenticationStatus; touchSession( identity: AuthenticatedBrowserIdentity - ): { readonly lastSeenAtMs: number } | undefined; + ): Promise<{ readonly lastSeenAtMs: number } | undefined>; } diff --git a/greenfield/src/server/domains/security/authenticationWorkGate.test.ts b/greenfield/src/server/domains/security/authenticationWorkGate.test.ts index ed5873273..c9ef4ce26 100644 --- a/greenfield/src/server/domains/security/authenticationWorkGate.test.ts +++ b/greenfield/src/server/domains/security/authenticationWorkGate.test.ts @@ -182,7 +182,7 @@ describe("process authentication work service", () => { }); pending.resolve(false); - await Promise.resolve(); + await Bun.sleep(0); expect( await gateway(() => Promise.resolve(true), { timeoutMs: 100 }) ).toBeTrue(); diff --git a/greenfield/src/server/domains/security/authenticationWorkGate.ts b/greenfield/src/server/domains/security/authenticationWorkGate.ts index 7e01bbb70..77716cc97 100644 --- a/greenfield/src/server/domains/security/authenticationWorkGate.ts +++ b/greenfield/src/server/domains/security/authenticationWorkGate.ts @@ -1,4 +1,4 @@ -import { Context, Data, Effect, Fiber, FiberSet, Layer, Semaphore } from "effect"; +import { Context, Data, Effect, Exit, Fiber, FiberSet, Layer, Semaphore } from "effect"; export type AuthenticationWorkOperation = "gateway" | "password" | "totp" | "webauthn"; type AuthenticationVerificationOperation = Extract< @@ -28,9 +28,18 @@ export class AuthenticationUpstreamUnavailableError extends Data.TaggedError( readonly operation: AuthenticationWorkOperation; }> {} +/** Expected wrapper when durable verification settlement cannot complete. */ +export class AuthenticationWorkSettlementError extends Data.TaggedError( + "AuthenticationWorkSettlementError" +)<{ + readonly cause: unknown; + readonly operation: AuthenticationWorkOperation; +}> {} + export type AuthenticationWorkError = | AuthenticationUpstreamUnavailableError | AuthenticationWorkCapacityError + | AuthenticationWorkSettlementError | AuthenticationWorkTimeoutError; export type AuthenticationVerificationWorkFailure = @@ -59,14 +68,14 @@ export interface AuthenticationWorkGate { export interface AuthenticationVerificationWorkOptions { /** Synchronous in-gate admission check run after the active permit is acquired. */ readonly onBeforeStart?: () => AuthenticationVerificationWorkStartDecision; - /** Synchronous settlement for active work whose caller stopped waiting. */ - readonly onCancellationBeforeRelease?: () => void; - /** Synchronous durable settlement that completes before another waiter starts. */ + /** Durable settlement for active work whose caller stopped waiting. */ + readonly onCancellationBeforeRelease?: () => Promise | void; + /** Durable settlement that completes before another waiter starts. */ readonly onFailureBeforeRelease?: ( failure: AuthenticationVerificationWorkFailure - ) => void; - /** Synchronous result settlement that completes before another waiter starts. */ - readonly onResultBeforeRelease?: (value: T) => void; + ) => Promise | void; + /** Result settlement that completes before another waiter starts. */ + readonly onResultBeforeRelease?: (value: T) => Promise | void; readonly signal?: AbortSignal; readonly timeoutMs: number; } @@ -126,9 +135,11 @@ type AuthenticationVerificationEffectRunner = ( work: (signal: AbortSignal) => Promise, timeoutMs: number, onBeforeStart?: () => AuthenticationVerificationWorkStartDecision, - onCancellationBeforeRelease?: () => void, - onFailureBeforeRelease?: (failure: AuthenticationVerificationWorkFailure) => void, - onResultBeforeRelease?: (value: T) => void + onCancellationBeforeRelease?: () => Promise | void, + onFailureBeforeRelease?: ( + failure: AuthenticationVerificationWorkFailure + ) => Promise | void, + onResultBeforeRelease?: (value: T) => Promise | void ) => Effect.Effect; interface AuthenticationWorkServiceShape { @@ -260,30 +271,31 @@ function abortSignalEffect(signal: AbortSignal): Effect.Effect { function trackedWork( gate: BoundedAuthenticationGate, - fibers: FiberSet.FiberSet, + fibers: FiberSet.FiberSet, work: (signal: AbortSignal) => Effect.Effect ): Effect.Effect; function trackedWork( gate: BoundedAuthenticationGate, - fibers: FiberSet.FiberSet, + fibers: FiberSet.FiberSet, work: (signal: AbortSignal) => Effect.Effect, timeoutMs: number, - onCancellationBeforeRelease?: () => void, - onFailureBeforeRelease?: (failure: E | AuthenticationWorkTimeoutError) => void, - onResultBeforeRelease?: (value: T) => void -): Effect.Effect; + onCancellationBeforeRelease?: () => Promise | void, + onFailureBeforeRelease?: ( + failure: E | AuthenticationWorkTimeoutError + ) => Promise | void, + onResultBeforeRelease?: (value: T) => Promise | void +): Effect.Effect; function trackedWork( gate: BoundedAuthenticationGate, - fibers: FiberSet.FiberSet, + fibers: FiberSet.FiberSet, work: (signal: AbortSignal) => Effect.Effect, timeoutMs?: number, - onCancellationBeforeRelease?: () => void, - onFailureBeforeRelease?: (failure: E | AuthenticationWorkTimeoutError) => void, - onResultBeforeRelease?: (value: T) => void -): Effect.Effect< - T, - E | AuthenticationWorkCapacityError | AuthenticationWorkTimeoutError -> { + onCancellationBeforeRelease?: () => Promise | void, + onFailureBeforeRelease?: ( + failure: E | AuthenticationWorkTimeoutError + ) => Promise | void, + onResultBeforeRelease?: (value: T) => Promise | void +): Effect.Effect { return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const accepted = yield* gate.admitted.takeIfAvailable(1); @@ -295,46 +307,74 @@ function trackedWork( const controller = new AbortController(); const workerState = { callerInterrupted: false, started: false }; - const failureState = { notified: false }; - const cancellationState = { notified: false }; + const upstreamCompleted = Promise.withResolvers(); + type SettlementClaim = Readonly<{ + promise: Promise; + source: "cancellation" | "failure" | "result" | "timeout"; + }>; + const settlementState: { claim?: SettlementClaim } = {}; + const beginSettlement = ( + source: SettlementClaim["source"], + settlement?: () => Promise | void + ): Readonly<{ claim: SettlementClaim; owned: boolean }> => { + const existing = settlementState.claim; + if (existing !== undefined) { + return { claim: existing, owned: false }; + } + const claim = Object.freeze({ + promise: Promise.resolve().then(() => settlement?.()), + source, + }); + settlementState.claim = claim; + return { claim, owned: true }; + }; + const awaitSettlement = ( + claim: SettlementClaim + ): Effect.Effect => + Effect.tryPromise({ + catch: (cause) => + new AuthenticationWorkSettlementError({ + cause, + operation: gate.operation, + }), + try: () => claim.promise, + }); const notifyCancellationBeforeRelease = (): Effect.Effect => - Effect.sync(() => { - if ( - !workerState.started || - !workerState.callerInterrupted || - cancellationState.notified || - onCancellationBeforeRelease === undefined - ) { - return; + Effect.suspend(() => { + if (!workerState.started || !workerState.callerInterrupted) { + return Effect.void; } - cancellationState.notified = true; - onCancellationBeforeRelease(); + const { claim } = beginSettlement( + "cancellation", + onCancellationBeforeRelease + ); + return awaitSettlement(claim).pipe(Effect.orDie); }); const notifyFailureBeforeRelease = ( failure: E | AuthenticationWorkTimeoutError - ): Effect.Effect => - Effect.sync(() => { - if ( - workerState.callerInterrupted || - failureState.notified || - onFailureBeforeRelease === undefined - ) { - return; + ): Effect.Effect => + Effect.suspend(() => { + if (workerState.callerInterrupted) { + return Effect.void; } - failureState.notified = true; - onFailureBeforeRelease(failure); + const { claim } = beginSettlement("failure", () => + onFailureBeforeRelease?.(failure) + ); + upstreamCompleted.resolve(); + return Effect.uninterruptible(awaitSettlement(claim)); }); - const notifyResultBeforeRelease = (value: T): Effect.Effect => - Effect.sync(() => { - if ( - controller.signal.aborted || - workerState.callerInterrupted || - failureState.notified || - onResultBeforeRelease === undefined - ) { - return; + const notifyResultBeforeRelease = ( + value: T + ): Effect.Effect => + Effect.suspend(() => { + if (workerState.callerInterrupted) { + return Effect.void; } - onResultBeforeRelease(value); + const { claim } = beginSettlement("result", () => + onResultBeforeRelease?.(value) + ); + upstreamCompleted.resolve(); + return Effect.uninterruptible(awaitSettlement(claim)); }); const activePermit = gate.active.take(1).pipe(Effect.as(true as const)); const abortedBeforeStart = abortSignalEffect(controller.signal).pipe( @@ -342,6 +382,15 @@ function trackedWork( ); const releaseActivePermit = gate.active.release(1); const releaseAdmission = gate.admitted.release(1); + const awaitClaimBeforeRelease = Effect.suspend(() => { + const claim = settlementState.claim; + return claim === undefined + ? Effect.void + : awaitSettlement(claim).pipe(Effect.ignore); + }); + const releaseWorkerPermits = awaitClaimBeforeRelease.pipe( + Effect.andThen(releaseAdmission) + ); const runtimeStopping = abortControllerEffect( controller, "Authentication runtime is stopping", @@ -350,8 +399,8 @@ function trackedWork( const upstream = Effect.suspend(() => controller.signal.aborted ? Effect.interrupt : work(controller.signal) ).pipe( - Effect.tap(notifyResultBeforeRelease), Effect.tapError(notifyFailureBeforeRelease), + Effect.tap(notifyResultBeforeRelease), Effect.onInterrupt(() => runtimeStopping), Effect.ensuring(notifyCancellationBeforeRelease()) ); @@ -368,13 +417,16 @@ function trackedWork( : Effect.interrupt ) ) - ).pipe(Effect.ensuring(releaseAdmission)); + ).pipe(Effect.ensuring(releaseWorkerPermits)); const fiber = yield* FiberSet.run(fibers, worker, { propagateInterruption: false, startImmediately: true, }); - let joined: Effect.Effect = - Fiber.join(fiber); + const joined: Effect.Effect< + T, + AuthenticationWorkSettlementError | AuthenticationWorkTimeoutError | E + > = Fiber.join(fiber); + let callerWait = joined; if (timeoutMs !== undefined) { const timeoutFailure = new AuthenticationWorkTimeoutError({ operation: gate.operation, @@ -390,17 +442,51 @@ function trackedWork( const awaitExit = Fiber.await(fiber); return awaitExit.pipe(Effect.asVoid); }); - const timeoutFallback = abortForTimeout.pipe( - Effect.andThen(notifyFailureBeforeRelease(timeoutFailure)), - Effect.andThen(awaitQueuedWorker), - Effect.andThen(Effect.fail(timeoutFailure)) + const timeoutFallback = Effect.sync(() => + beginSettlement("timeout", () => + onFailureBeforeRelease?.(timeoutFailure) + ) + ).pipe( + Effect.flatMap(({ claim, owned }) => + owned + ? abortForTimeout.pipe( + Effect.andThen(awaitSettlement(claim)), + Effect.andThen(awaitQueuedWorker), + Effect.andThen(Effect.fail(timeoutFailure)) + ) + : Effect.promise(() => upstreamCompleted.promise) + ) ); - joined = joined.pipe( + const continueUnlessTimeoutOwnsSettlement = Effect.suspend(() => + settlementState.claim?.source === "timeout" + ? Effect.never + : Effect.void + ); + const awaitUpstreamMilestone = Effect.promise( + () => upstreamCompleted.promise + ).pipe(Effect.andThen(continueUnlessTimeoutOwnsSettlement)); + const awaitWorkerOutcome = Fiber.await(fiber).pipe( + Effect.flatMap((exit) => + Effect.suspend(() => { + if (settlementState.claim?.source === "timeout") { + return Effect.never; + } + return Exit.isSuccess(exit) + ? Effect.void + : Effect.failCause(exit.cause); + }) + ) + ); + const awaitUpstream = Effect.raceFirst( + awaitUpstreamMilestone, + awaitWorkerOutcome + ).pipe( Effect.timeoutOrElse({ duration: timeoutMs, orElse: () => timeoutFallback, }) ); + callerWait = awaitUpstream.pipe(Effect.andThen(joined)); } const requestAbortReason = new DOMException( "Authentication request aborted", @@ -410,7 +496,7 @@ function trackedWork( workerState.callerInterrupted = true; abortController(controller, requestAbortReason); }); - return yield* restore(joined).pipe( + return yield* restore(callerWait).pipe( Effect.onInterrupt(() => abortForRequest.pipe( Effect.andThen( @@ -427,13 +513,15 @@ function trackedWork( function verificationWork( gate: BoundedAuthenticationGate, - fibers: FiberSet.FiberSet, + fibers: FiberSet.FiberSet, work: (signal: AbortSignal) => Promise, timeoutMs: number, onBeforeStart?: () => AuthenticationVerificationWorkStartDecision, - onCancellationBeforeRelease?: () => void, - onFailureBeforeRelease?: (failure: AuthenticationVerificationWorkFailure) => void, - onResultBeforeRelease?: (value: T) => void + onCancellationBeforeRelease?: () => Promise | void, + onFailureBeforeRelease?: ( + failure: AuthenticationVerificationWorkFailure + ) => Promise | void, + onResultBeforeRelease?: (value: T) => Promise | void ): Effect.Effect { const operation = ( signal: AbortSignal @@ -478,7 +566,7 @@ function verificationWork( onFailureBeforeRelease, (result) => { if (result.kind === "verified") { - onResultBeforeRelease?.(result.value); + return onResultBeforeRelease?.(result.value); } } ).pipe(Effect.map(({ value }) => value)); @@ -498,10 +586,7 @@ export function authenticationWorkLayer( return Layer.effect( AuthenticationWorkService, Effect.gen(function* () { - const fibers = yield* FiberSet.make< - unknown, - AuthenticationUpstreamUnavailableError - >(); + const fibers = yield* FiberSet.make(); const gateway = yield* createGate( "gateway", normalized.gatewayMaximumConcurrent, diff --git a/greenfield/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts b/greenfield/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts index 4b235a253..998582ee2 100644 --- a/greenfield/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts +++ b/greenfield/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts @@ -9,6 +9,7 @@ import { createTestStructuredLogger } from "../../test/support/requestContext.ts import { type AuthenticationVerificationWorkOptions, AuthenticationUpstreamUnavailableError, + AuthenticationWorkSettlementError, AuthenticationWorkTimeoutError, } from "./authenticationWorkGate.ts"; @@ -24,8 +25,7 @@ const inertRealtimeLayer = Layer.succeed( const testStructuredLogger = createTestStructuredLogger(); async function yieldToWorkService(): Promise { - await Promise.resolve(); - await Promise.resolve(); + await Bun.sleep(0); } function webAuthnRunner(runtime: ReturnType) { @@ -154,6 +154,161 @@ describe("process WebAuthn verification work service", () => { } }); + test("returns the typed timeout when queued verification never starts", async () => { + const runtime = createApplicationRuntime({ + authenticationWork: { + webAuthnMaximumConcurrent: 1, + webAuthnMaximumQueued: 1, + }, + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }); + const activeStarted = Promise.withResolvers(); + const releaseActive = Promise.withResolvers(); + let queuedFailureSettlements = 0; + let queuedWorkCalls = 0; + + try { + await runtime.initialize(); + const webAuthn = webAuthnRunner(runtime); + const active = webAuthn( + async () => { + activeStarted.resolve(); + await releaseActive.promise; + return "active"; + }, + { timeoutMs: 5000 } + ); + await activeStarted.promise; + + const failure = await captureFailure(() => + webAuthn( + () => { + queuedWorkCalls += 1; + return Promise.resolve("unexpected"); + }, + { + onFailureBeforeRelease: (timeoutFailure) => { + expect(timeoutFailure).toBeInstanceOf( + AuthenticationWorkTimeoutError + ); + queuedFailureSettlements += 1; + }, + timeoutMs: 20, + } + ) + ); + + expect(failure).toBeInstanceOf(AuthenticationWorkTimeoutError); + expect(failure).toMatchObject({ operation: "webauthn", timeoutMs: 20 }); + expect(queuedFailureSettlements).toBe(1); + expect(queuedWorkCalls).toBe(0); + + releaseActive.resolve(); + expect(await active).toBe("active"); + } finally { + releaseActive.resolve(); + await runtime.dispose(); + } + }); + + test("preserves a queued timeout settlement failure", async () => { + const runtime = createApplicationRuntime({ + authenticationWork: { + webAuthnMaximumConcurrent: 1, + webAuthnMaximumQueued: 1, + }, + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }); + const activeStarted = Promise.withResolvers(); + const releaseActive = Promise.withResolvers(); + const sentinel = new Error("private queued settlement failure"); + let queuedWorkCalls = 0; + + try { + await runtime.initialize(); + const webAuthn = webAuthnRunner(runtime); + const active = webAuthn( + async () => { + activeStarted.resolve(); + await releaseActive.promise; + return "active"; + }, + { timeoutMs: 5000 } + ); + await activeStarted.promise; + + const failure = await captureFailure(() => + webAuthn( + () => { + queuedWorkCalls += 1; + return Promise.resolve("unexpected"); + }, + { + onFailureBeforeRelease: () => Promise.reject(sentinel), + timeoutMs: 20, + } + ) + ); + + expect(failure).toBeInstanceOf(AuthenticationWorkSettlementError); + expect(failure).toMatchObject({ + cause: sentinel, + operation: "webauthn", + }); + expect(queuedWorkCalls).toBe(0); + + releaseActive.resolve(); + expect(await active).toBe("active"); + } finally { + releaseActive.resolve(); + await runtime.dispose(); + } + }); + + test("keeps a cooperative abort inside the timeout outcome", async () => { + const runtime = createApplicationRuntime({ + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }); + let failureSettlements = 0; + + try { + await runtime.initialize(); + const failure = await captureFailure(() => + webAuthnRunner(runtime)( + (signal) => + new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + reject(new Error("verification aborted")); + }, + { once: true } + ); + }), + { + onFailureBeforeRelease: async (timeoutFailure) => { + expect(timeoutFailure).toBeInstanceOf( + AuthenticationWorkTimeoutError + ); + failureSettlements += 1; + await Bun.sleep(30); + }, + timeoutMs: 20, + } + ) + ); + + expect(failure).toBeInstanceOf(AuthenticationWorkTimeoutError); + expect(failure).toMatchObject({ operation: "webauthn", timeoutMs: 20 }); + expect(failureSettlements).toBe(1); + } finally { + await runtime.dispose(); + } + }); + test("redacts defects and retains active capacity after timeout and abort", async () => { const runtime = createApplicationRuntime({ authenticationWork: { @@ -263,8 +418,12 @@ describe("process WebAuthn verification work service", () => { }); const releaseResult = Promise.withResolvers(); const resultStarted = Promise.withResolvers(); + const releaseResultSettlement = Promise.withResolvers(); + const resultSettlementStarted = Promise.withResolvers(); const releaseFailure = Promise.withResolvers(); const failureStarted = Promise.withResolvers(); + const releaseFailureSettlement = Promise.withResolvers(); + const failureSettlementStarted = Promise.withResolvers(); const order: string[] = []; let skippedWorkCalls = 0; @@ -279,7 +438,12 @@ describe("process WebAuthn verification work service", () => { return "verified"; }, { - onResultBeforeRelease: () => order.push("result-settled"), + onResultBeforeRelease: async () => { + order.push("result-settlement-started"); + resultSettlementStarted.resolve(); + await releaseResultSettlement.promise; + order.push("result-settled"); + }, timeoutMs: 5000, } ); @@ -294,7 +458,9 @@ describe("process WebAuthn verification work service", () => { order.push("queued-recheck"); return { proceed: false, value: "stale" }; }, - onResultBeforeRelease: () => order.push("skipped-settled"), + onResultBeforeRelease: () => { + order.push("skipped-settled"); + }, timeoutMs: 5000, } ); @@ -302,10 +468,19 @@ describe("process WebAuthn verification work service", () => { expect(order).toEqual(["result-work"]); releaseResult.resolve(); + await resultSettlementStarted.promise; + await yieldToWorkService(); + expect(order).toEqual(["result-work", "result-settlement-started"]); + releaseResultSettlement.resolve(); expect(await result).toBe("verified"); expect(await skipped).toBe("stale"); expect(skippedWorkCalls).toBe(0); - expect(order).toEqual(["result-work", "result-settled", "queued-recheck"]); + expect(order).toEqual([ + "result-work", + "result-settlement-started", + "result-settled", + "queued-recheck", + ]); const failed = webAuthn( async () => { @@ -315,7 +490,12 @@ describe("process WebAuthn verification work service", () => { throw new Error("verifier detail"); }, { - onFailureBeforeRelease: () => order.push("failure-settled"), + onFailureBeforeRelease: async () => { + order.push("failure-settlement-started"); + failureSettlementStarted.resolve(); + await releaseFailureSettlement.promise; + order.push("failure-settled"); + }, timeoutMs: 5000, } ); @@ -328,6 +508,10 @@ describe("process WebAuthn verification work service", () => { { timeoutMs: 5000 } ); releaseFailure.resolve(); + await failureSettlementStarted.promise; + await yieldToWorkService(); + expect(order).not.toContain("after-failure-start"); + releaseFailureSettlement.resolve(); expect(await captureFailure(() => failed)).toBeInstanceOf( AuthenticationUpstreamUnavailableError ); @@ -337,7 +521,225 @@ describe("process WebAuthn verification work service", () => { ); } finally { releaseResult.resolve(); + releaseResultSettlement.resolve(); releaseFailure.resolve(); + releaseFailureSettlement.resolve(); + await runtime.dispose(); + } + }); + + test("stops the verification deadline before awaiting result settlement", async () => { + const runtime = createApplicationRuntime({ + authenticationWork: { + webAuthnMaximumConcurrent: 1, + webAuthnMaximumQueued: 0, + }, + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }); + const releaseSettlement = Promise.withResolvers(); + const settlementStarted = Promise.withResolvers(); + let callerSettled = false; + let failureSettlements = 0; + + try { + await runtime.initialize(); + const webAuthn = webAuthnRunner(runtime); + const verification = webAuthn(() => Promise.resolve("verified"), { + onFailureBeforeRelease: () => { + failureSettlements += 1; + }, + onResultBeforeRelease: async () => { + settlementStarted.resolve(); + await releaseSettlement.promise; + }, + timeoutMs: 20, + }); + void verification.then( + () => { + callerSettled = true; + return true; + }, + () => { + callerSettled = true; + return false; + } + ); + await settlementStarted.promise; + await Bun.sleep(60); + + expect(callerSettled).toBeFalse(); + expect(failureSettlements).toBe(0); + expect( + await captureFailure(() => + webAuthn(() => Promise.resolve("overflow"), { + timeoutMs: 500, + }) + ) + ).toMatchObject({ + _tag: "AuthenticationWorkCapacityError", + operation: "webauthn", + }); + + releaseSettlement.resolve(); + expect(await verification).toBe("verified"); + expect(failureSettlements).toBe(0); + } finally { + releaseSettlement.resolve(); + await runtime.dispose(); + } + }); + + test("does not run cancellation settlement after result settlement is claimed", async () => { + const runtime = createApplicationRuntime({ + authenticationWork: { + webAuthnMaximumConcurrent: 1, + webAuthnMaximumQueued: 1, + }, + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }); + const releaseSettlement = Promise.withResolvers(); + const replacementStarted = Promise.withResolvers(); + const settlementStarted = Promise.withResolvers(); + let cancellationSettlements = 0; + let resultSettlements = 0; + + try { + await runtime.initialize(); + const webAuthn = webAuthnRunner(runtime); + const controller = new AbortController(); + const verification = webAuthn(() => Promise.resolve("verified"), { + onCancellationBeforeRelease: () => { + cancellationSettlements += 1; + }, + onResultBeforeRelease: async () => { + resultSettlements += 1; + settlementStarted.resolve(); + await releaseSettlement.promise; + }, + signal: controller.signal, + timeoutMs: 5000, + }); + await settlementStarted.promise; + + const cancellation = new Error("request cancelled"); + controller.abort(cancellation); + expect(await captureFailure(() => verification)).toBe(cancellation); + + const replacement = webAuthn( + () => { + replacementStarted.resolve(); + return Promise.resolve("replacement"); + }, + { timeoutMs: 5000 } + ); + await yieldToWorkService(); + expect(resultSettlements).toBe(1); + expect(cancellationSettlements).toBe(0); + + releaseSettlement.resolve(); + await replacementStarted.promise; + expect(await replacement).toBe("replacement"); + expect(resultSettlements).toBe(1); + expect(cancellationSettlements).toBe(0); + } finally { + releaseSettlement.resolve(); + await runtime.dispose(); + } + }); + + test("keeps a claimed settlement owned during runtime disposal", async () => { + const runtime = createApplicationRuntime({ + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }); + const releaseSettlement = Promise.withResolvers(); + const settlementStarted = Promise.withResolvers(); + let disposalCompleted = false; + let settlementCompleted = false; + + try { + await runtime.initialize(); + const verification = webAuthnRunner(runtime)( + () => Promise.resolve("verified"), + { + onResultBeforeRelease: async () => { + settlementStarted.resolve(); + await releaseSettlement.promise; + settlementCompleted = true; + }, + timeoutMs: 5000, + } + ); + const observedVerification = verification.catch(() => null); + await settlementStarted.promise; + const disposal = runtime.dispose().then(() => { + disposalCompleted = true; + return true; + }); + await Bun.sleep(0); + + expect(disposalCompleted).toBeFalse(); + expect(settlementCompleted).toBeFalse(); + + releaseSettlement.resolve(); + await disposal; + await observedVerification; + expect(disposalCompleted).toBeTrue(); + expect(settlementCompleted).toBeTrue(); + } finally { + releaseSettlement.resolve(); + await runtime.dispose(); + } + }); + + test("surfaces an in-gate recheck defect before the verification deadline", async () => { + const runtime = createApplicationRuntime({ + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }); + const sentinel = new Error("private recheck defect"); + + try { + await runtime.initialize(); + const failure = await captureFailure(() => + webAuthnRunner(runtime)(() => Promise.resolve("unused"), { + onBeforeStart: () => { + throw sentinel; + }, + timeoutMs: 20, + }) + ); + + expect(failure).toBe(sentinel); + } finally { + await runtime.dispose(); + } + }); + + test("wraps a failed durable settlement with its private cause", async () => { + const runtime = createApplicationRuntime({ + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }); + const sentinel = new Error("private database settlement failure"); + + try { + await runtime.initialize(); + const failure = await captureFailure(() => + webAuthnRunner(runtime)(() => Promise.resolve("verified"), { + onResultBeforeRelease: () => Promise.reject(sentinel), + timeoutMs: 5000, + }) + ); + + expect(failure).toBeInstanceOf(AuthenticationWorkSettlementError); + expect(failure).toMatchObject({ + cause: sentinel, + operation: "webauthn", + }); + } finally { await runtime.dispose(); } }); diff --git a/greenfield/src/server/domains/security/automation/credentialOperations.ts b/greenfield/src/server/domains/security/automation/credentialOperations.ts index 90e0f8a37..bd887cd3f 100644 --- a/greenfield/src/server/domains/security/automation/credentialOperations.ts +++ b/greenfield/src/server/domains/security/automation/credentialOperations.ts @@ -52,10 +52,10 @@ export function createAutomationCredentialOperations( "createCredential" | "listCredentials" | "revokeCredential" | "rotateCredential" > { return { - createCredential(identity, input, metadata) { + async createCredential(identity, input, metadata) { const generation = generateCredentialMaterials(context); try { - return context.repository.withImmediateTransaction((unit) => { + return await context.repository.withImmediateTransaction((unit) => { const createdAt = context.now(); const policy = context.authorizeAdministration( unit, @@ -177,9 +177,9 @@ export function createAutomationCredentialOperations( } }, - revokeCredential(identity, input, metadata) { + async revokeCredential(identity, input, metadata) { try { - return context.repository.withImmediateTransaction((unit) => { + return await context.repository.withImmediateTransaction((unit) => { const revokedAt = context.now(); const policy = context.authorizeAdministration( unit, @@ -242,10 +242,10 @@ export function createAutomationCredentialOperations( } }, - rotateCredential(identity, input, metadata) { + async rotateCredential(identity, input, metadata) { const generation = generateCredentialMaterials(context); try { - return context.repository.withImmediateTransaction((unit) => { + return await context.repository.withImmediateTransaction((unit) => { const createdAt = context.now(); const policy = context.authorizeAdministration( unit, diff --git a/greenfield/src/server/domains/security/automation/lifecycleCredential.test.ts b/greenfield/src/server/domains/security/automation/lifecycleCredential.test.ts index b572e46c3..1ce858324 100644 --- a/greenfield/src/server/domains/security/automation/lifecycleCredential.test.ts +++ b/greenfield/src/server/domains/security/automation/lifecycleCredential.test.ts @@ -42,7 +42,7 @@ describe("automation credential lifecycle", () => { const creationService = fixture.createService(); try { - const created = creationService.createPrincipal( + const created = await creationService.createPrincipal( fixture.identity, { ...initialPrincipalInput, @@ -66,7 +66,7 @@ describe("automation credential lifecycle", () => { const rotationService = fixture.createService({ repository }); const auditCount = readAutomationAuditEvents(fixture.database.sqlite).length; expect( - rotationService.rotateCredential( + await rotationService.rotateCredential( fixture.identity, { credentialId: created.result.credential.id, @@ -93,7 +93,7 @@ describe("automation credential lifecycle", () => { const creationService = fixture.createService(); try { - const created = creationService.createPrincipal( + const created = await creationService.createPrincipal( fixture.identity, initialPrincipalInput, fixture.metadata @@ -119,7 +119,7 @@ describe("automation credential lifecycle", () => { return id; }, }); - const rotation = rotationService.rotateCredential( + const rotation = await rotationService.rotateCredential( fixture.identity, { credentialId: created.result.credential.id, @@ -152,7 +152,7 @@ describe("automation credential lifecycle", () => { const service = fixture.createService(); try { - const created = service.createPrincipal( + const created = await service.createPrincipal( fixture.identity, initialPrincipalInput, fixture.metadata @@ -162,7 +162,7 @@ describe("automation credential lifecycle", () => { const rotatedAt = addMilliseconds(automationLifecycleInitialNow, 1); fixture.setNow(rotatedAt); - const rotation = service.rotateCredential( + const rotation = await service.rotateCredential( fixture.identity, { credentialId: created.result.credential.id, @@ -188,7 +188,7 @@ describe("automation credential lifecycle", () => { fixture.database.sqlite ).length; expect( - service.rotateCredential( + await service.rotateCredential( fixture.identity, { credentialId: created.result.credential.id, @@ -205,7 +205,7 @@ describe("automation credential lifecycle", () => { const revokedAt = addMilliseconds(rotatedAt, 1); fixture.setNow(revokedAt); - const lostReplacementRevoke = service.revokeCredential( + const lostReplacementRevoke = await service.revokeCredential( fixture.identity, { credentialId: rotation.result.credential.id, @@ -222,7 +222,7 @@ describe("automation credential lifecycle", () => { fixture.database.sqlite ).length; expect( - service.revokeCredential( + await service.revokeCredential( fixture.identity, { credentialId: rotation.result.credential.id, @@ -238,7 +238,7 @@ describe("automation credential lifecycle", () => { const retriedAt = addMilliseconds(revokedAt, 1); fixture.setNow(retriedAt); - const retry = service.rotateCredential( + const retry = await service.rotateCredential( fixture.identity, { credentialId: created.result.credential.id, @@ -264,7 +264,7 @@ describe("automation credential lifecycle", () => { const finalRevokeAt = addMilliseconds(retriedAt, 1); fixture.setNow(finalRevokeAt); expect( - service.revokeCredential( + await service.revokeCredential( fixture.identity, { credentialId: created.result.credential.id, @@ -301,7 +301,7 @@ describe("automation credential lifecycle", () => { const service = fixture.createService(); try { - const created = service.createPrincipal( + const created = await service.createPrincipal( fixture.identity, initialPrincipalInput, fixture.metadata @@ -319,23 +319,22 @@ describe("automation credential lifecycle", () => { { label: "Fourth credential" }, ] as const; for (const credential of credentialInputs) { - expect( - service.createCredential( - fixture.identity, - { - credential, - expectedAuthorizationVersion: 1, - principalId: automationLifecyclePrincipalId, - }, - fixture.metadata - ).status - ).toBe("created"); + const result = await service.createCredential( + fixture.identity, + { + credential, + expectedAuthorizationVersion: 1, + principalId: automationLifecyclePrincipalId, + }, + fixture.metadata + ); + expect(result.status).toBe("created"); } const auditCountAtCapacity = readAutomationAuditEvents( fixture.database.sqlite ).length; expect( - service.createCredential( + await service.createCredential( fixture.identity, { credential: { label: "Over capacity" }, @@ -351,20 +350,19 @@ describe("automation credential lifecycle", () => { const afterExpiry = addMilliseconds(shortExpiry, 1); fixture.setNow(afterExpiry); - expect( - service.createCredential( - fixture.identity, - { - credential: { - expiresAtMs: addHours(afterExpiry, 1).getTime(), - label: "Capacity after expiry", - }, - expectedAuthorizationVersion: 1, - principalId: automationLifecyclePrincipalId, + const createdAfterExpiry = await service.createCredential( + fixture.identity, + { + credential: { + expiresAtMs: addHours(afterExpiry, 1).getTime(), + label: "Capacity after expiry", }, - fixture.metadata - ).status - ).toBe("created"); + expectedAuthorizationVersion: 1, + principalId: automationLifecyclePrincipalId, + }, + fixture.metadata + ); + expect(createdAfterExpiry.status).toBe("created"); expect( fixture.repository.countActiveCredentials( automationLifecyclePrincipalId, @@ -396,7 +394,7 @@ describe("automation credential lifecycle", () => { const service = fixture.createService(); try { - const created = service.createPrincipal( + const created = await service.createPrincipal( fixture.identity, initialPrincipalInput, fixture.metadata @@ -410,7 +408,7 @@ describe("automation credential lifecycle", () => { }); const auditCount = readAutomationAuditEvents(fixture.database.sqlite).length; - expect(() => + expect( faulting.revokeCredential( fixture.identity, { @@ -420,7 +418,7 @@ describe("automation credential lifecycle", () => { }, fixture.metadata ) - ).toThrow(); + ).rejects.toThrow(); expect( readPersistedAutomationCredentials(fixture.database.sqlite)[0]?.revokedAt ).toBeNull(); @@ -437,7 +435,7 @@ describe("automation credential lifecycle", () => { const service = fixture.createService(); try { - const created = service.createPrincipal( + const created = await service.createPrincipal( fixture.identity, initialPrincipalInput, fixture.metadata @@ -453,7 +451,7 @@ describe("automation credential lifecycle", () => { "Future credential two", "Future credential three", ]) { - const futureCredential = service.createCredential( + const futureCredential = await service.createCredential( fixture.identity, { credential: { label }, @@ -482,7 +480,7 @@ describe("automation credential lifecycle", () => { fixture.database.sqlite ).length; expect( - service.createCredential( + await service.createCredential( fixture.identity, { credential: { label: "Rollback overflow" }, @@ -493,7 +491,7 @@ describe("automation credential lifecycle", () => { ) ).toEqual({ status: "conflict" }); expect( - service.rotateCredential( + await service.rotateCredential( fixture.identity, { credentialId: created.result.credential.id, @@ -517,7 +515,7 @@ describe("automation credential lifecycle", () => { }) ).toEqual({ status: "session-changed" }); expect( - service.revokeCredential( + await service.revokeCredential( fixture.identity, { credentialId: created.result.credential.id, @@ -533,7 +531,7 @@ describe("automation credential lifecycle", () => { )?.revokedAt ).toBeNull(); expect( - service.revokeCredential( + await service.revokeCredential( fixture.identity, { credentialId: futureCredentialId, @@ -545,20 +543,19 @@ describe("automation credential lifecycle", () => { ).toEqual({ status: "conflict" }); fixture.setNow(futureAt); - expect( - service.revokeCredential( - fixture.identity, - { - credentialId: created.result.credential.id, - expectedAuthorizationVersion: 1, - principalId: automationLifecyclePrincipalId, - }, - fixture.metadata - ).status - ).toBe("revoked"); + const futureRevocation = await service.revokeCredential( + fixture.identity, + { + credentialId: created.result.credential.id, + expectedAuthorizationVersion: 1, + principalId: automationLifecyclePrincipalId, + }, + fixture.metadata + ); + expect(futureRevocation.status).toBe("revoked"); fixture.setNow(rolledBackAt); expect( - service.revokeCredential( + await service.revokeCredential( fixture.identity, { credentialId: created.result.credential.id, @@ -578,7 +575,7 @@ describe("automation credential lifecycle", () => { const service = fixture.createService(); try { - const created = service.createPrincipal( + const created = await service.createPrincipal( fixture.identity, initialPrincipalInput, fixture.metadata @@ -588,17 +585,16 @@ describe("automation credential lifecycle", () => { const futureAt = addMinutes(automationLifecycleInitialNow, 2); fixture.setNow(futureAt); - expect( - service.revokeCredential( - fixture.identity, - { - credentialId: created.result.credential.id, - expectedAuthorizationVersion: 1, - principalId: automationLifecyclePrincipalId, - }, - fixture.metadata - ).status - ).toBe("revoked"); + const futureRevocation = await service.revokeCredential( + fixture.identity, + { + credentialId: created.result.credential.id, + expectedAuthorizationVersion: 1, + principalId: automationLifecyclePrincipalId, + }, + fixture.metadata + ); + expect(futureRevocation.status).toBe("revoked"); fixture.setNow(addMinutes(automationLifecycleInitialNow, 1)); const credentialCount = readPersistedAutomationCredentials( @@ -612,7 +608,7 @@ describe("automation credential lifecycle", () => { }) ).toEqual({ status: "session-changed" }); expect( - service.createCredential( + await service.createCredential( fixture.identity, { credential: { label: "Rollback credential" }, diff --git a/greenfield/src/server/domains/security/automation/lifecyclePrincipal.test.ts b/greenfield/src/server/domains/security/automation/lifecyclePrincipal.test.ts index 1d17b0a45..793f3bd35 100644 --- a/greenfield/src/server/domains/security/automation/lifecyclePrincipal.test.ts +++ b/greenfield/src/server/domains/security/automation/lifecyclePrincipal.test.ts @@ -38,7 +38,7 @@ describe("automation principal lifecycle", () => { try { expect( - service.createPrincipal( + await service.createPrincipal( fixture.identity, principalInput, fixture.metadata @@ -66,7 +66,7 @@ describe("automation principal lifecycle", () => { try { expect( - service.createPrincipal( + await service.createPrincipal( fixture.identity, principalInput, fixture.metadata @@ -102,25 +102,23 @@ describe("automation principal lifecycle", () => { const service = fixture.createService(); try { - expect( - service.createPrincipal( - fixture.identity, - principalInput, - fixture.metadata - ).status - ).toBe("created"); + const created = await service.createPrincipal( + fixture.identity, + principalInput, + fixture.metadata + ); + expect(created.status).toBe("created"); fixture.setNow(addMinutes(automationLifecycleInitialNow, 2)); - expect( - service.replaceCapabilities( - fixture.identity, - { - capabilities: ["notifications:read", "reports:read"], - expectedAuthorizationVersion: 1, - principalId: automationLifecyclePrincipalId, - }, - fixture.metadata - ).status - ).toBe("replaced"); + const replaced = await service.replaceCapabilities( + fixture.identity, + { + capabilities: ["notifications:read", "reports:read"], + expectedAuthorizationVersion: 1, + principalId: automationLifecyclePrincipalId, + }, + fixture.metadata + ); + expect(replaced.status).toBe("replaced"); fixture.setNow(addMinutes(automationLifecycleInitialNow, 1)); expect(service.listPrincipals(fixture.identity, { limit: 10 })).toEqual({ status: "session-changed", @@ -137,16 +135,15 @@ describe("automation principal lifecycle", () => { const rollbackPrincipalId = "rollback-principal"; try { - expect( - service.createPrincipal( - fixture.identity, - principalInput, - fixture.metadata - ).status - ).toBe("created"); + const created = await service.createPrincipal( + fixture.identity, + principalInput, + fixture.metadata + ); + expect(created.status).toBe("created"); const futureAt = addMinutes(automationLifecycleInitialNow, 2); fixture.setNow(futureAt); - const future = service.createPrincipal( + const future = await service.createPrincipal( fixture.identity, { ...principalInput, @@ -175,7 +172,7 @@ describe("automation principal lifecycle", () => { ).length; const auditCount = readAutomationAuditEvents(fixture.database.sqlite).length; expect( - service.createPrincipal( + await service.createPrincipal( fixture.identity, { ...principalInput, @@ -203,16 +200,15 @@ describe("automation principal lifecycle", () => { const service = fixture.createService(); try { - expect( - service.createPrincipal( - fixture.identity, - principalInput, - fixture.metadata - ).status - ).toBe("created"); + const created = await service.createPrincipal( + fixture.identity, + principalInput, + fixture.metadata + ); + expect(created.status).toBe("created"); const futureAt = addMinutes(automationLifecycleInitialNow, 2); fixture.setNow(futureAt); - const futureCredential = service.createCredential( + const futureCredential = await service.createCredential( fixture.identity, { credential: { label: "Future inventory credential" }, @@ -251,7 +247,7 @@ describe("automation principal lifecycle", () => { .query("UPDATE users SET mfa_enabled_at = NULL WHERE id = ?") .run(automationLifecycleUserId); expect( - mfaDisabled + await mfaDisabled .createService() .createPrincipal( mfaDisabled.identity, @@ -267,7 +263,7 @@ describe("automation principal lifecycle", () => { rolledBack.setNow(subMilliseconds(automationLifecycleSessionCreatedAt, 1)); try { expect( - rolledBack + await rolledBack .createService() .createPrincipal( rolledBack.identity, @@ -291,7 +287,7 @@ describe("automation principal lifecycle", () => { try { expect( - service.createPrincipal( + await service.createPrincipal( fixture.identity, { ...principalInput, @@ -320,7 +316,7 @@ describe("automation principal lifecycle", () => { }, }); expect( - service.createPrincipal( + await service.createPrincipal( authorized.identity, principalInput, authorized.metadata @@ -339,7 +335,11 @@ describe("automation principal lifecycle", () => { }, }); expect( - service.createPrincipal(stale.identity, principalInput, stale.metadata) + await service.createPrincipal( + stale.identity, + principalInput, + stale.metadata + ) ).toEqual({ status: "step-up-required" }); } finally { stale.database.sqlite.close(true); @@ -352,7 +352,7 @@ describe("automation principal lifecycle", () => { const service = fixture.createService(); try { - const created = service.createPrincipal( + const created = await service.createPrincipal( fixture.identity, { ...principalInput, @@ -438,7 +438,7 @@ describe("automation principal lifecycle", () => { ); try { - fixture.repository.withImmediateTransaction((unit) => { + await fixture.repository.withImmediateTransaction((unit) => { unit.insertPrincipalIfAvailable({ createdAt: automationLifecycleInitialNow, disabledAt: null, @@ -469,7 +469,7 @@ describe("automation principal lifecycle", () => { }); expect( - service.createPrincipal( + await service.createPrincipal( fixture.identity, principalInput, fixture.metadata @@ -498,17 +498,16 @@ describe("automation principal lifecycle", () => { const service = fixture.createService(); try { - expect( - service.createPrincipal( - fixture.identity, - principalInput, - fixture.metadata - ).status - ).toBe("created"); + const created = await service.createPrincipal( + fixture.identity, + principalInput, + fixture.metadata + ); + expect(created.status).toBe("created"); const createdAudits = readAutomationAuditEvents(fixture.database.sqlite); fixture.setNow(addMilliseconds(automationLifecycleInitialNow, 1)); - const unchanged = service.replaceCapabilities( + const unchanged = await service.replaceCapabilities( fixture.identity, { capabilities: ["notifications:read"], @@ -530,7 +529,7 @@ describe("automation principal lifecycle", () => { const changedAt = addMilliseconds(automationLifecycleInitialNow, 2); fixture.setNow(changedAt); - const winner = service.replaceCapabilities( + const winner = await service.replaceCapabilities( fixture.identity, { capabilities: ["notifications:read", "reports:read"], @@ -539,7 +538,7 @@ describe("automation principal lifecycle", () => { }, fixture.metadata ); - const stale = service.replaceCapabilities( + const stale = await service.replaceCapabilities( fixture.identity, { capabilities: ["reports:read"], @@ -585,7 +584,7 @@ describe("automation principal lifecycle", () => { const service = fixture.createService(); try { - const created = service.createPrincipal( + const created = await service.createPrincipal( fixture.identity, principalInput, fixture.metadata @@ -595,7 +594,7 @@ describe("automation principal lifecycle", () => { const disabledAt = addMilliseconds(automationLifecycleInitialNow, 1); fixture.setNow(disabledAt); - const first = service.disablePrincipal( + const first = await service.disablePrincipal( fixture.identity, { expectedAuthorizationVersion: 1, @@ -604,7 +603,7 @@ describe("automation principal lifecycle", () => { fixture.metadata ); const auditCount = readAutomationAuditEvents(fixture.database.sqlite).length; - const retry = service.disablePrincipal( + const retry = await service.disablePrincipal( fixture.identity, { expectedAuthorizationVersion: 1, @@ -641,7 +640,7 @@ describe("automation principal lifecycle", () => { const service = fixture.createService(); try { - const created = service.createPrincipal( + const created = await service.createPrincipal( fixture.identity, principalInput, fixture.metadata @@ -649,7 +648,7 @@ describe("automation principal lifecycle", () => { expect(created.status).toBe("created"); if (created.status !== "created") return; - fixture.repository.withImmediateTransaction((unit) => { + await fixture.repository.withImmediateTransaction((unit) => { for (let index = 0; index < 4; index += 1) { const token = deterministicAutomationToken(500 + index); expect( @@ -671,7 +670,7 @@ describe("automation principal lifecycle", () => { const disabledAt = addMilliseconds(automationLifecycleInitialNow, 1); fixture.setNow(disabledAt); expect( - service.disablePrincipal( + await service.disablePrincipal( fixture.identity, { expectedAuthorizationVersion: 1, @@ -702,7 +701,7 @@ describe("automation principal lifecycle", () => { const service = fixture.createService(); try { - const created = service.createPrincipal( + const created = await service.createPrincipal( fixture.identity, principalInput, fixture.metadata @@ -711,7 +710,7 @@ describe("automation principal lifecycle", () => { if (created.status !== "created") return; const futureCredentialAt = addMinutes(automationLifecycleInitialNow, 2); fixture.setNow(futureCredentialAt); - const futureCredential = service.createCredential( + const futureCredential = await service.createCredential( fixture.identity, { credential: { label: "Future credential" }, @@ -725,7 +724,7 @@ describe("automation principal lifecycle", () => { const disabledAt = addMinutes(automationLifecycleInitialNow, 1); fixture.setNow(disabledAt); expect( - service.disablePrincipal( + await service.disablePrincipal( fixture.identity, { expectedAuthorizationVersion: 1, diff --git a/greenfield/src/server/domains/security/automation/lifecycleRepository.test.ts b/greenfield/src/server/domains/security/automation/lifecycleRepository.test.ts index 1d12efb77..261775282 100644 --- a/greenfield/src/server/domains/security/automation/lifecycleRepository.test.ts +++ b/greenfield/src/server/domains/security/automation/lifecycleRepository.test.ts @@ -15,6 +15,7 @@ import { validAutomationCredentialInsert, validAutomationPrincipalInsert, } from "../../../database/validation/testSupport/securityRows.ts"; +import { testImmediateDatabaseWriteAdmission } from "../../../test/support/databaseWriteAdmission.ts"; import { openFreshMigratedDatabase } from "../../../test/support/freshDatabase.ts"; import { createAutomationLifecycleRepository } from "./lifecycleRepository.ts"; @@ -39,7 +40,10 @@ async function openAutomationRepositoryFixture() { const database = await openFreshMigratedDatabase(); return { ...database, - repository: createAutomationLifecycleRepository(database.orm), + repository: createAutomationLifecycleRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ), }; } @@ -53,7 +57,8 @@ describe("automation lifecycle repository", () => { const competing = new Database(databasePath, { strict: true }); competing.run("PRAGMA busy_timeout = 0"); const repository = createAutomationLifecycleRepository( - drizzle({ client: primary }) + drizzle({ client: primary }), + testImmediateDatabaseWriteAdmission ); try { @@ -67,7 +72,7 @@ describe("automation lifecycle repository", () => { let callbackFinished = false; let immediateCompetingWriterFailure: unknown; - const result = repository.withImmediateTransaction(() => { + const result = await repository.withImmediateTransaction(() => { try { competing.run("BEGIN IMMEDIATE"); competing.run("ROLLBACK"); @@ -97,7 +102,7 @@ describe("automation lifecycle repository", () => { const fixture = await openAutomationRepositoryFixture(); try { - const created = fixture.repository.withImmediateTransaction((unit) => { + const created = await fixture.repository.withImmediateTransaction((unit) => { const principal = unit.insertPrincipalIfAvailable( validAutomationPrincipalInsert ); @@ -165,7 +170,7 @@ describe("automation lifecycle repository", () => { const principalIds = ["pagination-a", "pagination-b", "pagination-c"]; try { - fixture.repository.withImmediateTransaction((unit) => { + await fixture.repository.withImmediateTransaction((unit) => { for (const principalId of principalIds) { expect( unit.insertPrincipalIfAvailable({ @@ -231,7 +236,7 @@ describe("automation lifecycle repository", () => { const checkedAt = addMinutes(securityCreatedAt, 10); try { - fixture.repository.withImmediateTransaction((unit) => { + await fixture.repository.withImmediateTransaction((unit) => { unit.insertPrincipalIfAvailable(validAutomationPrincipalInsert); unit.insertPrincipalIfAvailable({ ...validAutomationPrincipalInsert, @@ -289,7 +294,7 @@ describe("automation lifecycle repository", () => { const secondGrantedAt = addMilliseconds(securityCreatedAt, 2); try { - fixture.repository.withImmediateTransaction((unit) => { + await fixture.repository.withImmediateTransaction((unit) => { unit.insertPrincipalIfAvailable(validAutomationPrincipalInsert); expect( unit.replaceCapabilities({ @@ -344,7 +349,7 @@ describe("automation lifecycle repository", () => { const laterRevocationAttempt = addMilliseconds(securityCreatedAt, 2); try { - fixture.repository.withImmediateTransaction((unit) => { + await fixture.repository.withImmediateTransaction((unit) => { unit.insertPrincipalIfAvailable(validAutomationPrincipalInsert); expect( unit.insertCredentialIfAvailable(validAutomationCredentialInsert) @@ -410,7 +415,7 @@ describe("automation lifecycle repository", () => { const earlierRevokedAt = addMinutes(securityCreatedAt, 3); try { - fixture.repository.withImmediateTransaction((unit) => { + await fixture.repository.withImmediateTransaction((unit) => { unit.insertPrincipalIfAvailable(validAutomationPrincipalInsert); unit.insertCredentialIfAvailable({ ...validAutomationCredentialInsert, @@ -483,12 +488,12 @@ describe("automation lifecycle repository", () => { const collidingPrincipalId = "collision-target"; try { - fixture.repository.withImmediateTransaction((unit) => { + await fixture.repository.withImmediateTransaction((unit) => { unit.insertPrincipalIfAvailable(validAutomationPrincipalInsert); unit.insertCredentialIfAvailable(validAutomationCredentialInsert); }); - expect(() => + expect( fixture.repository.withImmediateTransaction((unit) => { unit.insertAuditEvent(validAuditEventInsert); expect( @@ -507,7 +512,7 @@ describe("automation lifecycle repository", () => { ).toBeUndefined(); throw new Error("forced automation credential collision rollback"); }) - ).toThrow("forced automation credential collision rollback"); + ).rejects.toThrow("forced automation credential collision rollback"); expect( fixture.repository.findPrincipal(collidingPrincipalId) diff --git a/greenfield/src/server/domains/security/automation/lifecycleRepository.ts b/greenfield/src/server/domains/security/automation/lifecycleRepository.ts index ef8079fc4..72e8cb268 100644 --- a/greenfield/src/server/domains/security/automation/lifecycleRepository.ts +++ b/greenfield/src/server/domains/security/automation/lifecycleRepository.ts @@ -1,5 +1,6 @@ import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; +import type { ImmediateDatabaseWriteAdmission } from "../../../database/immediateWriteAdmission.ts"; import type { SecurityTransaction, SynchronousResult, @@ -15,11 +16,14 @@ import { DrizzleAutomationLifecycleUnitOfWork } from "./lifecycleRepositoryUnitO type DrizzleTransactionCallback = Parameters[0]; /** - * Creates synchronous deferred/immediate automation-security transactions. - * @returns A validated repository bound to the supplied process database. + * Creates automation-security transactions with synchronous callbacks. + * @param database Process-owned Drizzle SQLite database. + * @param writeAdmission Process-owned bounded immediate-write admission. + * @returns A repository with synchronous callbacks and async immediate writes. */ export function createAutomationLifecycleRepository( - database: SQLiteBunDatabase + database: SQLiteBunDatabase, + writeAdmission: ImmediateDatabaseWriteAdmission ): AutomationLifecycleRepository { const reader = new DrizzleAutomationLifecycleReader(database); @@ -42,14 +46,18 @@ export function createAutomationLifecycleRepository( callback: ( unit: AutomationLifecycleUnitOfWork ) => SynchronousResult | never - ): T { - const transactionCallback = ((transaction: SecurityTransaction) => - callback( - new DrizzleAutomationLifecycleUnitOfWork(transaction) - )) as DrizzleTransactionCallback; - return database.transaction(transactionCallback, { - behavior: "immediate", - }) as T; + ): Promise { + return writeAdmission.run((markTransactionStarted) => { + const transactionCallback = ((transaction: SecurityTransaction) => { + markTransactionStarted(); + return callback( + new DrizzleAutomationLifecycleUnitOfWork(transaction) + ); + }) as DrizzleTransactionCallback; + return database.transaction(transactionCallback, { + behavior: "immediate", + }) as T; + }); }, withReadTransaction( callback: ( diff --git a/greenfield/src/server/domains/security/automation/lifecycleRepositoryTypes.ts b/greenfield/src/server/domains/security/automation/lifecycleRepositoryTypes.ts index 00aafff90..1ad56104a 100644 --- a/greenfield/src/server/domains/security/automation/lifecycleRepositoryTypes.ts +++ b/greenfield/src/server/domains/security/automation/lifecycleRepositoryTypes.ts @@ -122,7 +122,7 @@ export interface AutomationLifecycleUnitOfWork export interface AutomationLifecycleRepository extends AutomationLifecycleReader { withImmediateTransaction( callback: (unit: AutomationLifecycleUnitOfWork) => SynchronousResult - ): T; + ): Promise; withReadTransaction( callback: (reader: AutomationLifecycleReader) => SynchronousResult ): T; diff --git a/greenfield/src/server/domains/security/automation/lifecycleTypes.ts b/greenfield/src/server/domains/security/automation/lifecycleTypes.ts index 6fc002f31..343c4450b 100644 --- a/greenfield/src/server/domains/security/automation/lifecycleTypes.ts +++ b/greenfield/src/server/domains/security/automation/lifecycleTypes.ts @@ -41,34 +41,37 @@ export interface AutomationSecurityLifecycleService { identity: AuthenticatedBrowserIdentity, input: CreateAutomationCredentialInput, metadata: AuthenticationRequestMetadata - ): + ): Promise< | { readonly result: CreateAutomationCredentialResult; readonly status: "created"; } | AutomationAdministrationPolicyFailure | AutomationAdministrationTargetFailure - | AutomationCredentialGenerationFailure; + | AutomationCredentialGenerationFailure + >; createPrincipal( identity: AuthenticatedBrowserIdentity, input: CreateAutomationPrincipalInput, metadata: AuthenticationRequestMetadata - ): + ): Promise< | { readonly result: CreateAutomationPrincipalResult; readonly status: "created" } | AutomationAdministrationPolicyFailure | AutomationCredentialGenerationFailure - | { readonly status: "conflict" }; + | { readonly status: "conflict" } + >; disablePrincipal( identity: AuthenticatedBrowserIdentity, input: DisableAutomationPrincipalInput, metadata: AuthenticationRequestMetadata - ): + ): Promise< | { readonly result: DisableAutomationPrincipalResult; readonly status: "disabled"; } | AutomationAdministrationPolicyFailure - | AutomationAdministrationTargetFailure; + | AutomationAdministrationTargetFailure + >; listCredentials( identity: AuthenticatedBrowserIdentity, input: ListAutomationCredentialsInput @@ -86,36 +89,39 @@ export interface AutomationSecurityLifecycleService { identity: AuthenticatedBrowserIdentity, input: ReplaceAutomationCapabilitiesInput, metadata: AuthenticationRequestMetadata - ): + ): Promise< | { readonly result: ReplaceAutomationCapabilitiesResult; readonly status: "replaced"; } | AutomationAdministrationPolicyFailure - | AutomationAdministrationTargetFailure; + | AutomationAdministrationTargetFailure + >; revokeCredential( identity: AuthenticatedBrowserIdentity, input: RevokeAutomationCredentialInput, metadata: AuthenticationRequestMetadata - ): + ): Promise< | { readonly result: RevokeAutomationCredentialResult; readonly status: "revoked"; } | AutomationAdministrationPolicyFailure - | AutomationAdministrationTargetFailure; + | AutomationAdministrationTargetFailure + >; rotateCredential( identity: AuthenticatedBrowserIdentity, input: RotateAutomationCredentialInput, metadata: AuthenticationRequestMetadata - ): + ): Promise< | { readonly result: RotateAutomationCredentialResult; readonly status: "rotated"; } | AutomationAdministrationPolicyFailure | AutomationAdministrationTargetFailure - | AutomationCredentialGenerationFailure; + | AutomationCredentialGenerationFailure + >; } export interface AutomationSecurityLifecycleDependencies { diff --git a/greenfield/src/server/domains/security/automation/principalOperations.ts b/greenfield/src/server/domains/security/automation/principalOperations.ts index c4aeac8a8..faa592d72 100644 --- a/greenfield/src/server/domains/security/automation/principalOperations.ts +++ b/greenfield/src/server/domains/security/automation/principalOperations.ts @@ -67,10 +67,10 @@ export function createAutomationPrincipalOperations( "createPrincipal" | "disablePrincipal" | "listPrincipals" | "replaceCapabilities" > { return { - createPrincipal(identity, input, metadata) { + async createPrincipal(identity, input, metadata) { const generation = generateCredentialMaterials(context); try { - return context.repository.withImmediateTransaction((unit) => { + return await context.repository.withImmediateTransaction((unit) => { const createdAt = context.now(); const policy = context.authorizeAdministration( unit, @@ -163,9 +163,9 @@ export function createAutomationPrincipalOperations( } }, - disablePrincipal(identity, input, metadata) { + async disablePrincipal(identity, input, metadata) { try { - return context.repository.withImmediateTransaction((unit) => { + return await context.repository.withImmediateTransaction((unit) => { const disabledAt = context.now(); const policy = context.authorizeAdministration( unit, @@ -266,9 +266,9 @@ export function createAutomationPrincipalOperations( } }, - replaceCapabilities(identity, input, metadata) { + async replaceCapabilities(identity, input, metadata) { try { - return context.repository.withImmediateTransaction((unit) => { + return await context.repository.withImmediateTransaction((unit) => { const replacedAt = context.now(); const policy = context.authorizeAdministration( unit, diff --git a/greenfield/src/server/domains/security/automation/procedures.test.ts b/greenfield/src/server/domains/security/automation/procedures.test.ts index b881dc6f8..9e0123aa8 100644 --- a/greenfield/src/server/domains/security/automation/procedures.test.ts +++ b/greenfield/src/server/domains/security/automation/procedures.test.ts @@ -67,22 +67,25 @@ const createCredentialInput = { function successfulAutomationLifecycle(): AutomationSecurityLifecycleService { return createTestAutomationSecurityLifecycleService({ - createCredential: () => ({ - result: { credential, token }, - status: "created", - }), - createPrincipal: () => ({ - result: { credential, principal, token }, - status: "created", - }), - disablePrincipal: () => ({ - result: { - changed: true, - principal: disabledPrincipal, - revokedCredentials: 1, - }, - status: "disabled", - }), + createCredential: () => + Promise.resolve({ + result: { credential, token }, + status: "created", + }), + createPrincipal: () => + Promise.resolve({ + result: { credential, principal, token }, + status: "created", + }), + disablePrincipal: () => + Promise.resolve({ + result: { + changed: true, + principal: disabledPrincipal, + revokedCredentials: 1, + }, + status: "disabled", + }), listCredentials: () => ({ result: { credentials: [credential], @@ -99,21 +102,24 @@ function successfulAutomationLifecycle(): AutomationSecurityLifecycleService { }, status: "listed", }), - replaceCapabilities: () => ({ - result: { changed: false, principal }, - status: "replaced", - }), - revokeCredential: () => ({ - result: { credential: revokedCredential, revoked: true }, - status: "revoked", - }), - rotateCredential: () => ({ - result: { - credential: replacementCredential, - token: replacementToken, - }, - status: "rotated", - }), + replaceCapabilities: () => + Promise.resolve({ + result: { changed: false, principal }, + status: "replaced", + }), + revokeCredential: () => + Promise.resolve({ + result: { credential: revokedCredential, revoked: true }, + status: "revoked", + }), + rotateCredential: () => + Promise.resolve({ + result: { + credential: replacementCredential, + token: replacementToken, + }, + status: "rotated", + }), }); } diff --git a/greenfield/src/server/domains/security/automation/routes.ts b/greenfield/src/server/domains/security/automation/routes.ts index 63c0f55ba..61870ced2 100644 --- a/greenfield/src/server/domains/security/automation/routes.ts +++ b/greenfield/src/server/domains/security/automation/routes.ts @@ -127,8 +127,8 @@ export const automationSecurityRoutes = { createPrincipal: sessionProcedure .input(createAutomationPrincipalInputSchema) .output(createAutomationPrincipalResultSchema) - .mutation(({ ctx, input, signal }) => { - const result = ctx.automationSecurityLifecycle.createPrincipal( + .mutation(async ({ ctx, input, signal }) => { + const result = await ctx.automationSecurityLifecycle.createPrincipal( ctx.sessionIdentity, input, authenticationRequestMetadata(ctx, signal) @@ -148,8 +148,8 @@ export const automationSecurityRoutes = { createCredential: sessionProcedure .input(createAutomationCredentialInputSchema) .output(createAutomationCredentialResultSchema) - .mutation(({ ctx, input, signal }) => { - const result = ctx.automationSecurityLifecycle.createCredential( + .mutation(async ({ ctx, input, signal }) => { + const result = await ctx.automationSecurityLifecycle.createCredential( ctx.sessionIdentity, input, authenticationRequestMetadata(ctx, signal) @@ -162,8 +162,8 @@ export const automationSecurityRoutes = { rotateCredential: sessionProcedure .input(rotateAutomationCredentialInputSchema) .output(rotateAutomationCredentialResultSchema) - .mutation(({ ctx, input, signal }) => { - const result = ctx.automationSecurityLifecycle.rotateCredential( + .mutation(async ({ ctx, input, signal }) => { + const result = await ctx.automationSecurityLifecycle.rotateCredential( ctx.sessionIdentity, input, authenticationRequestMetadata(ctx, signal) @@ -176,8 +176,8 @@ export const automationSecurityRoutes = { revokeCredential: sessionProcedure .input(revokeAutomationCredentialInputSchema) .output(revokeAutomationCredentialResultSchema) - .mutation(({ ctx, input, signal }) => { - const result = ctx.automationSecurityLifecycle.revokeCredential( + .mutation(async ({ ctx, input, signal }) => { + const result = await ctx.automationSecurityLifecycle.revokeCredential( ctx.sessionIdentity, input, authenticationRequestMetadata(ctx, signal) @@ -190,8 +190,8 @@ export const automationSecurityRoutes = { replaceCapabilities: sessionProcedure .input(replaceAutomationCapabilitiesInputSchema) .output(replaceAutomationCapabilitiesResultSchema) - .mutation(({ ctx, input, signal }) => { - const result = ctx.automationSecurityLifecycle.replaceCapabilities( + .mutation(async ({ ctx, input, signal }) => { + const result = await ctx.automationSecurityLifecycle.replaceCapabilities( ctx.sessionIdentity, input, authenticationRequestMetadata(ctx, signal) @@ -211,8 +211,8 @@ export const automationSecurityRoutes = { disablePrincipal: sessionProcedure .input(disableAutomationPrincipalInputSchema) .output(disableAutomationPrincipalResultSchema) - .mutation(({ ctx, input, signal }) => { - const result = ctx.automationSecurityLifecycle.disablePrincipal( + .mutation(async ({ ctx, input, signal }) => { + const result = await ctx.automationSecurityLifecycle.disablePrincipal( ctx.sessionIdentity, input, authenticationRequestMetadata(ctx, signal) diff --git a/greenfield/src/server/domains/security/automation/testSupport/lifecycle.ts b/greenfield/src/server/domains/security/automation/testSupport/lifecycle.ts index dea49e1a3..229f47603 100644 --- a/greenfield/src/server/domains/security/automation/testSupport/lifecycle.ts +++ b/greenfield/src/server/domains/security/automation/testSupport/lifecycle.ts @@ -12,6 +12,7 @@ import { parseOpaqueToken, type GeneratedOpaqueToken, } from "../../../../shared/opaqueToken.ts"; +import { testImmediateDatabaseWriteAdmission } from "../../../../test/support/databaseWriteAdmission.ts"; import { openFreshMigratedDatabase } from "../../../../test/support/freshDatabase.ts"; import { testDashboardPasswordHash } from "../../../../test/support/securityPassword.ts"; import type { @@ -94,7 +95,7 @@ export function withAutomationLifecycleRepositoryHooks( ...repository, withImmediateTransaction( callback: (unit: AutomationLifecycleUnitOfWork) => SynchronousResult - ): T { + ): Promise { return repository.withImmediateTransaction((unit) => { hooks.beforeImmediateCallback?.(); return callback(unit); @@ -173,7 +174,10 @@ export async function openAutomationLifecycleFixture(): Promise { - const result = ctx.mfaAccountLifecycle.removeTotpFactor( + .mutation(async ({ ctx, input, signal }) => { + const result = await ctx.mfaAccountLifecycle.removeTotpFactor( ctx.sessionIdentity, input, authenticationRequestMetadata(ctx, signal) diff --git a/greenfield/src/server/domains/security/mfa/accountLifecycle.factors.test.ts b/greenfield/src/server/domains/security/mfa/accountLifecycle.factors.test.ts index 1dc096e28..d02d89d2c 100644 --- a/greenfield/src/server/domains/security/mfa/accountLifecycle.factors.test.ts +++ b/greenfield/src/server/domains/security/mfa/accountLifecycle.factors.test.ts @@ -144,7 +144,7 @@ describe("MFA account factor lifecycle", () => { ).toEqual({ status: "factor-limit" }); for (const factorId of factorIds.slice(0, -1)) { expect( - harness.service.removeTotpFactor( + await harness.service.removeTotpFactor( enabled.identity, { factorId }, accountLifecycleMetadata(`remove-${factorId}`) @@ -152,7 +152,7 @@ describe("MFA account factor lifecycle", () => { ).toMatchObject({ factorId, removed: true, status: "removed" }); } expect( - harness.service.removeTotpFactor( + await harness.service.removeTotpFactor( enabled.identity, { factorId: factorIds.at(-1)! }, accountLifecycleMetadata("remove-final") diff --git a/greenfield/src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts b/greenfield/src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts index 93dfe52de..00e4829e7 100644 --- a/greenfield/src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts +++ b/greenfield/src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts @@ -56,7 +56,7 @@ describe("MFA account maintenance lifecycle", () => { accountLifecycleMetadata("begin-pending-before-disable") ); expect(pending.status).toBe("created"); - harness.repository.withImmediateTransaction((unit) => { + await harness.repository.withImmediateTransaction((unit) => { unit.insertSession({ ...validAuthSessionInsert, authenticatedAt: subMinutes(accountLifecycleNow, 5), diff --git a/greenfield/src/server/domains/security/mfa/accountLifecycle.proofs.test.ts b/greenfield/src/server/domains/security/mfa/accountLifecycle.proofs.test.ts index ae86f6821..dd7414f34 100644 --- a/greenfield/src/server/domains/security/mfa/accountLifecycle.proofs.test.ts +++ b/greenfield/src/server/domains/security/mfa/accountLifecycle.proofs.test.ts @@ -219,7 +219,7 @@ describe("MFA account proof lifecycle", () => { const harness = await createAccountLifecycleHarness(); try { const enabled = await enableAccountMfa(harness); - harness.insertUnavailableTotpFactor(); + await harness.insertUnavailableTotpFactor(); const initialDecryptionCalls = harness.totpDecryptionCalls(); const results = []; diff --git a/greenfield/src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts b/greenfield/src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts index d72a8bc8b..7ade216b7 100644 --- a/greenfield/src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts +++ b/greenfield/src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts @@ -136,18 +136,19 @@ const immediateWebAuthnRuntime: Pick< const decision = options.onBeforeStart?.() ?? { proceed: true as const }; if (!decision.proceed) return decision.value; const signal = options.signal ?? new AbortController().signal; + let value: T; try { - const value = await work(signal); - options.onResultBeforeRelease?.(value); - return value; + value = await work(signal); } catch { options.signal?.throwIfAborted(); const failure = new AuthenticationUpstreamUnavailableError({ operation: "webauthn", }); - options.onFailureBeforeRelease?.(failure); + await options.onFailureBeforeRelease?.(failure); throw failure; } + await options.onResultBeforeRelease?.(value); + return value; }, }); @@ -274,7 +275,7 @@ describe("MFA account WebAuthn lifecycle", () => { ); try { const enabled = await enableAccountMfa(harness); - harness.repository.withImmediateTransaction((unit) => + await harness.repository.withImmediateTransaction((unit) => unit.insertWebAuthnCredential({ algorithm: -7, backedUp: false, @@ -292,7 +293,7 @@ describe("MFA account WebAuthn lifecycle", () => { }) ); expect( - harness.service.removeTotpFactor( + await harness.service.removeTotpFactor( enabled.identity, { factorId: enabled.factorId }, accountLifecycleMetadata("protect-totp-from-old-rp") @@ -348,7 +349,7 @@ describe("MFA account WebAuthn lifecycle", () => { ) ).toMatchObject({ counter: 1, lastUsedAt: accountLifecycleNow }); expect( - harness.service.removeTotpFactor( + await harness.service.removeTotpFactor( { sessionId: verified.session.id, userId: accountLifecycleUserId, @@ -358,7 +359,7 @@ describe("MFA account WebAuthn lifecycle", () => { ) ).toMatchObject({ removed: true, status: "removed" }); expect( - harness.service.removeWebAuthnCredential( + await harness.service.removeWebAuthnCredential( { sessionId: verified.session.id, userId: accountLifecycleUserId, @@ -368,7 +369,7 @@ describe("MFA account WebAuthn lifecycle", () => { ) ).toEqual({ status: "final-factor" }); expect( - harness.service.removeWebAuthnCredential( + await harness.service.removeWebAuthnCredential( { sessionId: verified.session.id, userId: accountLifecycleUserId, @@ -388,7 +389,7 @@ describe("MFA account WebAuthn lifecycle", () => { ); try { const enabled = await enableAccountMfa(harness); - harness.repository.withImmediateTransaction((unit) => { + await harness.repository.withImmediateTransaction((unit) => { unit.insertWebAuthnCredential({ algorithm: -7, backedUp: false, @@ -423,14 +424,14 @@ describe("MFA account WebAuthn lifecycle", () => { }); expect( - harness.service.removeWebAuthnCredential( + await harness.service.removeWebAuthnCredential( enabled.identity, { credentialId: oldCredentialInternalId }, accountLifecycleMetadata("remove-first-drifted-only-factor") ) ).toMatchObject({ removed: true, status: "removed" }); expect( - harness.service.removeWebAuthnCredential( + await harness.service.removeWebAuthnCredential( enabled.identity, { credentialId: secondOldCredentialInternalId }, accountLifecycleMetadata("protect-last-drifted-only-factor") @@ -525,7 +526,7 @@ describe("MFA account WebAuthn lifecycle", () => { }) ) .run(); - harness.repository.withImmediateTransaction((unit) => + await harness.repository.withImmediateTransaction((unit) => unit.insertWebAuthnCredential({ algorithm: -7, backedUp: true, @@ -602,20 +603,20 @@ describe("MFA account WebAuthn lifecycle", () => { ), }) as Pick; const timeoutRuntime = Object.freeze({ - runWebAuthnVerification( + async runWebAuthnVerification( _work: (signal: AbortSignal) => Promise, options: AuthenticationVerificationWorkOptions ): Promise { const decision = options.onBeforeStart?.() ?? { proceed: true as const, }; - if (!decision.proceed) return Promise.resolve(decision.value); + if (!decision.proceed) return decision.value; const failure = new AuthenticationWorkTimeoutError({ operation: "webauthn", timeoutMs: options.timeoutMs, }); - options.onFailureBeforeRelease?.(failure); - return Promise.reject(failure); + await options.onFailureBeforeRelease?.(failure); + throw failure; }, }); @@ -722,10 +723,10 @@ describe("MFA account WebAuthn lifecycle", () => { if (!decision.proceed) return decision.value; const value = await work(new AbortController().signal); if (!cancelActiveWork) { - options.onResultBeforeRelease?.(value); + await options.onResultBeforeRelease?.(value); return value; } - options.onCancellationBeforeRelease?.(); + await options.onCancellationBeforeRelease?.(); throw new DOMException("Test request cancelled", "AbortError"); }, }); diff --git a/greenfield/src/server/domains/security/mfa/accountLifecycleCrypto.ts b/greenfield/src/server/domains/security/mfa/accountLifecycleCrypto.ts index bb3ed4264..bbb2e60da 100644 --- a/greenfield/src/server/domains/security/mfa/accountLifecycleCrypto.ts +++ b/greenfield/src/server/domains/security/mfa/accountLifecycleCrypto.ts @@ -41,7 +41,9 @@ export interface AccountLifecycleCryptoHelpers { checkedAt: Date, signal: AbortSignal | undefined, recheckRateLimit: (() => RateLimitedResult | undefined) | undefined, - settleVerification: (verification: ConfirmedTotpFactorsVerification) => Settlement + settleVerification: ( + verification: ConfirmedTotpFactorsVerification + ) => Promise ) => Promise; } @@ -119,7 +121,7 @@ export function createAccountLifecycleCryptoHelpers( settleVerification ) => { if (factors.length === 0) { - return settleVerification({ kind: "not-matched" }); + return await settleVerification({ kind: "not-matched" }); } const admission = await options.totpWorkGate.run(async () => { const activeLimit = recheckRateLimit?.(); @@ -178,7 +180,7 @@ export function createAccountLifecycleCryptoHelpers( verification = { kind: "not-matched" }; } signal?.throwIfAborted(); - return settleVerification(verification); + return await settleVerification(verification); }, signal); return admission.accepted ? admission.value diff --git a/greenfield/src/server/domains/security/mfa/accountLifecycleTypes.ts b/greenfield/src/server/domains/security/mfa/accountLifecycleTypes.ts index 199ee227d..7f01084a9 100644 --- a/greenfield/src/server/domains/security/mfa/accountLifecycleTypes.ts +++ b/greenfield/src/server/domains/security/mfa/accountLifecycleTypes.ts @@ -269,12 +269,12 @@ export interface MfaAccountLifecycleService { identity: AuthenticatedBrowserIdentity, input: RemoveTotpFactorInput, metadata: AuthenticationRequestMetadata - ): RemoveTotpFactorResult; + ): Promise; removeWebAuthnCredential( identity: AuthenticatedBrowserIdentity, input: RemoveWebAuthnCredentialInput, metadata: AuthenticationRequestMetadata - ): RemoveWebAuthnCredentialResult; + ): Promise; rotateRecoveryCodes( identity: AuthenticatedBrowserIdentity, metadata: AuthenticationRequestMetadata diff --git a/greenfield/src/server/domains/security/mfa/accountMfaDisable.ts b/greenfield/src/server/domains/security/mfa/accountMfaDisable.ts index 5c73a7c17..b050357d7 100644 --- a/greenfield/src/server/domains/security/mfa/accountMfaDisable.ts +++ b/greenfield/src/server/domains/security/mfa/accountMfaDisable.ts @@ -76,11 +76,11 @@ export function createAccountMfaDisableOperation( return { ...activeLimit, status: "rate-limited" }; } - const completeVerification = (valid: boolean) => { + const completeVerification = async (valid: boolean) => { const completedAt = now(); if (!valid) { try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const activeLimit = activeRateLimitForTargets( unit, rateLimitTargets, @@ -145,7 +145,7 @@ export function createAccountMfaDisableOperation( const sessionToken = generateSessionToken(); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const current = currentAccount( unit, identity, diff --git a/greenfield/src/server/domains/security/mfa/accountPasswordReauthentication.ts b/greenfield/src/server/domains/security/mfa/accountPasswordReauthentication.ts index e58a8e70b..2ddde4c50 100644 --- a/greenfield/src/server/domains/security/mfa/accountPasswordReauthentication.ts +++ b/greenfield/src/server/domains/security/mfa/accountPasswordReauthentication.ts @@ -80,11 +80,11 @@ export function createAccountPasswordReauthenticationOperation( return { ...activeLimit, status: "rate-limited" }; } - const completeVerification = (valid: boolean) => { + const completeVerification = async (valid: boolean) => { const verifiedAt = now(); if (!valid) { try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const activeLimit = activeRateLimitForTargets( unit, rateLimitTargets, @@ -137,7 +137,7 @@ export function createAccountPasswordReauthenticationOperation( const sessionToken = generateSessionToken(); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const current = currentAccount( unit, identity, diff --git a/greenfield/src/server/domains/security/mfa/accountRecoveryCodeRotation.ts b/greenfield/src/server/domains/security/mfa/accountRecoveryCodeRotation.ts index af4ff3fea..e7a7ff7fd 100644 --- a/greenfield/src/server/domains/security/mfa/accountRecoveryCodeRotation.ts +++ b/greenfield/src/server/domains/security/mfa/accountRecoveryCodeRotation.ts @@ -80,7 +80,7 @@ export function createAccountRecoveryCodeRotationOperation( const rotatedAt = now(); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const current = currentAccount( unit, identity, diff --git a/greenfield/src/server/domains/security/mfa/accountRecoveryStepUp.ts b/greenfield/src/server/domains/security/mfa/accountRecoveryStepUp.ts index 3d43b582f..b5fd3b44b 100644 --- a/greenfield/src/server/domains/security/mfa/accountRecoveryStepUp.ts +++ b/greenfield/src/server/domains/security/mfa/accountRecoveryStepUp.ts @@ -91,7 +91,7 @@ export function createAccountRecoveryStepUpOperation( return { ...activeLimit, status: "rate-limited" }; } - const completeVerification = (valid: boolean) => { + const completeVerification = async (valid: boolean) => { const verifiedAt = now(); if ( !valid || @@ -99,7 +99,7 @@ export function createAccountRecoveryStepUpOperation( snapshot.recovery === undefined ) { try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const activeLimit = activeRateLimitForTargets( unit, rateLimitTargets, @@ -169,7 +169,7 @@ export function createAccountRecoveryStepUpOperation( const verifiedRecovery = snapshot.recovery; const sessionToken = generateSessionToken(); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const current = currentAccount( unit, identity, diff --git a/greenfield/src/server/domains/security/mfa/accountTotpEnrollmentBegin.ts b/greenfield/src/server/domains/security/mfa/accountTotpEnrollmentBegin.ts index 7e1a4d37f..b626c0e52 100644 --- a/greenfield/src/server/domains/security/mfa/accountTotpEnrollmentBegin.ts +++ b/greenfield/src/server/domains/security/mfa/accountTotpEnrollmentBegin.ts @@ -105,7 +105,7 @@ export function createBeginTotpEnrollmentOperation( }); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const current = currentAccount( unit, identity, diff --git a/greenfield/src/server/domains/security/mfa/accountTotpEnrollmentConfirmation.ts b/greenfield/src/server/domains/security/mfa/accountTotpEnrollmentConfirmation.ts index b6fc99d2f..d71abd166 100644 --- a/greenfield/src/server/domains/security/mfa/accountTotpEnrollmentConfirmation.ts +++ b/greenfield/src/server/domains/security/mfa/accountTotpEnrollmentConfirmation.ts @@ -145,16 +145,16 @@ export function createConfirmTotpEnrollmentOperation( return { account, factor, status: "ready" as const }; }); - const recordInvalidProof = ( + const recordInvalidProof = async ( identity: AuthenticatedBrowserIdentity, input: ConfirmTotpEnrollmentInput, metadata: AuthenticationRequestMetadata, snapshot: ConfirmationSnapshot, rateLimitTargets: readonly AuthenticationRateLimitTarget[], failedAt: Date - ): ConfirmTotpEnrollmentResult => { + ): Promise => { try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const activeLimit = activeRateLimitForTargets( unit, rateLimitTargets, @@ -218,7 +218,7 @@ export function createConfirmTotpEnrollmentOperation( } }; - const commitConfirmation = ( + const commitConfirmation = async ( identity: AuthenticatedBrowserIdentity, input: ConfirmTotpEnrollmentInput, metadata: AuthenticationRequestMetadata, @@ -228,9 +228,9 @@ export function createConfirmTotpEnrollmentOperation( confirmedAt: Date, preparedRecoveryCodes: PreparedRecoveryCodeSet | undefined, sessionToken: GeneratedOpaqueToken | undefined - ): ConfirmTotpEnrollmentResult => { + ): Promise => { try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const current = currentAccount( unit, identity, @@ -421,7 +421,7 @@ export function createConfirmTotpEnrollmentOperation( metadata.signal?.throwIfAborted(); if (verification === undefined) { return { - result: recordInvalidProof( + result: await recordInvalidProof( identity, input, metadata, @@ -434,7 +434,7 @@ export function createConfirmTotpEnrollmentOperation( } if (snapshot.account.user.mfaEnabledAt !== null) { return { - result: commitConfirmation( + result: await commitConfirmation( identity, input, metadata, @@ -475,7 +475,7 @@ export function createConfirmTotpEnrollmentOperation( } metadata.signal?.throwIfAborted(); - return commitConfirmation( + return await commitConfirmation( identity, input, metadata, diff --git a/greenfield/src/server/domains/security/mfa/accountTotpFactorRemoval.ts b/greenfield/src/server/domains/security/mfa/accountTotpFactorRemoval.ts index 41613d4ef..e8fe4b2a5 100644 --- a/greenfield/src/server/domains/security/mfa/accountTotpFactorRemoval.ts +++ b/greenfield/src/server/domains/security/mfa/accountTotpFactorRemoval.ts @@ -40,9 +40,9 @@ export function createRemoveTotpFactorOperation( } = context; return Object.freeze({ - removeTotpFactor(identity, input, metadata) { + async removeTotpFactor(identity, input, metadata) { const occurredAt = now(); - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const account = activeAccount( unit, identity, diff --git a/greenfield/src/server/domains/security/mfa/accountTotpStepUp.ts b/greenfield/src/server/domains/security/mfa/accountTotpStepUp.ts index 8ec2ab593..ac12d036f 100644 --- a/greenfield/src/server/domains/security/mfa/accountTotpStepUp.ts +++ b/greenfield/src/server/domains/security/mfa/accountTotpStepUp.ts @@ -101,7 +101,7 @@ export function createAccountTotpStepUpOperation( ? undefined : { ...activeLimit, status: "rate-limited" as const }; }, - (settledVerification): TotpStepUpResult => { + async (settledVerification): Promise => { const verifiedAt = now(); if (settledVerification.kind !== "matched") { const unblockedStatus = @@ -109,7 +109,7 @@ export function createAccountTotpStepUpOperation( ? "service-unavailable" : "invalid-proof"; try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const activeLimit = activeRateLimitForTargets( unit, rateLimitTargets, @@ -179,7 +179,7 @@ export function createAccountTotpStepUpOperation( const sessionToken = generateSessionToken(); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const current = currentAccount( unit, identity, diff --git a/greenfield/src/server/domains/security/mfa/accountWebAuthnCredentialRemoval.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnCredentialRemoval.ts index b095d9e63..b9c03a345 100644 --- a/greenfield/src/server/domains/security/mfa/accountWebAuthnCredentialRemoval.ts +++ b/greenfield/src/server/domains/security/mfa/accountWebAuthnCredentialRemoval.ts @@ -44,9 +44,9 @@ export function createRemoveWebAuthnCredentialOperation( } = context; return Object.freeze({ - removeWebAuthnCredential(identity, input, metadata) { + async removeWebAuthnCredential(identity, input, metadata) { const occurredAt = now(); - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const account = activeAccount( unit, identity, diff --git a/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentBegin.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentBegin.ts index 27449f249..52030ac82 100644 --- a/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentBegin.ts +++ b/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentBegin.ts @@ -123,7 +123,7 @@ export function createBeginWebAuthnEnrollmentOperation( const expiresAt = addMilliseconds(createdAt, webAuthnCeremonyTimeoutMs); const challengeId = generateId(); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const current = currentAccount( unit, identity, diff --git a/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentConfirmation.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentConfirmation.ts index 63d490eeb..24d91b863 100644 --- a/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentConfirmation.ts +++ b/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentConfirmation.ts @@ -175,13 +175,13 @@ export function createConfirmWebAuthnEnrollmentOperation( input, metadata ); - const consumeWithoutProofFailure = ( + const consumeWithoutProofFailure = async ( outcome: "cancelled" | "failed" - ): ConfirmWebAuthnEnrollmentResult | undefined => { + ): Promise => { const admitted = admission; return admitted === undefined ? undefined - : settlement.consumeWithoutProofFailure(admitted, outcome); + : await settlement.consumeWithoutProofFailure(admitted, outcome); }; try { @@ -323,13 +323,14 @@ export function createConfirmWebAuthnEnrollmentOperation( ? { proceed: true } : { proceed: false, value: decision }; }, - onCancellationBeforeRelease: () => { - consumeWithoutProofFailure("cancelled"); + onCancellationBeforeRelease: async () => { + await consumeWithoutProofFailure("cancelled"); }, - onFailureBeforeRelease: () => { - settledResult = consumeWithoutProofFailure("failed"); + onFailureBeforeRelease: async () => { + settledResult = + await consumeWithoutProofFailure("failed"); }, - onResultBeforeRelease: (value) => { + onResultBeforeRelease: async (value) => { if (value.status !== "verified-work") return; const admitted = admission; if (admitted === undefined) { @@ -338,7 +339,7 @@ export function createConfirmWebAuthnEnrollmentOperation( } if (value.verification.status === "verified") { const verifiedSettlement = - settlement.settleVerifiedRegistration( + await settlement.settleVerifiedRegistration( admitted, value.verification.verification, now() @@ -349,10 +350,11 @@ export function createConfirmWebAuthnEnrollmentOperation( settledResult = verifiedSettlement.result; } } else { - settledResult = settlement.recordInvalidRegistration( - admitted, - now() - ); + settledResult = + await settlement.recordInvalidRegistration( + admitted, + now() + ); } }, signal: metadata.signal, @@ -387,7 +389,11 @@ export function createConfirmWebAuthnEnrollmentOperation( if ("status" in prepared) return prepared; metadata.signal?.throwIfAborted(); const confirmedAt = now(); - return settlement.activateFirstCredential(staged, prepared, confirmedAt); + return await settlement.activateFirstCredential( + staged, + prepared, + confirmedAt + ); }, }); } diff --git a/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentSettlement.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentSettlement.ts index 0f7e3667c..c44ea760e 100644 --- a/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentSettlement.ts +++ b/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentSettlement.ts @@ -86,20 +86,20 @@ export interface AccountWebAuthnEnrollmentSettlement { staged: StagedFirstWebAuthnCredential, prepared: PreparedRecoveryCodeSet, confirmedAt: Date - ) => ConfirmWebAuthnEnrollmentResult; + ) => Promise; readonly consumeWithoutProofFailure: ( admission: AccountWebAuthnRegistrationAdmission, outcome: "cancelled" | "failed" - ) => ConfirmWebAuthnEnrollmentResult | undefined; + ) => Promise; readonly recordInvalidRegistration: ( admission: AccountWebAuthnRegistrationAdmission, failedAt: Date - ) => ConfirmWebAuthnEnrollmentResult; + ) => Promise; readonly settleVerifiedRegistration: ( admission: AccountWebAuthnRegistrationAdmission, verification: VerifiedWebAuthnRegistration, verifiedAt: Date - ) => VerifiedWebAuthnEnrollmentSettlement; + ) => Promise; } function credentialInsert( @@ -179,12 +179,12 @@ export function createAccountWebAuthnEnrollmentSettlement( } as const); }; - const recordInvalidRegistration = ( + const recordInvalidRegistration = async ( admission: AccountWebAuthnRegistrationAdmission, failedAt: Date - ): ConfirmWebAuthnEnrollmentResult => { + ): Promise => { try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const challenge = unit.findSessionWebAuthnChallenge( identity.sessionId, "registration" @@ -232,13 +232,13 @@ export function createAccountWebAuthnEnrollmentSettlement( } }; - const consumeWithoutProofFailure = ( + const consumeWithoutProofFailure = async ( admission: AccountWebAuthnRegistrationAdmission, outcome: "cancelled" | "failed" - ): ConfirmWebAuthnEnrollmentResult | undefined => { + ): Promise => { const occurredAt = now(); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const current = activeAccountMatchesSnapshot( unit, identity, @@ -289,11 +289,11 @@ export function createAccountWebAuthnEnrollmentSettlement( } }; - const settleVerifiedRegistration = ( + const settleVerifiedRegistration = async ( admission: AccountWebAuthnRegistrationAdmission, verification: VerifiedWebAuthnRegistration, verifiedAt: Date - ): VerifiedWebAuthnEnrollmentSettlement => { + ): Promise => { const candidate = credentialInsert( generateId, input, @@ -303,7 +303,7 @@ export function createAccountWebAuthnEnrollmentSettlement( verifiedAt ); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const challenge = unit.findSessionWebAuthnChallenge( identity.sessionId, "registration" @@ -442,14 +442,14 @@ export function createAccountWebAuthnEnrollmentSettlement( } }; - const activateFirstCredential = ( + const activateFirstCredential = async ( staged: StagedFirstWebAuthnCredential, prepared: PreparedRecoveryCodeSet, confirmedAt: Date - ): ConfirmWebAuthnEnrollmentResult => { + ): Promise => { const sessionToken = generateSessionToken(); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const current = currentAccount( unit, identity, diff --git a/greenfield/src/server/domains/security/mfa/accountWebAuthnRoutes.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnRoutes.ts index 2451411e1..63a55f323 100644 --- a/greenfield/src/server/domains/security/mfa/accountWebAuthnRoutes.ts +++ b/greenfield/src/server/domains/security/mfa/accountWebAuthnRoutes.ts @@ -206,8 +206,8 @@ export const accountWebAuthnRoutes = { removeWebAuthnCredential: sessionProcedure .input(removeWebAuthnCredentialInputSchema) .output(removeWebAuthnCredentialResultSchema) - .mutation(({ ctx, input, signal }) => { - const result = ctx.mfaAccountLifecycle.removeWebAuthnCredential( + .mutation(async ({ ctx, input, signal }) => { + const result = await ctx.mfaAccountLifecycle.removeWebAuthnCredential( ctx.sessionIdentity, input, authenticationRequestMetadata(ctx, signal) diff --git a/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUp.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUp.ts index aef9e8571..0994c1d31 100644 --- a/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUp.ts +++ b/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUp.ts @@ -167,13 +167,13 @@ export function createAccountWebAuthnStepUpOperation( metadata ); const { settleInvalidProof, settleVerified } = settlement; - const consumeWithoutFailure = ( + const consumeWithoutFailure = async ( outcome: "cancelled" | "failed" - ): WebAuthnStepUpResult | undefined => { + ): Promise => { const admitted = admission; return admitted === undefined ? undefined - : settlement.consumeWithoutFailure(admitted, outcome); + : await settlement.consumeWithoutFailure(admitted, outcome); }; try { @@ -326,27 +326,27 @@ export function createAccountWebAuthnStepUpOperation( ? { proceed: true } : { proceed: false, value: decision }; }, - onCancellationBeforeRelease: () => { - consumeWithoutFailure("cancelled"); + onCancellationBeforeRelease: async () => { + await consumeWithoutFailure("cancelled"); }, - onFailureBeforeRelease: () => { - settledResult = consumeWithoutFailure("failed"); + onFailureBeforeRelease: async () => { + settledResult = await consumeWithoutFailure("failed"); }, - onResultBeforeRelease: (value) => { + onResultBeforeRelease: async (value) => { if (value.status !== "verified-work") return; const admitted = admission; if (admitted === undefined) { settledResult = { status: "state-changed" }; return; } - settledResult = - value.verification.status === "verified" - ? settleVerified( - admitted, - value.verification.verification, - now() - ) - : settleInvalidProof(admitted, now()); + settledResult = await (value.verification.status === + "verified" + ? settleVerified( + admitted, + value.verification.verification, + now() + ) + : settleInvalidProof(admitted, now())); }, signal: metadata.signal, timeoutMs: webAuthnVerificationTimeoutMs, diff --git a/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpBegin.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpBegin.ts index 8452a33c3..b8033bc15 100644 --- a/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpBegin.ts +++ b/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpBegin.ts @@ -117,7 +117,7 @@ export function createBeginWebAuthnStepUpOperation( const expiresAt = addMilliseconds(createdAt, webAuthnCeremonyTimeoutMs); const challengeId = generateId(); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const current = currentAccount( unit, identity, diff --git a/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpSettlement.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpSettlement.ts index 18c803119..1937fbbdd 100644 --- a/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpSettlement.ts +++ b/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpSettlement.ts @@ -55,16 +55,16 @@ export interface AccountWebAuthnStepUpSettlement { readonly consumeWithoutFailure: ( admission: AccountWebAuthnStepUpAdmission, outcome: "cancelled" | "failed" - ) => WebAuthnStepUpResult | undefined; + ) => Promise; readonly settleInvalidProof: ( admission: AccountWebAuthnStepUpAdmission, failedAt: Date - ) => WebAuthnStepUpResult; + ) => Promise; readonly settleVerified: ( admission: AccountWebAuthnStepUpAdmission, verification: VerifiedWebAuthnAuthentication, verifiedAt: Date - ) => WebAuthnStepUpResult; + ) => Promise; } /** @@ -86,13 +86,13 @@ export function createAccountWebAuthnStepUpSettlement( } = context; const rateLimitTargets = accountMfaRateLimitTargets(identity.userId); - const consumeWithoutFailure = ( + const consumeWithoutFailure = async ( admitted: AccountWebAuthnStepUpAdmission, outcome: "cancelled" | "failed" - ): WebAuthnStepUpResult | undefined => { + ): Promise => { const occurredAt = now(); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const current = activeAccountMatchesSnapshot( unit, identity, @@ -143,12 +143,12 @@ export function createAccountWebAuthnStepUpSettlement( } }; - const settleInvalidProof = ( + const settleInvalidProof = async ( admitted: AccountWebAuthnStepUpAdmission, failedAt: Date - ): WebAuthnStepUpResult => { + ): Promise => { try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const challenge = unit.findSessionWebAuthnChallenge( identity.sessionId, "step-up" @@ -234,21 +234,21 @@ export function createAccountWebAuthnStepUpSettlement( } }; - const settleVerified = ( + const settleVerified = async ( admitted: AccountWebAuthnStepUpAdmission, verification: VerifiedWebAuthnAuthentication, verifiedAt: Date - ): WebAuthnStepUpResult => { + ): Promise => { if ( admitted.selectedCredential === undefined || verification.credentialId !== admitted.selectedCredential.credentialId ) { - return settleInvalidProof(admitted, verifiedAt); + return await settleInvalidProof(admitted, verifiedAt); } const selectedCredential = admitted.selectedCredential; const sessionToken = generateSessionToken(); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const challenge = unit.findSessionWebAuthnChallenge( identity.sessionId, "step-up" diff --git a/greenfield/src/server/domains/security/mfa/lifecycleRepository.test.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepository.test.ts index 8922835ab..5945c3d80 100644 --- a/greenfield/src/server/domains/security/mfa/lifecycleRepository.test.ts +++ b/greenfield/src/server/domains/security/mfa/lifecycleRepository.test.ts @@ -26,6 +26,7 @@ import { validUserTotpFactorInsert, } from "../../../database/validation/testSupport/securityRows.ts"; import { userInsertSchema } from "../../../database/validation/users.ts"; +import { testImmediateDatabaseWriteAdmission } from "../../../test/support/databaseWriteAdmission.ts"; import { openFreshMigratedDatabase } from "../../../test/support/freshDatabase.ts"; import { createMfaLifecycleRepository, @@ -43,7 +44,10 @@ const exhaustedPendingLoginValidatorHash = "f".repeat(64); async function openMfaRepositoryFixture() { const database = await openFreshMigratedDatabase(); - const repository = createMfaLifecycleRepository(database.orm); + const repository = createMfaLifecycleRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ); try { database.orm @@ -55,7 +59,7 @@ async function openMfaRepositoryFixture() { }) ) .run(); - repository.withImmediateTransaction((unit) => { + await repository.withImmediateTransaction((unit) => { unit.insertSession(validAuthSessionInsert); unit.insertPendingLogin(validAuthPendingLoginInsert); unit.insertTotpFactor({ @@ -87,7 +91,10 @@ describe("MFA lifecycle repository", () => { const primary = new Database(databasePath, { create: true, strict: true }); const competing = new Database(databasePath, { strict: true }); competing.run("PRAGMA busy_timeout = 0"); - const repository = createMfaLifecycleRepository(drizzle({ client: primary })); + const repository = createMfaLifecycleRepository( + drizzle({ client: primary }), + testImmediateDatabaseWriteAdmission + ); try { let deferredCompetingWriterAcquired = false; @@ -100,7 +107,7 @@ describe("MFA lifecycle repository", () => { let callbackFinished = false; let immediateCompetingWriterFailure: unknown; - const result = repository.withImmediateTransaction(() => { + const result = await repository.withImmediateTransaction(() => { try { competing.run("BEGIN IMMEDIATE"); competing.run("ROLLBACK"); @@ -130,7 +137,7 @@ describe("MFA lifecycle repository", () => { const fixture = await openMfaRepositoryFixture(); try { - expect(() => + expect( fixture.repository.withImmediateTransaction((unit) => { expect( unit.advanceTotpLastUsedStep({ @@ -182,7 +189,7 @@ describe("MFA lifecycle repository", () => { unit.deleteRateLimitBucket(rateLimitBucketKey); throw new Error("forced MFA repository rollback"); }) - ).toThrow("forced MFA repository rollback"); + ).rejects.toThrow("forced MFA repository rollback"); expect( fixture.repository.findTotpFactor( @@ -234,10 +241,10 @@ describe("MFA lifecycle repository", () => { userId: securityUserId, }; const stepContenders = [ - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.advanceTotpLastUsedStep(advanceInput) ), - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.advanceTotpLastUsedStep(advanceInput) ), ]; @@ -260,10 +267,10 @@ describe("MFA lifecycle repository", () => { userId: securityUserId, }; const recoveryContenders = [ - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.consumeRecoveryCode(consumeInput) ), - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.consumeRecoveryCode(consumeInput) ), ]; @@ -292,10 +299,10 @@ describe("MFA lifecycle repository", () => { validatorHash: validAuthPendingLoginInsert.validatorHash, }; const consumptionContenders = [ - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.consumePendingLogin(consumeInput) ), - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.consumePendingLogin(consumeInput) ), ]; @@ -306,7 +313,7 @@ describe("MFA lifecycle repository", () => { fixture.repository.findPendingLogin(pendingLoginSelector) ).toBeUndefined(); - fixture.repository.withImmediateTransaction((unit) => { + await fixture.repository.withImmediateTransaction((unit) => { unit.insertPendingLogin({ ...validAuthPendingLoginInsert, id: exhaustedPendingLoginId, @@ -321,17 +328,16 @@ describe("MFA lifecycle repository", () => { validatorHash: exhaustedPendingLoginValidatorHash, }; for (let attempt = 1; attempt < pendingLoginAttemptMaximum; attempt += 1) { - expect( - fixture.repository.withImmediateTransaction((unit) => - unit.incrementPendingLoginAttempt(incrementInput) - )?.attemptCount - ).toBe(attempt); + const incremented = await fixture.repository.withImmediateTransaction( + (unit) => unit.incrementPendingLoginAttempt(incrementInput) + ); + expect(incremented?.attemptCount).toBe(attempt); } const finalAttemptContenders = [ - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.incrementPendingLoginAttempt(incrementInput) ), - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.incrementPendingLoginAttempt(incrementInput) ), ]; @@ -341,7 +347,7 @@ describe("MFA lifecycle repository", () => { ); expect(finalAttemptContenders[1]).toBeUndefined(); expect( - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.consumePendingLogin({ ...consumeInput, id: exhaustedPendingLoginId, diff --git a/greenfield/src/server/domains/security/mfa/lifecycleRepository.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepository.ts index 2df782016..8e6210415 100644 --- a/greenfield/src/server/domains/security/mfa/lifecycleRepository.ts +++ b/greenfield/src/server/domains/security/mfa/lifecycleRepository.ts @@ -1,5 +1,6 @@ import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; +import type { ImmediateDatabaseWriteAdmission } from "../../../database/immediateWriteAdmission.ts"; import type { SecurityTransaction, SynchronousResult, @@ -50,10 +51,12 @@ export type { * Immediate callbacks are deliberately synchronous so no expensive cryptography can * retain the SQLite write lock across an await. * @param database Process-owned Drizzle SQLite database. - * @returns MFA lifecycle repository with deferred reads and immediate writes. + * @param writeAdmission Process-owned bounded immediate-write admission. + * @returns MFA repository with deferred reads and admitted async writes. */ export function createMfaLifecycleRepository( - database: SQLiteBunDatabase + database: SQLiteBunDatabase, + writeAdmission: ImmediateDatabaseWriteAdmission ): MfaLifecycleRepository { const runTransaction = database.transaction.bind(database) as unknown as ( callback: (transaction: SecurityTransaction) => T, @@ -83,11 +86,15 @@ export function createMfaLifecycleRepository( listWebAuthnCredentials: reader.listWebAuthnCredentials.bind(reader), withImmediateTransaction( callback: (unit: MfaLifecycleUnitOfWork) => SynchronousResult | never - ): T { - return runTransaction( - (transaction): T => - callback(new DrizzleMfaLifecycleUnitOfWork(transaction)) as T, - { behavior: "immediate" } + ): Promise { + return writeAdmission.run((markTransactionStarted) => + runTransaction( + (transaction): T => { + markTransactionStarted(); + return callback(new DrizzleMfaLifecycleUnitOfWork(transaction)); + }, + { behavior: "immediate" } + ) ); }, withReadTransaction( diff --git a/greenfield/src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts index 3687941cf..b9e8af1ec 100644 --- a/greenfield/src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts +++ b/greenfield/src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts @@ -16,6 +16,7 @@ import { webAuthnExternalCredentialId, } from "../../../database/validation/testSupport/securityRows.ts"; import { userInsertSchema } from "../../../database/validation/users.ts"; +import { testImmediateDatabaseWriteAdmission } from "../../../test/support/databaseWriteAdmission.ts"; import { openFreshMigratedDatabase } from "../../../test/support/freshDatabase.ts"; import { createMfaLifecycleRepository } from "./lifecycleRepository.ts"; import type { @@ -36,7 +37,10 @@ const thirdUsedAt = addMilliseconds(secondUsedAt, 1); async function openWebAuthnRepositoryFixture() { const database = await openFreshMigratedDatabase(); - const repository = createMfaLifecycleRepository(database.orm); + const repository = createMfaLifecycleRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ); try { database.orm @@ -48,7 +52,7 @@ async function openWebAuthnRepositoryFixture() { }) ) .run(); - repository.withImmediateTransaction((unit) => { + await repository.withImmediateTransaction((unit) => { unit.insertSession(validAuthSessionInsert); unit.insertPendingLogin({ ...validAuthPendingLoginInsert, @@ -96,7 +100,7 @@ describe("MFA lifecycle WebAuthn repository", () => { const fixture = await openWebAuthnRepositoryFixture(); try { - const initial = fixture.repository.withImmediateTransaction((unit) => + const initial = await fixture.repository.withImmediateTransaction((unit) => unit.replaceWebAuthnChallenge(registrationChallenge()) ); expect( @@ -106,13 +110,14 @@ describe("MFA lifecycle WebAuthn repository", () => { ) ).toEqual(initial); - const replacement = fixture.repository.withImmediateTransaction((unit) => - unit.replaceWebAuthnChallenge( - registrationChallenge({ - challenge: "B".repeat(32), - id: replacementChallengeId, - }) - ) + const replacement = await fixture.repository.withImmediateTransaction( + (unit) => + unit.replaceWebAuthnChallenge( + registrationChallenge({ + challenge: "B".repeat(32), + id: replacementChallengeId, + }) + ) ); expect( fixture.repository.findSessionWebAuthnChallenge( @@ -121,7 +126,7 @@ describe("MFA lifecycle WebAuthn repository", () => { ) ).toEqual(replacement); expect( - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.consumeWebAuthnChallenge({ ...initial, checkedAt: addMinutes(initial.createdAt, 1), @@ -129,7 +134,7 @@ describe("MFA lifecycle WebAuthn repository", () => { ) ).toBeUndefined(); - const stepUp = fixture.repository.withImmediateTransaction((unit) => + const stepUp = await fixture.repository.withImmediateTransaction((unit) => unit.replaceWebAuthnChallenge( registrationChallenge({ challenge: "C".repeat(32), @@ -138,16 +143,17 @@ describe("MFA lifecycle WebAuthn repository", () => { }) ) ); - const pendingLogin = fixture.repository.withImmediateTransaction((unit) => - unit.replaceWebAuthnChallenge( - registrationChallenge({ - challenge: "D".repeat(32), - id: pendingLoginChallengeId, - pendingLoginId: pendingLoginSelector, - purpose: "login", - sessionId: null, - }) - ) + const pendingLogin = await fixture.repository.withImmediateTransaction( + (unit) => + unit.replaceWebAuthnChallenge( + registrationChallenge({ + challenge: "D".repeat(32), + id: pendingLoginChallengeId, + pendingLoginId: pendingLoginSelector, + purpose: "login", + sessionId: null, + }) + ) ); expect( @@ -173,7 +179,7 @@ describe("MFA lifecycle WebAuthn repository", () => { { ...pendingLogin, checkedAt: pendingLogin.expiresAt }, ]) { expect( - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.consumeWebAuthnChallenge(invalidSnapshot) ) ).toBeUndefined(); @@ -187,12 +193,12 @@ describe("MFA lifecycle WebAuthn repository", () => { checkedAt: addMinutes(pendingLogin.createdAt, 1), }; expect( - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.consumeWebAuthnChallenge(consumeInput) ) ).toEqual(pendingLogin); expect( - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.consumeWebAuthnChallenge(consumeInput) ) ).toBeUndefined(); @@ -205,14 +211,14 @@ describe("MFA lifecycle WebAuthn repository", () => { const fixture = await openWebAuthnRepositoryFixture(); try { - const inserted = fixture.repository.withImmediateTransaction((unit) => + const inserted = await fixture.repository.withImmediateTransaction((unit) => unit.insertWebAuthnCredentialIfAvailable( validUserWebAuthnCredentialInsert ) ); if (!inserted) throw new Error("Expected available WebAuthn credential"); expect( - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.insertWebAuthnCredentialIfAvailable({ ...validUserWebAuthnCredentialInsert, id: secondCredentialId, @@ -237,18 +243,18 @@ describe("MFA lifecycle WebAuthn repository", () => { ); const firstZeroCounterInput = advanceCredentialInput(inserted); - const firstZeroCounter = fixture.repository.withImmediateTransaction((unit) => - unit.advanceWebAuthnCredential(firstZeroCounterInput) + const firstZeroCounter = await fixture.repository.withImmediateTransaction( + (unit) => unit.advanceWebAuthnCredential(firstZeroCounterInput) ); expect(firstZeroCounter?.lastUsedAt).toEqual(firstUsedAt); expect( - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.advanceWebAuthnCredential(firstZeroCounterInput) ) ).toBeUndefined(); if (!firstZeroCounter) throw new Error("Expected zero-counter CAS winner"); - expect(() => + expect( fixture.repository.withImmediateTransaction((unit) => unit.advanceWebAuthnCredential( advanceCredentialInput(firstZeroCounter, { @@ -256,8 +262,8 @@ describe("MFA lifecycle WebAuthn repository", () => { }) ) ) - ).toThrow("WebAuthn credential transition is invalid"); - const secondZeroCounter = fixture.repository.withImmediateTransaction( + ).rejects.toThrow("WebAuthn credential transition is invalid"); + const secondZeroCounter = await fixture.repository.withImmediateTransaction( (unit) => unit.advanceWebAuthnCredential( advanceCredentialInput(firstZeroCounter, { @@ -269,7 +275,7 @@ describe("MFA lifecycle WebAuthn repository", () => { if (!secondZeroCounter) throw new Error("Expected second zero-counter CAS winner"); - const monotonic = fixture.repository.withImmediateTransaction((unit) => + const monotonic = await fixture.repository.withImmediateTransaction((unit) => unit.advanceWebAuthnCredential( advanceCredentialInput(secondZeroCounter, { counter: 1, @@ -279,7 +285,7 @@ describe("MFA lifecycle WebAuthn repository", () => { ); expect(monotonic).toMatchObject({ counter: 1, lastUsedAt: secondUsedAt }); expect( - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.advanceWebAuthnCredential( advanceCredentialInput(secondZeroCounter, { counter: 1, @@ -288,7 +294,7 @@ describe("MFA lifecycle WebAuthn repository", () => { ) ) ).toBeUndefined(); - expect(() => + expect( fixture.repository.withImmediateTransaction((unit) => unit.advanceWebAuthnCredential( advanceCredentialInput(monotonic ?? secondZeroCounter, { @@ -297,8 +303,8 @@ describe("MFA lifecycle WebAuthn repository", () => { }) ) ) - ).toThrow("WebAuthn credential transition is invalid"); - expect(() => + ).rejects.toThrow("WebAuthn credential transition is invalid"); + expect( fixture.repository.withImmediateTransaction((unit) => unit.advanceWebAuthnCredential( advanceCredentialInput(secondZeroCounter, { @@ -310,7 +316,7 @@ describe("MFA lifecycle WebAuthn repository", () => { }) ) ) - ).toThrow("WebAuthn credential transition is invalid"); + ).rejects.toThrow("WebAuthn credential transition is invalid"); } finally { fixture.database.sqlite.close(true); } @@ -320,7 +326,7 @@ describe("MFA lifecycle WebAuthn repository", () => { const fixture = await openWebAuthnRepositoryFixture(); try { - fixture.repository.withImmediateTransaction((unit) => { + await fixture.repository.withImmediateTransaction((unit) => { unit.insertPendingLogin({ ...validAuthPendingLoginInsert, allowsWebAuthn: false, @@ -336,13 +342,13 @@ describe("MFA lifecycle WebAuthn repository", () => { userId: securityUserId, validatorHash: validAuthPendingLoginInsert.validatorHash, }; - expect( - fixture.repository.withImmediateTransaction((unit) => + const consumedPendingLogin = + await fixture.repository.withImmediateTransaction((unit) => unit.consumePendingLogin(enabledInput) - )?.id - ).toBe(pendingLoginSelector); + ); + expect(consumedPendingLogin?.id).toBe(pendingLoginSelector); expect( - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.consumePendingLogin(enabledInput) ) ).toBeUndefined(); @@ -353,7 +359,7 @@ describe("MFA lifecycle WebAuthn repository", () => { validatorHash: webAuthnDisabledPendingLoginValidatorHash, }; expect( - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.consumePendingLogin(disabledInput) ) ).toBeUndefined(); @@ -369,13 +375,13 @@ describe("MFA lifecycle WebAuthn repository", () => { const fixture = await openWebAuthnRepositoryFixture(); try { - expect(() => + expect( fixture.repository.withImmediateTransaction((unit) => { unit.replaceWebAuthnChallenge(registrationChallenge()); unit.insertWebAuthnCredential(validUserWebAuthnCredentialInsert); throw new Error("forced WebAuthn repository rollback"); }) - ).toThrow("forced WebAuthn repository rollback"); + ).rejects.toThrow("forced WebAuthn repository rollback"); expect( fixture.repository.findSessionWebAuthnChallenge( validAuthSessionInsert.id, @@ -384,7 +390,7 @@ describe("MFA lifecycle WebAuthn repository", () => { ).toBeUndefined(); expect(fixture.repository.countWebAuthnCredentials(securityUserId)).toBe(0); - fixture.repository.withImmediateTransaction((unit) => { + await fixture.repository.withImmediateTransaction((unit) => { unit.insertWebAuthnCredential(validUserWebAuthnCredentialInsert); unit.insertWebAuthnCredential({ ...validUserWebAuthnCredentialInsert, @@ -394,13 +400,13 @@ describe("MFA lifecycle WebAuthn repository", () => { }); }); expect(fixture.repository.countWebAuthnCredentials(securityUserId)).toBe(2); - expect( - fixture.repository.withImmediateTransaction((unit) => + const deletedCredential = await fixture.repository.withImmediateTransaction( + (unit) => unit.deleteWebAuthnCredential(securityUserId, secondCredentialId) - )?.id - ).toBe(secondCredentialId); + ); + expect(deletedCredential?.id).toBe(secondCredentialId); expect( - fixture.repository.withImmediateTransaction((unit) => + await fixture.repository.withImmediateTransaction((unit) => unit.deleteWebAuthnCredentialsForUser(securityUserId) ) ).toBe(1); diff --git a/greenfield/src/server/domains/security/mfa/lifecycleRepositoryTypes.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepositoryTypes.ts index 8fc0a63e5..e0d6df211 100644 --- a/greenfield/src/server/domains/security/mfa/lifecycleRepositoryTypes.ts +++ b/greenfield/src/server/domains/security/mfa/lifecycleRepositoryTypes.ts @@ -249,7 +249,7 @@ export interface MfaLifecycleUnitOfWork export interface MfaLifecycleRepository extends MfaLifecycleReader { withImmediateTransaction( callback: (unit: MfaLifecycleUnitOfWork) => SynchronousResult - ): T; + ): Promise; withReadTransaction( callback: (reader: MfaLifecycleReader) => SynchronousResult ): T; diff --git a/greenfield/src/server/domains/security/mfa/loginCoordinator.ts b/greenfield/src/server/domains/security/mfa/loginCoordinator.ts index cb8027f11..9684bf34e 100644 --- a/greenfield/src/server/domains/security/mfa/loginCoordinator.ts +++ b/greenfield/src/server/domains/security/mfa/loginCoordinator.ts @@ -52,7 +52,7 @@ export interface MfaLoginCoordinator { completedAt: Date, metadata: AuthenticationRequestMetadata, consumeProof: (unit: MfaLifecycleUnitOfWork) => MfaLoginProofConsumptionResult - ) => CompleteMfaLoginResult; + ) => Promise; readonly recordFailure: ( resolved: ResolvedPendingLogin | undefined, credential: ParsedOpaqueToken, @@ -60,7 +60,7 @@ export interface MfaLoginCoordinator { failedAt: Date, reason: MfaLoginFailureReason, unblockedStatus?: "invalid-proof" | "service-unavailable" - ) => CompleteMfaLoginResult; + ) => Promise; readonly recordWebAuthnFailure: ( resolved: ResolvedPendingLogin, credential: ParsedOpaqueToken, @@ -68,20 +68,20 @@ export interface MfaLoginCoordinator { failedAt: Date, consumeChallenge: (unit: MfaLifecycleUnitOfWork) => boolean, attemptCheckedAt?: Date - ) => CompleteMfaLoginResult; + ) => Promise; readonly recordWebAuthnCancellation: ( resolved: ResolvedPendingLogin, metadata: AuthenticationRequestMetadata, cancelledAt: Date, consumeChallenge: (unit: MfaLifecycleUnitOfWork) => boolean - ) => void; + ) => Promise; readonly recordWebAuthnUnavailable: ( resolved: ResolvedPendingLogin, metadata: AuthenticationRequestMetadata, failedAt: Date, consumeChallenge: (unit: MfaLifecycleUnitOfWork) => boolean, reason?: "webauthn_configuration_mismatch" - ) => CompleteMfaLoginResult; + ) => Promise; } type MfaLoginCoordinatorPort = Pick< @@ -100,7 +100,7 @@ export function createMfaLoginCoordinator( ): MfaLoginCoordinator { const { audit, generateSessionToken, repository, sessionIdleDurationMs } = context; - const finishLogin: MfaLoginCoordinator["finishLogin"] = ( + const finishLogin: MfaLoginCoordinator["finishLogin"] = async ( resolved, credential, method, @@ -113,7 +113,7 @@ export function createMfaLoginCoordinator( ); const sessionToken = generateSessionToken(); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const currentUser = unit.findUserById(resolved.user.id); if ( currentUser === undefined || @@ -205,7 +205,7 @@ export function createMfaLoginCoordinator( } }; - const recordFailure: MfaLoginCoordinator["recordFailure"] = ( + const recordFailure: MfaLoginCoordinator["recordFailure"] = async ( resolved, credential, metadata, @@ -214,7 +214,7 @@ export function createMfaLoginCoordinator( unblockedStatus = "invalid-proof" ) => { const targets = mfaLoginRateLimitTargets(metadata.clientSourceId); - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const activeLimit = activeRateLimitForTargets(unit, targets, failedAt); if (activeLimit !== undefined) { return { ...activeLimit, status: "rate-limited" as const }; @@ -257,7 +257,7 @@ export function createMfaLoginCoordinator( }); }; - const recordWebAuthnFailure: MfaLoginCoordinator["recordWebAuthnFailure"] = ( + const recordWebAuthnFailure: MfaLoginCoordinator["recordWebAuthnFailure"] = async ( resolved, credential, metadata, @@ -267,7 +267,7 @@ export function createMfaLoginCoordinator( ) => { const targets = mfaLoginRateLimitTargets(metadata.clientSourceId); try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { if (!consumeChallenge(unit)) { throw new MfaLoginStateChangedError(); } @@ -319,8 +319,8 @@ export function createMfaLoginCoordinator( }; const recordWebAuthnCancellation: MfaLoginCoordinator["recordWebAuthnCancellation"] = - (resolved, metadata, cancelledAt, consumeChallenge) => { - repository.withImmediateTransaction((unit) => { + async (resolved, metadata, cancelledAt, consumeChallenge) => { + await repository.withImmediateTransaction((unit) => { if (!consumeChallenge(unit)) return; audit(unit, { action: "auth.login.mfa", @@ -339,41 +339,36 @@ export function createMfaLoginCoordinator( }); }; - const recordWebAuthnUnavailable: MfaLoginCoordinator["recordWebAuthnUnavailable"] = ( - resolved, - metadata, - failedAt, - consumeChallenge, - reason - ) => { - try { - return repository.withImmediateTransaction((unit) => { - if (!consumeChallenge(unit)) { - throw new MfaLoginStateChangedError(); - } - audit(unit, { - action: "auth.login.mfa", - actor: { - authenticatorId: null, - id: "browser", - kind: "anonymous", - }, - metadata: { method: "webauthn", ...(reason && { reason }) }, - occurredAt: failedAt, - outcome: "failed", - requestId: metadata.requestId, - targetId: resolved.user.id, - targetType: "user", + const recordWebAuthnUnavailable: MfaLoginCoordinator["recordWebAuthnUnavailable"] = + async (resolved, metadata, failedAt, consumeChallenge, reason) => { + try { + return await repository.withImmediateTransaction((unit) => { + if (!consumeChallenge(unit)) { + throw new MfaLoginStateChangedError(); + } + audit(unit, { + action: "auth.login.mfa", + actor: { + authenticatorId: null, + id: "browser", + kind: "anonymous", + }, + metadata: { method: "webauthn", ...(reason && { reason }) }, + occurredAt: failedAt, + outcome: "failed", + requestId: metadata.requestId, + targetId: resolved.user.id, + targetType: "user", + }); + return { status: "service-unavailable" } as const; }); - return { status: "service-unavailable" } as const; - }); - } catch (error) { - if (error instanceof MfaLoginStateChangedError) { - return { status: "state-changed" }; + } catch (error) { + if (error instanceof MfaLoginStateChangedError) { + return { status: "state-changed" }; + } + throw error; } - throw error; - } - }; + }; return Object.freeze({ finishLogin, diff --git a/greenfield/src/server/domains/security/mfa/loginLifecycle.pending.test.ts b/greenfield/src/server/domains/security/mfa/loginLifecycle.pending.test.ts index 2c032c91e..68d869f53 100644 --- a/greenfield/src/server/domains/security/mfa/loginLifecycle.pending.test.ts +++ b/greenfield/src/server/domains/security/mfa/loginLifecycle.pending.test.ts @@ -58,7 +58,7 @@ describe("MFA pending-login lifecycle", () => { expect(harness.passwordCryptoTransactionStates).toEqual([false, false]); expect( - harness.service.revokePendingLogin( + await harness.service.revokePendingLogin( pending.credential, mfaLoginMetadata("request-revoke") ) @@ -67,7 +67,7 @@ describe("MFA pending-login lifecycle", () => { harness.service.pendingLoginSummary(pending.credential) ).toBeUndefined(); expect( - harness.service.revokePendingLogin( + await harness.service.revokePendingLogin( pending.credential, mfaLoginMetadata("request-revoke-replay") ) diff --git a/greenfield/src/server/domains/security/mfa/loginLifecycle.totp.test.ts b/greenfield/src/server/domains/security/mfa/loginLifecycle.totp.test.ts index b7c7a69d2..52d09c93b 100644 --- a/greenfield/src/server/domains/security/mfa/loginLifecycle.totp.test.ts +++ b/greenfield/src/server/domains/security/mfa/loginLifecycle.totp.test.ts @@ -243,7 +243,7 @@ describe("MFA TOTP login lifecycle", () => { test("accounts for wrong proofs when another TOTP factor is unavailable", async () => { const harness = await createMfaLoginHarness(); try { - harness.insertUnavailableTotpFactor(); + await harness.insertUnavailableTotpFactor(); const pending = await beginPasswordMfaLogin( harness, "request-unavailable-factor-pending" diff --git a/greenfield/src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts b/greenfield/src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts index e7013e574..6f22bffeb 100644 --- a/greenfield/src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts +++ b/greenfield/src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts @@ -80,17 +80,18 @@ function inertWorkRuntime(): WebAuthnWorkRuntime { const decision = options.onBeforeStart?.() ?? { proceed: true as const }; if (!decision.proceed) return decision.value; const signal = new AbortController().signal; + let value: T; try { - const value = await work(signal); - options.onResultBeforeRelease?.(value); - return value; + value = await work(signal); } catch { const failure = new AuthenticationUpstreamUnavailableError({ operation: "webauthn", }); - options.onFailureBeforeRelease?.(failure); + await options.onFailureBeforeRelease?.(failure); throw failure; } + await options.onResultBeforeRelease?.(value); + return value; }, }); } @@ -99,19 +100,17 @@ function failingWorkRuntime( failure: "capacity" | "timeout" | "upstream" ): WebAuthnWorkRuntime { return Object.freeze({ - runWebAuthnVerification( + async runWebAuthnVerification( _work: (signal: AbortSignal) => Promise, options: AuthenticationVerificationWorkOptions ): Promise { if (failure === "capacity") { - return Promise.reject( - new AuthenticationWorkCapacityError({ - operation: "webauthn", - }) - ); + throw new AuthenticationWorkCapacityError({ + operation: "webauthn", + }); } const decision = options.onBeforeStart?.() ?? { proceed: true as const }; - if (!decision.proceed) return Promise.resolve(decision.value); + if (!decision.proceed) return decision.value; const error = failure === "timeout" ? new AuthenticationWorkTimeoutError({ @@ -121,31 +120,29 @@ function failingWorkRuntime( : new AuthenticationUpstreamUnavailableError({ operation: "webauthn", }); - options.onFailureBeforeRelease?.(error); - return Promise.reject(error); + await options.onFailureBeforeRelease?.(error); + throw error; }, }); } function cancellationWorkRuntime(): WebAuthnWorkRuntime { return Object.freeze({ - runWebAuthnVerification( + async runWebAuthnVerification( _work: (signal: AbortSignal) => Promise, options: AuthenticationVerificationWorkOptions ): Promise { const decision = options.onBeforeStart?.() ?? { proceed: true as const }; - if (!decision.proceed) return Promise.resolve(decision.value); - options.onCancellationBeforeRelease?.(); - return Promise.reject( - new DOMException("WebAuthn request aborted", "AbortError") - ); + if (!decision.proceed) return decision.value; + await options.onCancellationBeforeRelease?.(); + throw new DOMException("WebAuthn request aborted", "AbortError"); }, }); } function queuedTimeoutWorkRuntime(): WebAuthnWorkRuntime { return Object.freeze({ - runWebAuthnVerification( + async runWebAuthnVerification( _work: (signal: AbortSignal) => Promise, options: AuthenticationVerificationWorkOptions ): Promise { @@ -153,8 +150,8 @@ function queuedTimeoutWorkRuntime(): WebAuthnWorkRuntime { operation: "webauthn", timeoutMs: options.timeoutMs, }); - options.onFailureBeforeRelease?.(failure); - return Promise.reject(failure); + await options.onFailureBeforeRelease?.(failure); + throw failure; }, }); } @@ -268,7 +265,7 @@ describe("MFA WebAuthn login lifecycle", () => { harness.database.sqlite.run( "UPDATE user_webauthn_credentials SET rp_id = 'legacy.example'" ); - harness.repository.withImmediateTransaction((unit) => { + await harness.repository.withImmediateTransaction((unit) => { unit.deleteTotpFactor(mfaLoginUserId, mfaLoginTotpFactorId); }); @@ -560,7 +557,7 @@ describe("MFA WebAuthn login lifecycle", () => { webAuthn: webAuthnDependencies(), }); try { - harness.repository.withImmediateTransaction((unit) => + await harness.repository.withImmediateTransaction((unit) => unit.insertWebAuthnCredential({ algorithm: -7, backedUp: true, @@ -617,7 +614,7 @@ describe("MFA WebAuthn login lifecycle", () => { const harness = await createMfaLoginHarness({ webAuthn: webAuthnDependencies({ adapter: fixedChallengeAdapter({ - beforeVerification: () => { + beforeVerification: async () => { const current = harnessState.value; if (current === undefined) { throw new Error( @@ -628,7 +625,7 @@ describe("MFA WebAuthn login lifecycle", () => { "login-mfa-source", mfaLoginClientSourceId ); - current.repository.withImmediateTransaction((unit) => { + await current.repository.withImmediateTransaction((unit) => { unit.upsertRateLimitBucket({ blockedUntil: addMinutes(mfaLoginNow, 1), bucketKey, diff --git a/greenfield/src/server/domains/security/mfa/loginLifecycleTypes.ts b/greenfield/src/server/domains/security/mfa/loginLifecycleTypes.ts index e2ca91751..7a0b00ea4 100644 --- a/greenfield/src/server/domains/security/mfa/loginLifecycleTypes.ts +++ b/greenfield/src/server/domains/security/mfa/loginLifecycleTypes.ts @@ -65,7 +65,7 @@ export type BeginWebAuthnLoginLifecycleResult = | { readonly status: "state-changed" }; export interface MfaLoginLifecycleService { - beginPendingLogin(input: BeginPendingLoginInput): BeginPendingLoginResult; + beginPendingLogin(input: BeginPendingLoginInput): Promise; beginWebAuthnLogin( credential: ParsedOpaqueToken, metadata: AuthenticationRequestMetadata @@ -89,7 +89,7 @@ export interface MfaLoginLifecycleService { revokePendingLogin( credential: ParsedOpaqueToken, metadata: AuthenticationRequestMetadata - ): boolean; + ): Promise; } export interface MfaLoginWebAuthnDependencies { diff --git a/greenfield/src/server/domains/security/mfa/loginPendingLifecycle.ts b/greenfield/src/server/domains/security/mfa/loginPendingLifecycle.ts index b2cf48d9b..9127dcee3 100644 --- a/greenfield/src/server/domains/security/mfa/loginPendingLifecycle.ts +++ b/greenfield/src/server/domains/security/mfa/loginPendingLifecycle.ts @@ -120,9 +120,9 @@ export function createPendingLoginOperations( } = context; return Object.freeze({ - beginPendingLogin(input) { + async beginPendingLogin(input) { const pendingToken = generatePendingLoginToken(); - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const user = unit.findUserById(input.userSnapshot.id); if ( user === undefined || @@ -222,14 +222,14 @@ export function createPendingLoginOperations( : pendingLoginSummary(resolved.pending, resolved.user); }, - revokePendingLogin(credential, metadata) { + async revokePendingLogin(credential, metadata) { const occurredAt = now(); const resolved = repository.withReadTransaction((reader) => resolvePendingLogin(reader, credential, occurredAt) ); if (resolved === undefined) return false; try { - return repository.withImmediateTransaction((unit) => { + return await repository.withImmediateTransaction((unit) => { const removed = unit.deletePendingLogin( resolved.user.id, resolved.pending.id diff --git a/greenfield/src/server/domains/security/mfa/loginWebAuthnProof.ts b/greenfield/src/server/domains/security/mfa/loginWebAuthnProof.ts index 79a6694d6..544643df8 100644 --- a/greenfield/src/server/domains/security/mfa/loginWebAuthnProof.ts +++ b/greenfield/src/server/domains/security/mfa/loginWebAuthnProof.ts @@ -271,7 +271,9 @@ export function createWebAuthnLoginProofOperation( let attemptedAt: Date | undefined; let settledResult: CompleteMfaLoginResult | undefined; - const settleInvalidProof = (failedAt: Date): CompleteMfaLoginResult => + const settleInvalidProof = ( + failedAt: Date + ): Promise => coordinator.recordWebAuthnFailure( resolved, pendingCredential, @@ -280,7 +282,7 @@ export function createWebAuthnLoginProofOperation( (unit) => consumeChallenge(unit, challenge, attemptedAt ?? checkedAt), attemptedAt ?? checkedAt ); - const settleUnavailable = (failedAt: Date): CompleteMfaLoginResult => + const settleUnavailable = (failedAt: Date): Promise => coordinator.recordWebAuthnUnavailable( resolved, metadata, @@ -382,8 +384,8 @@ export function createWebAuthnLoginProofOperation( attemptedAt = admittedAt; return { proceed: true }; }, - onCancellationBeforeRelease: () => { - coordinator.recordWebAuthnCancellation( + onCancellationBeforeRelease: async () => { + await coordinator.recordWebAuthnCancellation( resolved, metadata, now(), @@ -395,12 +397,14 @@ export function createWebAuthnLoginProofOperation( ) ); }, - onFailureBeforeRelease: () => { + onFailureBeforeRelease: async () => { if (attemptedAt !== undefined) { - settledResult = settleUnavailable(now()); + settledResult = await settleUnavailable(now()); } }, - onResultBeforeRelease: (result: WebAuthnLoginWorkResult) => { + onResultBeforeRelease: async ( + result: WebAuthnLoginWorkResult + ) => { if (result.kind !== "verification") return; const verification = result.result; if ( @@ -412,15 +416,15 @@ export function createWebAuthnLoginProofOperation( verification.verification ) ) { - settledResult = settleInvalidProof(now()); + settledResult = await settleInvalidProof(now()); return; } const completedAt = now(); if (compareAsc(completedAt, challenge.expiresAt) >= 0) { - settledResult = settleInvalidProof(completedAt); + settledResult = await settleInvalidProof(completedAt); return; } - settledResult = coordinator.finishLogin( + settledResult = await coordinator.finishLogin( resolved, pendingCredential, "webauthn", diff --git a/greenfield/src/server/domains/security/mfa/testSupport/accountLifecycle.ts b/greenfield/src/server/domains/security/mfa/testSupport/accountLifecycle.ts index 8a24646e9..31f92ec56 100644 --- a/greenfield/src/server/domains/security/mfa/testSupport/accountLifecycle.ts +++ b/greenfield/src/server/domains/security/mfa/testSupport/accountLifecycle.ts @@ -10,6 +10,7 @@ import { import { userInsertSchema } from "../../../../database/validation/users.ts"; import { generateOpaqueToken } from "../../../../shared/opaqueToken.ts"; import { createTestAuthenticationWorkGate } from "../../../../test/support/authenticationWorkGate.ts"; +import { testImmediateDatabaseWriteAdmission } from "../../../../test/support/databaseWriteAdmission.ts"; import { openFreshMigratedDatabase } from "../../../../test/support/freshDatabase.ts"; import { testDashboardPasswordHash } from "../../../../test/support/securityPassword.ts"; import type { AuthenticationWorkBudget } from "../../authenticationWorkBudget.ts"; @@ -109,7 +110,10 @@ export async function createAccountLifecycleHarness( options: AccountLifecycleHarnessOptions = {} ) { const database = await openFreshMigratedDatabase(); - const repository = createMfaLifecycleRepository(database.orm); + const repository = createMfaLifecycleRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ); const cryptoTransactionStates: boolean[] = []; const consumedWorkUnits: number[] = []; const consumedTotpWorkUnits: number[] = []; @@ -134,7 +138,7 @@ export async function createAccountLifecycleHarness( }) ) .run(); - repository.withImmediateTransaction((unit) => { + await repository.withImmediateTransaction((unit) => { unit.insertSession({ ...validAuthSessionInsert, authenticatedAt: subMinutes(accountLifecycleNow, 5), @@ -264,8 +268,8 @@ export async function createAccountLifecycleHarness( database, consumedWorkUnits, consumedTotpWorkUnits, - insertUnavailableTotpFactor() { - repository.withImmediateTransaction((unit) => + async insertUnavailableTotpFactor() { + await repository.withImmediateTransaction((unit) => unit.insertTotpFactor({ confirmedAt: accountLifecycleNow, createdAt: subMinutes(accountLifecycleNow, 1), diff --git a/greenfield/src/server/domains/security/mfa/testSupport/loginLifecycle.ts b/greenfield/src/server/domains/security/mfa/testSupport/loginLifecycle.ts index 62e5c9e9a..10649e9e3 100644 --- a/greenfield/src/server/domains/security/mfa/testSupport/loginLifecycle.ts +++ b/greenfield/src/server/domains/security/mfa/testSupport/loginLifecycle.ts @@ -18,6 +18,7 @@ import { createTestAuthenticationWorkGate, createTestGatewayWorkRuntime, } from "../../../../test/support/authenticationWorkGate.ts"; +import { testImmediateDatabaseWriteAdmission } from "../../../../test/support/databaseWriteAdmission.ts"; import { openFreshMigratedDatabase } from "../../../../test/support/freshDatabase.ts"; import { testDashboardPasswordHash } from "../../../../test/support/securityPassword.ts"; import { createAuthenticationLifecycleService } from "../../authenticationLifecycle.ts"; @@ -128,7 +129,10 @@ export interface MfaLoginHarnessOptions { export async function createMfaLoginHarness(options: MfaLoginHarnessOptions = {}) { const database = await openFreshMigratedDatabase(); - const repository = createMfaLifecycleRepository(database.orm); + const repository = createMfaLifecycleRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ); let beforeTotpDecrypt: (() => Promise | void) | undefined; let passwordVerificationCalls = 0; const passwordCryptoTransactionStates: boolean[] = []; @@ -151,7 +155,7 @@ export async function createMfaLoginHarness(options: MfaLoginHarnessOptions = {} }) ) .run(); - repository.withImmediateTransaction((unit) => { + await repository.withImmediateTransaction((unit) => { unit.insertTotpFactor({ ...validUserTotpFactorInsert, confirmedAt: mfaLoginNow, @@ -285,7 +289,10 @@ export async function createMfaLoginHarness(options: MfaLoginHarnessOptions = {} now: options.now ?? (() => mfaLoginNow), passwordWorkBudget, passwordWorkGate, - repository: createAuthenticationLifecycleRepository(database.orm), + repository: createAuthenticationLifecycleRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ), verifyGatewayCredential: () => Promise.resolve(false), verifyPassword: (password) => { passwordVerificationCalls += 1; @@ -303,8 +310,8 @@ export async function createMfaLoginHarness(options: MfaLoginHarnessOptions = {} recoveryCryptoTransactionStates, repository, service, - insertUnavailableTotpFactor() { - repository.withImmediateTransaction((unit) => + async insertUnavailableTotpFactor() { + await repository.withImmediateTransaction((unit) => unit.insertTotpFactor({ ...validUserTotpFactorInsert, confirmedAt: mfaLoginNow, diff --git a/greenfield/src/server/domains/security/procedures.test.ts b/greenfield/src/server/domains/security/procedures.test.ts index fca8b1702..45da97696 100644 --- a/greenfield/src/server/domains/security/procedures.test.ts +++ b/greenfield/src/server/domains/security/procedures.test.ts @@ -37,7 +37,8 @@ function createTestMfaLoginLifecycleService( ): MfaLoginLifecycleService { return Object.freeze({ beginPendingLogin: - overrides.beginPendingLogin ?? (() => ({ status: "identity-changed" })), + overrides.beginPendingLogin ?? + (() => Promise.resolve({ status: "identity-changed" })), beginWebAuthnLogin: overrides.beginWebAuthnLogin ?? (() => Promise.resolve({ status: "service-unavailable" })), @@ -52,7 +53,8 @@ function createTestMfaLoginLifecycleService( (() => Promise.resolve({ status: "service-unavailable" })), pendingLoginSummary: overrides.pendingLoginSummary ?? ((): undefined => undefined), - revokePendingLogin: overrides.revokePendingLogin ?? (() => false), + revokePendingLogin: + overrides.revokePendingLogin ?? (() => Promise.resolve(false)), }); } @@ -269,7 +271,7 @@ describe("authentication procedures", () => { createTestApplicationRuntime(), { authenticationLifecycle: createTestAuthenticationLifecycleService({ - revokeSession: () => ({ revoked }), + revokeSession: () => Promise.resolve({ revoked }), }), responseHeaders, } @@ -291,7 +293,7 @@ describe("authentication procedures", () => { createTestApplicationRuntime(), { authenticationLifecycle: createTestAuthenticationLifecycleService({ - revokeSession: () => ({ status: "step-up-required" }), + revokeSession: () => Promise.resolve({ status: "step-up-required" }), }), responseHeaders, } @@ -384,7 +386,7 @@ describe("authentication procedures", () => { createTestApplicationRuntime(), { authenticationLifecycle: createTestAuthenticationLifecycleService({ - revokeSession: (): undefined => {}, + revokeSession: () => Promise.resolve(undefined), }), responseHeaders: revokeHeaders, } diff --git a/greenfield/src/server/domains/security/testSupport/authenticationLifecycle.ts b/greenfield/src/server/domains/security/testSupport/authenticationLifecycle.ts index f709c8e11..982c919df 100644 --- a/greenfield/src/server/domains/security/testSupport/authenticationLifecycle.ts +++ b/greenfield/src/server/domains/security/testSupport/authenticationLifecycle.ts @@ -5,6 +5,7 @@ import { createTestAuthenticationWorkGate, createTestGatewayWorkRuntime, } from "../../../test/support/authenticationWorkGate.ts"; +import { testImmediateDatabaseWriteAdmission } from "../../../test/support/databaseWriteAdmission.ts"; import { openFreshMigratedDatabase } from "../../../test/support/freshDatabase.ts"; import { createAuthenticationLifecycleService, @@ -46,7 +47,7 @@ export interface AuthenticationLifecycleHarnessOptions { } const unavailablePendingLoginLifecycle: PendingLoginLifecyclePort = Object.freeze({ - beginPendingLogin: () => ({ status: "mfa-unavailable" as const }), + beginPendingLogin: () => Promise.resolve({ status: "mfa-unavailable" as const }), }); export async function createAuthenticationLifecycleHarness( @@ -83,7 +84,10 @@ export async function createAuthenticationLifecycleHarness( ...(options.recentAuthenticationWindowMs !== undefined && { recentAuthenticationWindowMs: options.recentAuthenticationWindowMs, }), - repository: createAuthenticationLifecycleRepository(database.orm), + repository: createAuthenticationLifecycleRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ), verifyGatewayCredential: (credential, signal) => { gatewayVerificationCalls += 1; if (options.verifyGatewayCredential !== undefined) { diff --git a/greenfield/src/server/platform/errors/safeFailure.test.ts b/greenfield/src/server/platform/errors/safeFailure.test.ts index 5e1d7d7b8..0784f1648 100644 --- a/greenfield/src/server/platform/errors/safeFailure.test.ts +++ b/greenfield/src/server/platform/errors/safeFailure.test.ts @@ -9,7 +9,10 @@ import { DatabaseRuntimePathError, DatabaseRuntimeSnapshotRequiredError, DatabaseRuntimeStartupError, + DatabaseRuntimeWriteAdmissionTimeoutError, + DatabaseRuntimeWriteContentionError, } from "../../database/runtime/databaseErrors.ts"; +import { AuthenticationWorkSettlementError } from "../../domains/security/authenticationWorkGate.ts"; import { describeSafeFailure } from "./safeFailure.ts"; class ExpectedFailure extends Data.TaggedError("ApplicationListenerStopError")<{ @@ -135,6 +138,13 @@ test("recognizes every redacted database-runtime failure tag", () => { message: "startup failed", reason: "database-startup-failed", }), + new DatabaseRuntimeWriteAdmissionTimeoutError({ + message: "write admission timed out", + timeoutMs: 5000, + }), + new DatabaseRuntimeWriteContentionError({ + message: "write contention", + }), ]; expect(failures.map((failure) => describeSafeFailure(failure).tag)).toEqual([ @@ -144,5 +154,20 @@ test("recognizes every redacted database-runtime failure tag", () => { "DatabaseRuntimePathError", "DatabaseRuntimeSnapshotRequiredError", "DatabaseRuntimeStartupError", + "DatabaseRuntimeWriteAdmissionTimeoutError", + "DatabaseRuntimeWriteContentionError", ]); }); + +test("recognizes redacted authentication settlement failures", () => { + const failure = new AuthenticationWorkSettlementError({ + cause: new Error("private settlement detail"), + operation: "webauthn", + }); + + expect(describeSafeFailure(failure)).toMatchObject({ + kind: "tagged", + name: "AuthenticationWorkSettlementError", + tag: "AuthenticationWorkSettlementError", + }); +}); diff --git a/greenfield/src/server/platform/errors/safeFailure.ts b/greenfield/src/server/platform/errors/safeFailure.ts index 97e9cfbe0..1c9f4d630 100644 --- a/greenfield/src/server/platform/errors/safeFailure.ts +++ b/greenfield/src/server/platform/errors/safeFailure.ts @@ -7,6 +7,7 @@ const knownFailureTags = new Set([ "ApplicationListenerStopTimeoutError", "AuthenticationUpstreamUnavailableError", "AuthenticationWorkCapacityError", + "AuthenticationWorkSettlementError", "AuthenticationWorkTimeoutError", "DatabaseRuntimeCheckpointError", "DatabaseRuntimeCloseError", @@ -14,6 +15,8 @@ const knownFailureTags = new Set([ "DatabaseRuntimePathError", "DatabaseRuntimeSnapshotRequiredError", "DatabaseRuntimeStartupError", + "DatabaseRuntimeWriteAdmissionTimeoutError", + "DatabaseRuntimeWriteContentionError", "MonitoringRunConflictError", "MonitoringSnapshotValidationError", "RealtimeEventCursorStreamError", diff --git a/greenfield/src/server/platform/runtime/applicationRuntime.ts b/greenfield/src/server/platform/runtime/applicationRuntime.ts index 3a4f96e92..851e0cc3b 100644 --- a/greenfield/src/server/platform/runtime/applicationRuntime.ts +++ b/greenfield/src/server/platform/runtime/applicationRuntime.ts @@ -1,10 +1,24 @@ import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; -import { Data, Effect, Exit, Fiber, Layer, ManagedRuntime, Stream } from "effect"; +import { + Context, + Data, + Effect, + Exit, + Fiber, + Layer, + ManagedRuntime, + Stream, +} from "effect"; +import type { + ImmediateDatabaseWriteAdmission, + MarkDatabaseTransactionStarted, +} from "../../database/immediateWriteAdmission.ts"; import { databaseRuntimeLayer, DatabaseRuntimeService, type DatabaseRuntimeLayerOptions, + type RuntimeOwnedDatabase, } from "../../database/runtime/databaseService.ts"; import { type AuthenticationWorkLayerOptions, @@ -87,7 +101,7 @@ export interface ApplicationRuntime { } /** Runtime-owned database access exposed only to the Dashboard composition root. */ -export interface DashboardDatabaseRuntimeService { +export interface DashboardDatabaseRuntimeService extends ImmediateDatabaseWriteAdmission { readonly orm: () => Promise; } @@ -377,19 +391,17 @@ function createApplicationRuntimeFromManagedRuntime( }); } -function databaseBackedRealtimeEventPumpLayer(): Layer.Layer< - RealtimeEventPumpService, - never, - DatabaseRuntimeService -> { +function databaseBackedRealtimeEventPumpLayer( + databaseOrm: Effect.Effect +): Layer.Layer { return Layer.unwrap( - DatabaseRuntimeService.pipe( - Effect.map((database) => + databaseOrm.pipe( + Effect.map((orm) => realtimeEventPumpLayer({ makePump: (pumpRuntime) => new RealtimeEventPump({ ...pumpRuntime, - store: createRealtimeEventStore(database.orm), + store: createRealtimeEventStore(orm), }), }) ) @@ -416,21 +428,24 @@ export function createApplicationRuntime( } /** - * Creates the production Dashboard runtime with SQLite and realtime in one Layer graph. - * The realtime layer depends on and therefore finalizes before the retained database. + * Creates the production Dashboard runtime with ordered application and database scopes. + * Authentication and realtime finalize before the retained database, allowing claimed + * durable settlements to finish in the still-live database scope during shutdown. * @param options Explicit database, logger, and authentication composition inputs. * @returns One process runtime exposing only the owned ORM to Dashboard composition. */ export function createDashboardApplicationRuntime( options: DashboardApplicationRuntimeOptions ): DashboardApplicationRuntime { - const databaseLayer = databaseRuntimeLayer(options.database); - const databaseAndRealtimeLayer = databaseBackedRealtimeEventPumpLayer().pipe( - Layer.provideMerge(databaseLayer) + const databaseRuntime = ManagedRuntime.make(databaseRuntimeLayer(options.database)); + const databaseOrm = databaseRuntime.contextEffect.pipe( + Effect.map(Context.get(DatabaseRuntimeService)), + Effect.map((database) => database.orm) ); + const databaseBackedRealtimeLayer = databaseBackedRealtimeEventPumpLayer(databaseOrm); const runtime = ManagedRuntime.make( Layer.mergeAll( - databaseAndRealtimeLayer, + databaseBackedRealtimeLayer, authenticationWorkLayer(options.authenticationWork), createEffectLoggerLayer(options.logger) ) @@ -441,13 +456,37 @@ export function createDashboardApplicationRuntime( ); const database: DashboardDatabaseRuntimeService = Object.freeze({ orm: () => - runtime.runPromise( + databaseRuntime.runPromise( DatabaseRuntimeService.pipe(Effect.map((service) => service.orm)) ), + run( + operation: (markTransactionStarted: MarkDatabaseTransactionStarted) => T + ): Promise { + return databaseRuntime.runPromise( + DatabaseRuntimeService.pipe( + Effect.flatMap((service) => service.runImmediateWrite(operation)) + ) + ); + }, }); + let disposePromise: Promise | undefined; return Object.freeze({ ...applicationRuntime, database, + dispose() { + disposePromise ??= (async () => { + try { + await applicationRuntime.dispose(); + } finally { + await databaseRuntime.dispose(); + } + })(); + return disposePromise; + }, + async initialize() { + await databaseRuntime.context(); + await applicationRuntime.initialize(); + }, }); } diff --git a/greenfield/src/server/platform/runtime/dashboardApplicationRuntime.test.ts b/greenfield/src/server/platform/runtime/dashboardApplicationRuntime.test.ts index 9a19b26c8..1b7c54a82 100644 --- a/greenfield/src/server/platform/runtime/dashboardApplicationRuntime.test.ts +++ b/greenfield/src/server/platform/runtime/dashboardApplicationRuntime.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { maxTime } from "date-fns/constants"; +import type { RuntimeOwnedDatabase } from "../../database/runtime/databaseService.ts"; import { migrationsDirectory } from "../../test/support/freshDatabase.ts"; import { withTestTimeout } from "../../test/support/promise.ts"; import { createTestStructuredLogger } from "../../test/support/requestContext.ts"; @@ -139,4 +140,80 @@ describe("Dashboard application runtime", () => { } } }); + + test("finishes a claimed database settlement before disposing its database scope", async () => { + const runtime = await createTestDashboardRuntime(); + let competingWriter: Database | undefined; + + try { + await runtime.initialize(); + const orm = (await runtime.database.orm()) as RuntimeOwnedDatabase; + const databasePath = orm.$client.filename; + orm.$client.run( + "CREATE TABLE disposal_settlement_probe (value TEXT NOT NULL)" + ); + + competingWriter = new Database(databasePath, { strict: true }); + competingWriter.run("PRAGMA busy_timeout = 0"); + competingWriter.run("BEGIN IMMEDIATE"); + + const firstAdmissionAttempt = Promise.withResolvers(); + let callbackCalls = 0; + const verification = runtime.services.authentication.runWebAuthnVerification( + () => Promise.resolve("verified"), + { + onResultBeforeRelease: () => + runtime.database.run((markTransactionStarted) => { + firstAdmissionAttempt.resolve(); + return orm.$client + .transaction(() => { + markTransactionStarted(); + callbackCalls += 1; + orm.$client.run( + "INSERT INTO disposal_settlement_probe (value) VALUES ('committed')" + ); + }) + .immediate(); + }), + timeoutMs: 5000, + } + ); + const observedVerification = verification.catch(() => null); + await firstAdmissionAttempt.promise; + + let disposalCompleted = false; + const disposal = runtime.dispose().then(() => { + disposalCompleted = true; + return true; + }); + await Bun.sleep(30); + + expect(disposalCompleted).toBeFalse(); + expect(callbackCalls).toBe(0); + + competingWriter.run("ROLLBACK"); + await disposal; + await observedVerification; + expect(disposalCompleted).toBeTrue(); + expect(callbackCalls).toBe(1); + + const verificationDatabase = new Database(databasePath, { + readonly: true, + strict: true, + }); + try { + expect( + verificationDatabase + .query("SELECT value FROM disposal_settlement_probe") + .all() + ).toEqual([{ value: "committed" }]); + } finally { + verificationDatabase.close(true); + } + } finally { + if (competingWriter?.inTransaction) competingWriter.run("ROLLBACK"); + competingWriter?.close(true); + await runtime.dispose(); + } + }); }); diff --git a/greenfield/src/server/test/contracts/trpcErrors.test.ts b/greenfield/src/server/test/contracts/trpcErrors.test.ts index 9c71e21ea..bd854d5d7 100644 --- a/greenfield/src/server/test/contracts/trpcErrors.test.ts +++ b/greenfield/src/server/test/contracts/trpcErrors.test.ts @@ -4,6 +4,8 @@ import { TRPCError } from "@trpc/server"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { contractAuthenticationErrorReasons } from "../../../contracts/registry.ts"; +import { DatabaseRuntimeWriteAdmissionTimeoutError } from "../../database/runtime/databaseErrors.ts"; +import { AuthenticationWorkSettlementError } from "../../domains/security/authenticationWorkGate.ts"; import { authenticationPolicyError, publicProcedure, router } from "../../trpc/trpc.ts"; import { createTestRequestContext } from "../support/requestContext.ts"; @@ -11,12 +13,14 @@ const sentinel = "secret /home/ubuntu/private-stack-path"; type ErrorProcedure = | (typeof contractAuthenticationErrorReasons)[number] + | "database-write-unavailable" | "expected" | "forged-policy-cause" | "tampered-policy-cause" | "unexpected"; const errorProcedurePaths = { + "database-write-unavailable": "auth.logout", expected: "events.stream", "forged-policy-cause": "accountSecurity.summary", mfa_enrollment_required: "accountSecurity.stepUpRecovery", @@ -69,6 +73,15 @@ async function queryWireBody(procedure: ErrorProcedure): Promise<{ }); throw error; }), + logout: publicProcedure.query(() => { + throw new AuthenticationWorkSettlementError({ + cause: new DatabaseRuntimeWriteAdmissionTimeoutError({ + message: sentinel, + timeoutMs: 5000, + }), + operation: "webauthn", + }); + }), }), events: router({ stream: publicProcedure.query(() => { @@ -116,6 +129,18 @@ describe("tRPC error transport", () => { expect(text).not.toContain('"path"'); }); + test("maps durable database-write exhaustion to one redacted 503", async () => { + const { response, text } = await queryWireBody("database-write-unavailable"); + + expect(response.status).toBe(503); + expect(text).toContain("Database write capacity is temporarily unavailable"); + expect(text).not.toContain(sentinel); + expect(text).not.toContain("DatabaseRuntimeWriteAdmissionTimeoutError"); + expect(text).not.toContain('"cause"'); + expect(text).not.toContain('"stack"'); + expect(text).not.toContain('"path"'); + }); + for (const reason of contractAuthenticationErrorReasons) { test(`exposes the allowlisted ${reason} policy reason`, async () => { const { response, text } = await queryWireBody(reason); diff --git a/greenfield/src/server/test/support/authenticationWorkGate.ts b/greenfield/src/server/test/support/authenticationWorkGate.ts index cba48aea1..c662bebac 100644 --- a/greenfield/src/server/test/support/authenticationWorkGate.ts +++ b/greenfield/src/server/test/support/authenticationWorkGate.ts @@ -151,17 +151,17 @@ export function createTestGatewayWorkRuntime( operation: "gateway", timeoutMs: options.timeoutMs, }); - options.onFailureBeforeRelease?.(failure); + await options.onFailureBeforeRelease?.(failure); throw failure; } if (outcome.kind === "unavailable") { const failure = new AuthenticationUpstreamUnavailableError({ operation: "gateway", }); - options.onFailureBeforeRelease?.(failure); + await options.onFailureBeforeRelease?.(failure); throw failure; } - options.onResultBeforeRelease?.(outcome.value); + await options.onResultBeforeRelease?.(outcome.value); return outcome.value; } finally { releaseAllowed = true; diff --git a/greenfield/src/server/test/support/databaseWriteAdmission.ts b/greenfield/src/server/test/support/databaseWriteAdmission.ts new file mode 100644 index 000000000..d7182a9e3 --- /dev/null +++ b/greenfield/src/server/test/support/databaseWriteAdmission.ts @@ -0,0 +1,25 @@ +import type { + ImmediateDatabaseWriteAdmission, + MarkDatabaseTransactionStarted, +} from "../../database/immediateWriteAdmission.ts"; + +/** Immediate asynchronous admission used only by isolated single-connection tests. */ +export const testImmediateDatabaseWriteAdmission: ImmediateDatabaseWriteAdmission = + Object.freeze({ + run( + operation: (markTransactionStarted: MarkDatabaseTransactionStarted) => T + ): Promise { + return Promise.resolve().then(() => operation(() => {})); + }, + }); + +/** + * Bound adapter for runtime-shaped test doubles that need immediate-write admission. + * @param operation Synchronous transaction callback under test. + * @returns The callback result through the asynchronous admission boundary. + */ +export function runTestImmediateDatabaseWrite( + operation: (markTransactionStarted: MarkDatabaseTransactionStarted) => T +): Promise { + return testImmediateDatabaseWriteAdmission.run(operation); +} diff --git a/greenfield/src/server/test/support/requestContext.ts b/greenfield/src/server/test/support/requestContext.ts index c6962cbf1..4be657423 100644 --- a/greenfield/src/server/test/support/requestContext.ts +++ b/greenfield/src/server/test/support/requestContext.ts @@ -29,6 +29,7 @@ import { createRequestContext, type RequestContext, } from "../../trpc/context.ts"; +import { runTestImmediateDatabaseWrite } from "./databaseWriteAdmission.ts"; const anonymousAuthentication: RequestAuthentication = { kind: "anonymous" }; @@ -198,8 +199,8 @@ function runInertAuthenticationVerification( const decision = options.onBeforeStart?.() ?? { proceed: true as const }; if (!decision.proceed) return Promise.resolve(decision.value); const signal = options.signal ?? new AbortController().signal; - return work(signal).then((value) => { - options.onResultBeforeRelease?.(value); + return work(signal).then(async (value) => { + await options.onResultBeforeRelease?.(value); return value; }); } @@ -229,12 +230,13 @@ export function createTestAuthenticationLifecycleService( login: overrides.login ?? (() => Promise.resolve({ status: "bootstrap-required" as const })), - logout: overrides.logout ?? (() => false), - revokeSession: overrides.revokeSession ?? (() => ({ revoked: false })), + logout: overrides.logout ?? (() => Promise.resolve(false)), + revokeSession: + overrides.revokeSession ?? (() => Promise.resolve({ revoked: false })), status: overrides.status ?? (() => ({ authenticated: false, isBootstrapRequired: false })), - touchSession: overrides.touchSession ?? ((): undefined => {}), + touchSession: overrides.touchSession ?? (() => Promise.resolve(undefined)), }); } @@ -248,21 +250,27 @@ export function createTestAutomationSecurityLifecycleService( ): AutomationSecurityLifecycleService { return Object.freeze({ createCredential: - overrides.createCredential ?? (() => ({ status: "session-changed" })), + overrides.createCredential ?? + (() => Promise.resolve({ status: "session-changed" })), createPrincipal: - overrides.createPrincipal ?? (() => ({ status: "session-changed" })), + overrides.createPrincipal ?? + (() => Promise.resolve({ status: "session-changed" })), disablePrincipal: - overrides.disablePrincipal ?? (() => ({ status: "session-changed" })), + overrides.disablePrincipal ?? + (() => Promise.resolve({ status: "session-changed" })), listCredentials: overrides.listCredentials ?? (() => ({ status: "session-changed" })), listPrincipals: overrides.listPrincipals ?? (() => ({ status: "session-changed" })), replaceCapabilities: - overrides.replaceCapabilities ?? (() => ({ status: "session-changed" })), + overrides.replaceCapabilities ?? + (() => Promise.resolve({ status: "session-changed" })), revokeCredential: - overrides.revokeCredential ?? (() => ({ status: "session-changed" })), + overrides.revokeCredential ?? + (() => Promise.resolve({ status: "session-changed" })), rotateCredential: - overrides.rotateCredential ?? (() => ({ status: "session-changed" })), + overrides.rotateCredential ?? + (() => Promise.resolve({ status: "session-changed" })), }); } @@ -297,9 +305,11 @@ export function createTestMfaAccountLifecycleService( overrides.reauthenticatePassword ?? (() => Promise.resolve({ status: "session-changed" })), removeTotpFactor: - overrides.removeTotpFactor ?? (() => ({ status: "session-changed" })), + overrides.removeTotpFactor ?? + (() => Promise.resolve({ status: "session-changed" })), removeWebAuthnCredential: - overrides.removeWebAuthnCredential ?? (() => ({ status: "session-changed" })), + overrides.removeWebAuthnCredential ?? + (() => Promise.resolve({ status: "session-changed" })), rotateRecoveryCodes: overrides.rotateRecoveryCodes ?? (() => Promise.resolve({ status: "session-changed" })), @@ -327,7 +337,7 @@ export function createTestMfaLoginLifecycleService( return Object.freeze({ beginPendingLogin: overrides.beginPendingLogin ?? - (() => ({ status: "mfa-unavailable" as const })), + (() => Promise.resolve({ status: "mfa-unavailable" as const })), beginWebAuthnLogin: overrides.beginWebAuthnLogin ?? (() => Promise.resolve({ status: "service-unavailable" })), @@ -341,7 +351,8 @@ export function createTestMfaLoginLifecycleService( overrides.completeWebAuthnLogin ?? (() => Promise.resolve({ status: "service-unavailable" })), pendingLoginSummary: overrides.pendingLoginSummary ?? ((): undefined => {}), - revokePendingLogin: overrides.revokePendingLogin ?? (() => false), + revokePendingLogin: + overrides.revokePendingLogin ?? (() => Promise.resolve(false)), }); } @@ -420,7 +431,10 @@ export function withTestDashboardDatabase( ): DashboardApplicationRuntime { return Object.freeze({ ...applicationRuntime, - database: Object.freeze({ orm: () => Promise.resolve(database) }), + database: Object.freeze({ + orm: () => Promise.resolve(database), + run: runTestImmediateDatabaseWrite, + }), }); } diff --git a/greenfield/src/server/test/system/serverAuthenticationTransport.test.ts b/greenfield/src/server/test/system/serverAuthenticationTransport.test.ts index 1ecb19e4e..5a11bc30d 100644 --- a/greenfield/src/server/test/system/serverAuthenticationTransport.test.ts +++ b/greenfield/src/server/test/system/serverAuthenticationTransport.test.ts @@ -368,7 +368,7 @@ describe("authentication HTTP transport policy", () => { authenticationLifecycle: createTestAuthenticationLifecycleService({ logout: () => { logoutCalls += 1; - return true; + return Promise.resolve(true); }, }), authenticateCredential: () => { diff --git a/greenfield/src/server/trpc/procedureErrorPolicy.test.ts b/greenfield/src/server/trpc/procedureErrorPolicy.test.ts index 11c01fe0c..84944b106 100644 --- a/greenfield/src/server/trpc/procedureErrorPolicy.test.ts +++ b/greenfield/src/server/trpc/procedureErrorPolicy.test.ts @@ -5,6 +5,11 @@ import * as v from "valibot"; import { procedureContracts } from "../../contracts/contractRegistry.ts"; import type { ProcedureContract } from "../../contracts/registry.ts"; +import { + DatabaseRuntimeWriteAdmissionTimeoutError, + DatabaseRuntimeWriteContentionError, +} from "../database/runtime/databaseErrors.ts"; +import { AuthenticationWorkSettlementError } from "../domains/security/authenticationWorkGate.ts"; import { captureFailure } from "../test/support/promise.ts"; import { createTestRequestContext } from "../test/support/requestContext.ts"; import { @@ -59,7 +64,7 @@ describe("procedure expected-error policy", () => { expect(() => Reflect.apply(Array.prototype.push, logoutErrors, ["UNAUTHORIZED"]) ).toThrow(); - expect(logoutErrors).toEqual([]); + expect(logoutErrors).toEqual(["SERVICE_UNAVAILABLE"]); }); test.each(invalidPolicyFixtures)("rejects $name", ({ policy }) => { @@ -98,6 +103,46 @@ describe("procedure expected-error policy", () => { expect((undeclared as TRPCError).message).not.toContain(sentinel); }); + test.each([ + { + error: new DatabaseRuntimeWriteAdmissionTimeoutError({ + message: "private timeout detail", + timeoutMs: 5000, + }), + name: "admission timeout", + }, + { + error: new DatabaseRuntimeWriteContentionError({ + message: "private contention detail", + }), + name: "post-admission contention", + }, + ])("maps direct and settled $name failures only for declared routes", ({ error }) => { + for (const cause of [ + error, + new AuthenticationWorkSettlementError({ + cause: error, + operation: "webauthn", + }), + ]) { + const internal = new TRPCError({ + cause, + code: "INTERNAL_SERVER_ERROR", + message: "private tRPC detail", + }); + const declared = applyProcedureExpectedErrorPolicy("auth.logout", internal); + expect(declared).toMatchObject({ + cause: error, + code: "SERVICE_UNAVAILABLE", + message: "Database write capacity is temporarily unavailable", + }); + + const undeclared = applyProcedureExpectedErrorPolicy("auth.status", internal); + expect(undeclared.code).toBe("INTERNAL_SERVER_ERROR"); + expect(undeclared.message).not.toContain(error.message); + } + }); + test("internalizes expected-looking errors from unregistered procedure paths", async () => { const testRouter = router({ unregistered: publicProcedure.query(() => { diff --git a/greenfield/src/server/trpc/procedureErrorPolicy.ts b/greenfield/src/server/trpc/procedureErrorPolicy.ts index 22665fb86..0e6d50dd9 100644 --- a/greenfield/src/server/trpc/procedureErrorPolicy.ts +++ b/greenfield/src/server/trpc/procedureErrorPolicy.ts @@ -2,6 +2,11 @@ import { getTRPCErrorFromUnknown, StandardSchemaV1Error, TRPCError } from "@trpc import { procedureContracts } from "../../contracts/contractRegistry.ts"; import type { ContractErrorCode, ProcedureContract } from "../../contracts/registry.ts"; +import { + isDatabaseRuntimeWriteUnavailableError, + type DatabaseRuntimeWriteUnavailableError, +} from "../database/runtime/databaseErrors.ts"; +import { AuthenticationWorkSettlementError } from "../domains/security/authenticationWorkGate.ts"; export type ProcedureExpectedErrorPolicy = Readonly< Record @@ -55,11 +60,13 @@ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ "accountSecurity.disableMfa": [ "CONFLICT", "FORBIDDEN", + "SERVICE_UNAVAILABLE", "TOO_MANY_REQUESTS", "UNAUTHORIZED", ], "accountSecurity.reauthenticatePassword": [ "FORBIDDEN", + "SERVICE_UNAVAILABLE", "TOO_MANY_REQUESTS", "UNAUTHORIZED", ], @@ -67,23 +74,27 @@ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ "CONFLICT", "FORBIDDEN", "NOT_FOUND", + "SERVICE_UNAVAILABLE", "UNAUTHORIZED", ], "accountSecurity.removeWebAuthnCredential": [ "CONFLICT", "FORBIDDEN", "NOT_FOUND", + "SERVICE_UNAVAILABLE", "UNAUTHORIZED", ], "accountSecurity.rotateRecoveryCodes": [ "CONFLICT", "FORBIDDEN", + "SERVICE_UNAVAILABLE", "TOO_MANY_REQUESTS", "UNAUTHORIZED", ], "accountSecurity.stepUpRecovery": [ "CONFLICT", "FORBIDDEN", + "SERVICE_UNAVAILABLE", "TOO_MANY_REQUESTS", "UNAUTHORIZED", ], @@ -109,7 +120,13 @@ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ "TOO_MANY_REQUESTS", "UNAUTHORIZED", ], - "auth.changePassword": ["CONFLICT", "FORBIDDEN", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], + "auth.changePassword": [ + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], "auth.login": [ "CONFLICT", "SERVICE_UNAVAILABLE", @@ -119,11 +136,11 @@ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ "auth.loginRecovery": ["SERVICE_UNAVAILABLE", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], "auth.loginTotp": ["SERVICE_UNAVAILABLE", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], "auth.loginWebAuthn": ["SERVICE_UNAVAILABLE", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], - "auth.logout": [], - "auth.revokeSession": ["FORBIDDEN", "UNAUTHORIZED"], + "auth.logout": ["SERVICE_UNAVAILABLE"], + "auth.revokeSession": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], "auth.sessions": ["FORBIDDEN", "UNAUTHORIZED"], "auth.status": [], - "auth.touch": ["FORBIDDEN", "UNAUTHORIZED"], + "auth.touch": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], "automationSecurity.createCredential": [ "CONFLICT", "FORBIDDEN", @@ -143,6 +160,7 @@ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ "CONFLICT", "FORBIDDEN", "NOT_FOUND", + "SERVICE_UNAVAILABLE", "UNAUTHORIZED", ], "automationSecurity.listCredentials": ["FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED"], @@ -151,12 +169,14 @@ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ "CONFLICT", "FORBIDDEN", "NOT_FOUND", + "SERVICE_UNAVAILABLE", "UNAUTHORIZED", ], "automationSecurity.revokeCredential": [ "CONFLICT", "FORBIDDEN", "NOT_FOUND", + "SERVICE_UNAVAILABLE", "UNAUTHORIZED", ], "automationSecurity.rotateCredential": [ @@ -228,6 +248,28 @@ function isImplicitInputValidationError(error: TRPCError): boolean { return error.code === "BAD_REQUEST" && error.cause instanceof StandardSchemaV1Error; } +function databaseWriteUnavailableCause( + error: TRPCError +): DatabaseRuntimeWriteUnavailableError | undefined { + if (error.code !== "INTERNAL_SERVER_ERROR") return undefined; + if (isDatabaseRuntimeWriteUnavailableError(error.cause)) return error.cause; + return error.cause instanceof AuthenticationWorkSettlementError && + isDatabaseRuntimeWriteUnavailableError(error.cause.cause) + ? error.cause.cause + : undefined; +} + +function mapDatabaseWriteUnavailableError(error: TRPCError): TRPCError { + const cause = databaseWriteUnavailableCause(error); + return cause === undefined + ? error + : new TRPCError({ + cause, + code: "SERVICE_UNAVAILABLE", + message: "Database write capacity is temporarily unavailable", + }); +} + /** * Converts undeclared errors from registered production routes into internal defects. * Framework-owned input-validation failures and existing internal defects remain implicit. @@ -239,20 +281,21 @@ export function applyProcedureExpectedErrorPolicy( path: string, error: TRPCError ): TRPCError { + const mappedError = mapDatabaseWriteUnavailableError(error); const expectedErrors = Object.hasOwn(runtimeProcedureExpectedErrorPolicy, path) ? runtimeProcedureExpectedErrorPolicy[path] : undefined; if ( - error.code === "INTERNAL_SERVER_ERROR" || - isImplicitInputValidationError(error) || + mappedError.code === "INTERNAL_SERVER_ERROR" || + isImplicitInputValidationError(mappedError) || (expectedErrors !== undefined && - (expectedErrors as readonly string[]).includes(error.code)) + (expectedErrors as readonly string[]).includes(mappedError.code)) ) { - return error; + return mappedError; } return new TRPCError({ - cause: new UndeclaredProcedureErrorCause(path, error.code), + cause: new UndeclaredProcedureErrorCause(path, mappedError.code), code: "INTERNAL_SERVER_ERROR", message: "Internal server error", }); From c15c27adbde182d43989614662de5ca93142ab48 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Thu, 6 Aug 2026 21:07:51 +0200 Subject: [PATCH 5/6] fix(database): close runtime hardening findings --- greenfield/README.md | 3 +- .../application-architecture.md | 27 +++-- .../greenfield-rewrite/data-and-security.md | 17 ++- .../greenfield-rewrite/implementation-plan.md | 7 +- .../greenfield-rewrite/progress.md | 20 ++-- .../runtime-and-delivery.md | 95 ++++++++++----- .../greenfield-phase-two-threat-model.md | 21 ++-- .../migrations/applyVerifiedMigrations.ts | 10 +- .../migrations/migrationGraph.test.ts | 33 ++++- .../server/database/runtime/databaseErrors.ts | 18 +-- .../database/runtime/databasePath.test.ts | 35 +++++- .../database/runtime/databaseService.test.ts | 84 ++++++++++--- .../database/runtime/databaseStartup.ts | 3 + .../security/authenticationWorkGate.ts | 49 ++++---- .../authenticationWorkGate.webAuthn.test.ts | 113 ++++++++++++++++++ .../platform/runtime/applicationRuntime.ts | 2 +- 16 files changed, 409 insertions(+), 128 deletions(-) diff --git a/greenfield/README.md b/greenfield/README.md index 633fe6537..c6b8b7ffa 100644 --- a/greenfield/README.md +++ b/greenfield/README.md @@ -39,4 +39,5 @@ coexisting application or its `node_modules`. Cutover preserves the Git repository metadata, removes the retired application tree, and promotes the **contents** of this directory to the repository root. No application import or configuration -path should need rewriting during that promotion. +path should need rewriting during that promotion. Persistent state stays in the existing +`/production/state` project tree and is not moved outside the Dashboard project. diff --git a/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md index e5d4cb25b..ea546abc4 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md +++ b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md @@ -394,18 +394,21 @@ Server orchestration represents expected failures as tagged Effect errors in the channel. The tRPC boundary exhaustively maps those internal tags to the stable client code set; unknown defects and internal `cause` values may be logged only through a redaction boundary and are never serialized to clients. One caller-supplied process logger is installed as the only logger on -the existing `ManagedRuntime` and exposed by `ApplicationRuntime` to ordinary TypeScript +the application `ManagedRuntime` and exposed by `ApplicationRuntime` to ordinary TypeScript boundaries. Event-specific allowlists drop unknown fields and Effect messages/annotations; runtime disposal precedes the logger's idempotent flush. -The production web `ApplicationRuntime` merges the structured logger, process-owned SQLite layer, -database-backed realtime pump, and one process-scoped authentication-work service into the same -`ManagedRuntime`. The database layer loads and verifies the release migration graph before opening -one fixed private state file, configures and verifies the connection policy, and constructs -Drizzle from that retained native handle. The composition root obtains that one ORM for every -domain repository, while the realtime layer depends on the same service and therefore finalizes -before SQLite. The narrower generic runtime factory remains available for focused service and -transport tests with an injected realtime layer. +The production web `DashboardApplicationRuntime` coordinates two eagerly initialized, +process-owned scopes. A retained database `ManagedRuntime` loads and verifies the release migration +graph before opening one fixed private state file, configures and verifies the connection policy, +and constructs Drizzle from that retained native handle. A separate application `ManagedRuntime` +owns the structured logger, database-backed realtime pump, and process-scoped authentication-work +service. The composition root obtains the same ORM and bounded write-admission port for every +domain repository; the realtime layer depends on that retained database service. Shutdown disposes +the application scope before the database scope, so realtime and every claimed durable +authentication settlement finish before SQLite is checkpointed and closed. The narrower generic +runtime factory remains available for focused service and transport tests with an injected realtime +layer. The authentication service owns separate bounded admission and active-work semaphores for Gateway verification, password/Argon2 work, TOTP AES/HMAC work, and WebAuthn parsing/signature @@ -416,15 +419,15 @@ while Gateway capacity, deadline, and unavailable tags are exhaustively translat tRPC procedure maps the resulting domain outcome. No request creates or disposes a database or runtime. -The same `ManagedRuntime` coordinates listener shutdown. An external `stop(true)` request crosses +The application `ManagedRuntime` coordinates listener shutdown. An external `stop(true)` request crosses the Promise-facing composition boundary as an abort signal; Effect owns the graceful-stop fiber, deadline/force race, tagged stop and timeout failures, separately bounded force attempt, and settlement of the original graceful operation before the runtime scope is disposed. A rejected graceful stop receives one bounded best-effort force attempt while preserving the initiating -failure. No second runtime or manual timer/`Promise.race` shutdown system is created. +failure. No request-local runtime or manual timer/`Promise.race` shutdown system is created. After listener settlement, runtime finalization closes realtime, passively checkpoints and strictly closes SQLite, and only then flushes the process logger. If listener escalation cannot prove -settlement, the server keeps runtime resources alive for supervisor containment rather than +settlement, the server keeps both runtime scopes alive for supervisor containment rather than closing a database beneath potentially active requests. ## Realtime Architecture diff --git a/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md index 023bafd40..b7dfa1df3 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md +++ b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md @@ -13,13 +13,19 @@ current Bun driver no longer accepts the legacy `schema` option; add its explicit `relations` model only for domains that use the relational query API. Pin and revalidate the directory/file identities during acquisition, and reject a rollback-journal, shared-memory, or WAL sidecar - unless it is a single-link current-user-owned `0600` regular file. + unless it is a single-link current-user-owned `0600` regular file. Reject a parent chain whose + ownership or write permissions let another principal replace the validated directory entry; + application startup never chmods, chowns, or otherwise repairs that chain. - Enable and verify foreign keys and checks, disable `trusted_schema`, select WAL with `synchronous=FULL` and a 1,000-page automatic checkpoint, and set `busy_timeout=0`. The zero timeout is the measured non-blocking policy: a synchronous SQLite wait must not stall Bun's event loop. Effect owns explicit bounded retry/deadline schedules at cross-process admission and - read boundaries; the future worker's write adapters must adopt the same explicit policy before - that process starts. + read boundaries. Every current domain repository receives the same process-owned asynchronous + write-admission port: it may retry only before `BEGIN IMMEDIATE` admits the transaction and the + synchronous callback starts. A callback is never replayed. Exhausted admission and post-admission + contention remain typed failures; only mutation routes that declare temporary write unavailability + expose the fixed redacted `SERVICE_UNAVAILABLE` response. The future worker must use the same + explicit policy before that process starts. - Use Drizzle's typed query builder for ordinary reads/writes and its parameterized `sql` tagged template for SQLite-specific queries, CTEs, queue claims, and expressions not represented cleanly by the builder. @@ -232,7 +238,8 @@ table/column existence fallbacks. Retention remains explicit per append-only table. The later maintenance job removes bounded batches, requests passive checkpoints during normal operation, exposes WAL/checkpoint health, and runs expensive optimization only under a resource-scoped job. Backups include database plus -release/schema identity and remain restore-tested release artifacts. +release/schema identity, resolve only beneath +`/production/state/backups`, and remain restore-tested release artifacts. ## Worker and Privileged Operations @@ -251,7 +258,7 @@ Queue behavior is explicit: - resource leases prevent conflicting deploy, restore, Docker, or OpenClaw operations; - cancel requests are persisted and propagated to the child process group; - stdout/stderr are incrementally bounded, redacted, and spilled to a controlled log file when - necessary; and + necessary only beneath `/production/state/job-output`; and - final structured output is validated before persistence or display. `Bun.spawn` receives argument arrays, a deliberate environment allowlist, an explicit working diff --git a/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md b/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md index 16b4ada43..3adaeb134 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md +++ b/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md @@ -28,7 +28,9 @@ the remaining rewrite phases are still incomplete. - create immutable build/release manifests and resource-capped development/test scripts; and - implement probes and observability. -**Exit gate:** empty database, docs, build, web, worker, and paired rollback work end-to-end. +**Exit gate:** greenfield bootstrap protects the existing project ancestor chain and provisions one +canonical `/production/state` root for the static web/worker UID; empty database, +docs, build, web, worker, and paired rollback then work end-to-end. ### Phase 2: trust and transport @@ -145,6 +147,9 @@ The rewrite is ready only when all of the following are true: after a resolved incident reappears; - database constraints, query plans, migrations, backup, restore, retention, WAL, and paired rollback are verified; +- greenfield bootstrap provisions the same canonical `/production/state` root for web + and worker, and activation fails before pointer promotion on ancestor ownership, mode, symlink, + or identity drift; - the Drizzle schema, generated fresh baseline, and an introspected freshly initialized database agree in CI; - authentication, step-up, automation scopes, dangerous adapters, file boundaries, secret diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md index beb4cd5d4..123d189e0 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -716,12 +716,13 @@ closes a phase; dated entries below provide the evidence, not a second status so ### 2026-08-06 — Process-owned database runtime -- The production `ManagedRuntime` now owns one strict native SQLite connection and the exact - Drizzle client built from it. Dashboard repositories and the database-backed realtime pump use - that same handle; request handling cannot create or dispose a database or runtime. The layer - graph finalizes realtime before a passive checkpoint and strict SQLite close, and the process - logger flushes only after runtime disposal. Listener drain still completes before this sequence, - with readiness withdrawn before drain begins. +- The production Dashboard runtime now coordinates a dedicated database `ManagedRuntime` with a + separate application `ManagedRuntime`. The retained database scope owns one strict native SQLite + connection and the exact Drizzle client built from it; Dashboard repositories and the + database-backed realtime pump use that same handle through narrow ports. Request handling cannot + create or dispose a database or runtime. Listener drain completes first, then the application + scope finalizes realtime and authentication work before the database scope passively checkpoints + and strictly closes SQLite; the process logger flushes only after both scopes are disposed. - Startup verifies the complete release-owned migration graph before database mutation. The Linux artifact reader holds descriptor-rooted directories and regular files through `/proc/self/fd`, rejects symlinks, hardlinks, special files, inventory drift, path replacement, invalid UTF-8, @@ -731,7 +732,12 @@ closes a phase; dated entries below provide the evidence, not a second status so directory. Dashboard creates a missing file with exclusive no-follow `0600` semantics, opens it through SQLite with `create: false`, pins and revalidates the directory and file device/inode identities, and validates every rollback-journal, shared-memory, or WAL sidecar present during - acquisition as a single-link current-user-owned `0600` regular file. + acquisition as a single-link current-user-owned `0600` regular file. It also rejects a writable + or untrusted ancestor chain and never mutates host permissions. Persistent state remains at + `/production/state` inside the existing project layout. The future greenfield + bootstrap/release boundary must safely protect that ancestor chain before runtime validation; on + the current host this includes clearing group write from `/home/ubuntu/projects`. That caller and + its disposable-host activation test remain an explicit Phase 1 blocker. - Every connection verifies foreign keys and checks enabled, `trusted_schema` disabled, WAL, `synchronous=FULL`, a 1,000-page automatic checkpoint, and `busy_timeout=0`. Zero is deliberate: SQLite never blocks the Bun thread waiting for another process; bounded Effect schedules own diff --git a/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md index eaf7c20f7..28360bdc7 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md +++ b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md @@ -263,14 +263,20 @@ logs use stable event names and include release identity, process role, duration safe identifiers. They do not serialize arbitrary request bodies or command environments. The current production web runtime requires one process logger, installs it as the only Effect -logger, and reuses that exact instance at ordinary HTTP/tRPC boundaries. The same `ManagedRuntime` -owns the retained SQLite/Drizzle service and its database-backed realtime dependency. After the -listener settles, realtime finalizes before passive checkpoint and strict database close; the -synchronous log sink flushes last. Its serializer emits bounded NDJSON from event-specific -allowlisted fields, and a sink failure emits one constant direct-stderr fallback without recursive -logging. The future executable web/worker composition roots still own creation of the stdout/stderr -sink, release/config identity, and startup/shutdown events; this slice does not claim that absent -process entrypoint. +logger in the application scope, and reuses that exact instance at ordinary HTTP/tRPC boundaries. +The Dashboard composition root coordinates that application `ManagedRuntime` with a separate, +retained database `ManagedRuntime`. The database-backed realtime layer and all repositories receive +the same SQLite/Drizzle service through narrow ports. After the listener settles, the application +scope finalizes realtime and authentication work before the database scope performs its passive +checkpoint and strict close; the synchronous log sink flushes last. Its serializer emits bounded +NDJSON from event-specific allowlisted fields, and a sink failure emits one constant direct-stderr +fallback without recursive logging. The future executable web/worker composition roots still own +creation of the stdout/stderr sink, release/config identity, and startup/shutdown events; this slice +does not claim that absent process entrypoint. Replacement production units must bind both streams, +including the direct-stderr fallback, to project-derived files beneath +`/production/state/logs`; default journald persistence, `LogsDirectory=`, and a +configurable external log root are forbidden. Transient job units route their streams beneath +`/production/state/job-output` instead. Expose distinct probes: @@ -408,25 +414,51 @@ after the rewritten executable roots and immutable release wrapper exist, then v referenced path and the measured resource limits in CI. Until then, the absence of `greenfield/systemd/` is an explicit incomplete delivery item rather than a compatibility link. +Persistent state remains inside the existing Dashboard project layout at +`/production/state`, but outside every immutable release directory. Production +composition derives that path from `MIRA_DASHBOARD_PROJECT_ROOT`; neither configuration nor a +systemd `StateDirectory=` may select a separate state root. The future greenfield bootstrap/release +boundary must create that directory as current-user-owned `0700` and protect its existing ancestor +chain before activation. For a non-sticky ancestor owned by the managed UID, preparation may only +clear group/other write bits through a no-follow directory descriptor, preserve every other +permission, verify device/inode before and after, and then revalidate the whole chain. It must fail +closed for symlinks, ownership drift, or a writable foreign-owned ancestor; application runtime +startup never repairs permissions. On the current host, first cutover therefore requires +`chmod go-w /home/ubuntu/projects` (currently `0775` to `0755`) without moving any project data. +The real bootstrap/release caller, the production-path composition tests, and the replacement-unit +assertions remain blocking Phase 1 delivery items. Unit source remains under +`/production/checkout/systemd` or the active development worktree. Only installed +copies of systemd unit files may live outside `/development` or +`/production`; all Dashboard state, logs, backups, runtime binaries, checkouts, and +release artifacts remain inside those project directories. + Recommended layout: ```text -production/ - releases// - server/ - browser/ - migrations/ - docs/generated/ - scripts/ - release-manifest.json - releases/current -> - releases/previous -> - runtimes/bun//bun - state/ - mira-dashboard.db - backups/ - job-output/ - logs/ +/ + production/ + checkout/ + releases// + server/ + browser/ + migrations/ + docs/generated/ + scripts/ + release-manifest.json + releases/current -> + releases/previous -> + runtimes/bun//bun + state/ + mira-dashboard.db + backups/ + job-output/ + logs/ + development/ + state/ + local/ + preview/ + remote/ + worktrees// ``` The release manifest contains Git commit, clean-tree state, Bun revision, lockfile hash, @@ -437,14 +469,17 @@ Deployment flow: 1. Build and test one artifact using the same resolved Bun runtime throughout the build. 2. Transfer or materialize it into a new immutable release directory and verify every hash. -3. Acquire the deployment lease, drain active jobs, enter maintenance mode, and quiesce all +3. Prepare and verify `/production/state` plus its protected ancestor chain before + changing the active release pointer. +4. Acquire the deployment lease, drain active jobs, enter maintenance mode, and quiesce all database writers. -4. Snapshot and verify the current database while writers remain stopped. -5. Apply migrations to a copy, run schema/preflight checks, then atomically promote the +5. Snapshot and verify the current database while writers remain stopped. +6. Apply migrations to a copy, run schema/preflight checks, then atomically promote the database state. -6. Start worker and web against the candidate, with readiness deadlines. -7. Run authenticated smoke checks, including tRPC, SSE, Gateway, docs, and one safe queued job. -8. Atomically record current/previous and prune only releases whose manifests verify. +7. Let one deployment-held initializer create or promote the database, then start worker in + `validate-only` mode and web against the candidate, with readiness deadlines. +8. Run authenticated smoke checks, including tRPC, SSE, Gateway, docs, and one safe queued job. +9. Atomically record current/previous and prune only releases whose manifests verify. Because the new application carries no schema compatibility code, rollback is a **release and database pair**. If activation crosses a non-backward-compatible migration, rollback restores diff --git a/greenfield/docs/security/greenfield-phase-two-threat-model.md b/greenfield/docs/security/greenfield-phase-two-threat-model.md index f2e9e8e74..ce06e2d87 100644 --- a/greenfield/docs/security/greenfield-phase-two-threat-model.md +++ b/greenfield/docs/security/greenfield-phase-two-threat-model.md @@ -70,16 +70,16 @@ Gateway connection, event recovery, session, or chat behavior. ## Actors And Trust Boundaries -| Actor | Trust and authority | -| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| Operator browser | May hold a hardened Dashboard session cookie. It is still untrusted input and cannot assert recent authentication or capabilities. | -| Automation caller | Presents one bearer credential and receives only the principal's exact registered capabilities. It cannot administer automation security. | -| Remote attacker | May submit cross-site requests, malformed bodies, guessed credentials, replayed proofs, or concurrency floods. | -| Trusted reverse proxy | The only component allowed to assert forwarded client identity and the absolute inbound body deadline. | -| Dashboard web process | Owns request policy, the process `ManagedRuntime`, security services, and SQLite access. It is not a secret-free boundary. | -| SQLite database | Durable authority for identities, validators, cooldowns, challenges, authorization versions, audit, and realtime events. | -| OpenClaw Gateway | External authority for the submitted Gateway token. During Phase 2 it is contacted only by the one-shot bootstrap probe. | -| Future worker and persistent Gateway client | Outside this threat-model closure; their adapters and protocols require separate qualification. | +| Actor | Trust and authority | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Operator browser | May hold a hardened Dashboard session cookie. It is still untrusted input and cannot assert recent authentication or capabilities. | +| Automation caller | Presents one bearer credential and receives only the principal's exact registered capabilities. It cannot administer automation security. | +| Remote attacker | May submit cross-site requests, malformed bodies, guessed credentials, replayed proofs, or concurrency floods. | +| Trusted reverse proxy | The only component allowed to assert forwarded client identity and the absolute inbound body deadline. | +| Dashboard web process | Owns request policy, coordinated application/database `ManagedRuntime` scopes, security services, and SQLite access. It is not a secret-free boundary. | +| SQLite database | Durable authority for identities, validators, cooldowns, challenges, authorization versions, audit, and realtime events. | +| OpenClaw Gateway | External authority for the submitted Gateway token. During Phase 2 it is contacted only by the one-shot bootstrap probe. | +| Future worker and persistent Gateway client | Outside this threat-model closure; their adapters and protocols require separate qualification. | The security-relevant crossings are browser or automation caller to Dashboard, trusted proxy to Dashboard, Dashboard to SQLite, Dashboard to the local OpenClaw Gateway, and Dashboard event @@ -106,6 +106,7 @@ security-administration boundary. | A fake or auth-disabled Gateway sends binary, unknown, malformed, oversized, duplicate, out-of-order, wrong-ID, contradictory, or permissive frames. | Accept text JSON only; cap the challenge at 4 KiB and current installed hello at 25 MiB; allow exactly challenge plus matching response; require operator role, `operator.admin` requested only for this handshake, and token auth mode; fail every unknown or contradictory frame immediately; classify only structured token mismatch as invalid. | `src/server/platform/gateway/gatewayCredentialProtocol.test.ts`; `src/server/platform/gateway/gatewayCredentialVerifier.test.ts`; `src/server/test/system/serverGatewayCredentialVerification.test.ts` | | Gateway startup or transport unavailability creates an internal retry storm or bypasses durable throttling. | Never reconnect or retry inside the verifier, including `startup-sidecars`; redact the failure and require the operator/client to retry the whole HTTP bootstrap request under durable cooldown. | `src/server/platform/gateway/gatewayCredentialVerifier.test.ts`; `src/server/test/system/serverGatewayCredentialVerification.test.ts`; `src/server/domains/security/authenticationLifecycle.rateLimit.test.ts` | | A success, failure, setup error, or abort settles Effect work while the native socket remains alive. | Once a socket exists, every terminal path initiates close and the Promise remains pending until native close is observed; the enclosing Effect permit therefore remains held until the transport actually settles. | `src/server/platform/gateway/gatewayCredentialVerifier.test.ts`; `src/server/domains/security/authenticationWorkGate.test.ts`; `src/server/test/system/serverGatewayCredentialVerification.test.ts` | +| A verifier result races its deadline or caller abort while durable database settlement is pending. | Result, failure, timeout, and cancellation contend for one synchronous settlement claim; every loser awaits the winner, the verifier deadline stops at the upstream outcome, and process permits remain held until the claimed settlement finishes. | `src/server/domains/security/authenticationWorkGate.webAuthn.test.ts`; `src/server/platform/runtime/dashboardApplicationRuntime.test.ts` | | A schema or migration weakens a security invariant. | One checksummed unpublished baseline, strict tables, constraint/introspection tests, and Drizzle no-drift checks. | `src/server/database/migrations/securityIdentitySchema.baseline.test.ts`; `src/server/database/migrations/securityIdentitySchema.automation.test.ts`; `src/server/database/migrations/mfaLifecycleSchema.test.ts`; `src/server/database/migrations/migrationGraph.test.ts` | ## Selective Effect Boundary diff --git a/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts b/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts index c9bc9567a..7219a3f4a 100644 --- a/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts +++ b/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts @@ -114,7 +114,15 @@ function expectedSchemaObjects( } } -function maximumExpectedSchemaObjectCount( +/** + * Finds the bounded schema-inventory high-water mark across every migration prefix. + * Later migrations may drop reviewed objects, so the final graph size is not a safe + * bound for inspecting a database stopped at an earlier valid prefix. + * @param migrations Ordered checksum-verified migration graph. + * @returns Maximum schema-object count across all prefixes, bounded at 4096. + * @internal + */ +export function maximumExpectedSchemaObjectCount( migrations: readonly VerifiedMigration[] ): number { const expectedDatabase = new Database(":memory:", { strict: true }); diff --git a/greenfield/src/server/database/migrations/migrationGraph.test.ts b/greenfield/src/server/database/migrations/migrationGraph.test.ts index 693456a6d..3b014184c 100644 --- a/greenfield/src/server/database/migrations/migrationGraph.test.ts +++ b/greenfield/src/server/database/migrations/migrationGraph.test.ts @@ -5,8 +5,14 @@ import { migrationsDirectory, openFreshMigratedDatabase, } from "../../test/support/freshDatabase.ts"; -import { applyVerifiedMigrations } from "./applyVerifiedMigrations.ts"; -import { loadVerifiedMigrations } from "./loadVerifiedMigrations.ts"; +import { + applyVerifiedMigrations, + maximumExpectedSchemaObjectCount, +} from "./applyVerifiedMigrations.ts"; +import { + loadVerifiedMigrations, + type VerifiedMigration, +} from "./loadVerifiedMigrations.ts"; interface IntegrityRow { integrity_check: string; @@ -45,6 +51,29 @@ const expectedTables: string[] = [ "users", ]; describe("database migration graph", () => { + test("bounds schema inventory by the largest valid prefix before later object drops", () => { + const migrations = [ + { + id: "20260806000000_schema-inventory-peak", + migrationSha256: "0".repeat(64), + snapshotSha256: "1".repeat(64), + statements: [ + "CREATE TABLE retained (id INTEGER PRIMARY KEY) STRICT", + "CREATE TABLE removed (id INTEGER PRIMARY KEY, value TEXT NOT NULL) STRICT", + "CREATE INDEX removed_value_index ON removed(value)", + ], + }, + { + id: "20260806000001_drop-schema-objects", + migrationSha256: "2".repeat(64), + snapshotSha256: "3".repeat(64), + statements: ["DROP TABLE removed"], + }, + ] satisfies readonly VerifiedMigration[]; + + expect(maximumExpectedSchemaObjectCount(migrations)).toBe(3); + }); + test("contains one reviewed baseline applicable to an empty database", async () => { const migrations = await loadVerifiedMigrations({ directory: migrationsDirectory, diff --git a/greenfield/src/server/database/runtime/databaseErrors.ts b/greenfield/src/server/database/runtime/databaseErrors.ts index 553f8886a..27882fbfa 100644 --- a/greenfield/src/server/database/runtime/databaseErrors.ts +++ b/greenfield/src/server/database/runtime/databaseErrors.ts @@ -1,5 +1,7 @@ import { Schema } from "effect"; +const TaggedErrorClass = Schema.TaggedError; + export type DatabaseRuntimePathFailureReason = | "database-file-invalid" | "state-directory-invalid"; @@ -14,7 +16,7 @@ export type DatabaseRuntimeStartupFailureReason = | "options-invalid"; /** Expected failure when the retained database path violates its private-file policy. */ -export class DatabaseRuntimePathError extends Schema.TaggedErrorClass( +export class DatabaseRuntimePathError extends TaggedErrorClass( "mira-dashboard/server/database/runtime/DatabaseRuntimePathError" )("DatabaseRuntimePathError", { message: Schema.String, @@ -25,7 +27,7 @@ export class DatabaseRuntimePathError extends Schema.TaggedErrorClass( +export class DatabaseRuntimeStartupError extends TaggedErrorClass( "mira-dashboard/server/database/runtime/DatabaseRuntimeStartupError" )("DatabaseRuntimeStartupError", { message: Schema.String, @@ -41,7 +43,7 @@ export class DatabaseRuntimeStartupError extends Schema.TaggedErrorClass( +export class DatabaseRuntimeLockTimeoutError extends TaggedErrorClass( "mira-dashboard/server/database/runtime/DatabaseRuntimeLockTimeoutError" )("DatabaseRuntimeLockTimeoutError", { message: Schema.String, @@ -49,7 +51,7 @@ export class DatabaseRuntimeLockTimeoutError extends Schema.TaggedErrorClass( +export class DatabaseRuntimeWriteAdmissionTimeoutError extends TaggedErrorClass( "mira-dashboard/server/database/runtime/DatabaseRuntimeWriteAdmissionTimeoutError" )("DatabaseRuntimeWriteAdmissionTimeoutError", { message: Schema.String, @@ -57,28 +59,28 @@ export class DatabaseRuntimeWriteAdmissionTimeoutError extends Schema.TaggedErro }) {} /** Expected non-replayed contention after an immediate transaction was admitted. */ -export class DatabaseRuntimeWriteContentionError extends Schema.TaggedErrorClass( +export class DatabaseRuntimeWriteContentionError extends TaggedErrorClass( "mira-dashboard/server/database/runtime/DatabaseRuntimeWriteContentionError" )("DatabaseRuntimeWriteContentionError", { message: Schema.String, }) {} /** Fail-closed signal that a published database needs a verified release snapshot. */ -export class DatabaseRuntimeSnapshotRequiredError extends Schema.TaggedErrorClass( +export class DatabaseRuntimeSnapshotRequiredError extends TaggedErrorClass( "mira-dashboard/server/database/runtime/DatabaseRuntimeSnapshotRequiredError" )("DatabaseRuntimeSnapshotRequiredError", { message: Schema.String, }) {} /** Sanitized failure from a process-owned passive WAL checkpoint. */ -export class DatabaseRuntimeCheckpointError extends Schema.TaggedErrorClass( +export class DatabaseRuntimeCheckpointError extends TaggedErrorClass( "mira-dashboard/server/database/runtime/DatabaseRuntimeCheckpointError" )("DatabaseRuntimeCheckpointError", { message: Schema.String, }) {} /** Sanitized failure while closing the process-owned native SQLite handle. */ -export class DatabaseRuntimeCloseError extends Schema.TaggedErrorClass( +export class DatabaseRuntimeCloseError extends TaggedErrorClass( "mira-dashboard/server/database/runtime/DatabaseRuntimeCloseError" )("DatabaseRuntimeCloseError", { message: Schema.String, diff --git a/greenfield/src/server/database/runtime/databasePath.test.ts b/greenfield/src/server/database/runtime/databasePath.test.ts index 9f10da081..64b309e1d 100644 --- a/greenfield/src/server/database/runtime/databasePath.test.ts +++ b/greenfield/src/server/database/runtime/databasePath.test.ts @@ -6,6 +6,7 @@ import { mkdtemp, rename, rm, + stat, symlink, writeFile, } from "node:fs/promises"; @@ -84,15 +85,29 @@ describe("database runtime path policy", () => { ); }); - test("rejects a replaceable state-directory entry beneath a writable parent", async () => { + test("accepts a state directory beneath a non-writable traversable ancestor", async () => { const parentDirectory = await privateTemporaryDirectory(); const stateDirectory = path.join(parentDirectory, "state"); await mkdir(stateDirectory, { mode: 0o700 }); - await chmod(parentDirectory, 0o777); + await chmod(parentDirectory, 0o755); + + const prepared = await prepareDatabasePath(stateDirectory, true); + expect(prepared?.filePath).toBe( + path.join(stateDirectory, dashboardDatabaseFileName) + ); + }); + + test("rejects an owner-owned 0775 ancestor without changing its mode", async () => { + const parentDirectory = await privateTemporaryDirectory(); + const stateDirectory = path.join(parentDirectory, "state"); + await mkdir(stateDirectory, { mode: 0o700 }); + await chmod(parentDirectory, 0o775); const failure = await rejectionOf(prepareDatabasePath(stateDirectory, true)); + const parentStatus = await stat(parentDirectory); expect(failure).toBeInstanceOf(DatabaseRuntimePathError); expect(failure).toMatchObject({ reason: "state-directory-invalid" }); + expect(parentStatus.mode & 0o777).toBe(0o775); }); test("rejects symlinked, multiply linked, and permissive database files", async () => { @@ -177,6 +192,22 @@ describe("database runtime path policy", () => { expect(failure).toMatchObject({ reason: "state-directory-invalid" }); }); + test("detects state-directory entry replacement after preparation", async () => { + const parentDirectory = await privateTemporaryDirectory(); + const stateDirectory = path.join(parentDirectory, "state"); + const displacedDirectory = path.join(parentDirectory, "displaced-state"); + await mkdir(stateDirectory, { mode: 0o700 }); + const prepared = await prepareDatabasePath(stateDirectory, true); + if (!prepared) throw new Error("Expected a prepared database path"); + + await rename(stateDirectory, displacedDirectory); + await mkdir(stateDirectory, { mode: 0o700 }); + + const failure = await rejectionOf(assertDatabasePathStillValid(prepared)); + expect(failure).toBeInstanceOf(DatabaseRuntimePathError); + expect(failure).toMatchObject({ reason: "state-directory-invalid" }); + }); + test("detects parent-chain policy drift after preparation", async () => { const parentDirectory = await privateTemporaryDirectory(); const stateDirectory = path.join(parentDirectory, "state"); diff --git a/greenfield/src/server/database/runtime/databaseService.test.ts b/greenfield/src/server/database/runtime/databaseService.test.ts index 3cf27573f..276ac6117 100644 --- a/greenfield/src/server/database/runtime/databaseService.test.ts +++ b/greenfield/src/server/database/runtime/databaseService.test.ts @@ -25,6 +25,10 @@ import { databaseRuntimeLayer, type DatabaseRuntimeLayerOptions, } from "./databaseService.ts"; +import { + initializeDatabaseRuntime, + normalizeDatabaseRuntimeOptions, +} from "./databaseStartup.ts"; const migrationsDirectory = path.resolve(import.meta.dir, "../../../../migrations"); const releaseId = "0".repeat(40); @@ -47,6 +51,12 @@ async function rejectionOf(promise: Promise): Promise { } } +function bindCallable(member: unknown, receiver: object): unknown { + if (typeof member !== "function") return member; + return (...arguments_: unknown[]): unknown => + Reflect.apply(member, receiver, arguments_) as unknown; +} + function options( stateDirectory: string, startupMode: DatabaseRuntimeLayerOptions["startupMode"] = "initialize-empty" @@ -181,7 +191,7 @@ describe("database runtime service", () => { ); }); - test("validate-only waits for a concurrent empty-database initializer", async () => { + test("validate-only rechecks empty state after a concurrent initializer commits", async () => { const stateDirectory = await privateTemporaryDirectory(); const databasePath = path.join(stateDirectory, "mira-dashboard.db"); await writeFile(databasePath, "", { mode: 0o600 }); @@ -194,32 +204,68 @@ describe("database runtime service", () => { initializer.run("PRAGMA journal_mode = WAL"); initializer.run("PRAGMA busy_timeout = 0"); initializer.run("BEGIN IMMEDIATE"); - let validationSettled = false; + const validator = new Database(databasePath, { strict: true }); + const initialSchemaRead = Promise.withResolvers(); + let initialSchemaReadRecorded = false; try { - const validation = buildRuntime(options(stateDirectory, "validate-only")); - void validation.then( - () => { - validationSettled = true; - return true; + applyVerifiedMigrations(initializer, migrations, { releaseId }); + const validationBoundary = new Proxy(validator, { + get(target, property) { + if (property === "query") { + return ((sql: string) => { + const statement = target.query(sql); + if ( + !initialSchemaReadRecorded && + sql.includes("SELECT 1 AS present") + ) { + return new Proxy(statement, { + get(statementTarget, statementProperty) { + if (statementProperty === "get") { + return () => { + const observation: unknown = + statementTarget.get(); + initialSchemaReadRecorded = true; + initialSchemaRead.resolve(observation); + return observation; + }; + } + const member: unknown = Reflect.get( + statementTarget, + statementProperty, + statementTarget + ); + return bindCallable(member, statementTarget); + }, + }); + } + return statement; + }) as Database["query"]; + } + const member: unknown = Reflect.get(target, property, target); + return bindCallable(member, target); }, - () => { - validationSettled = true; - return false; - } + }); + const normalizedOptions = normalizeDatabaseRuntimeOptions( + options(stateDirectory, "validate-only") ); - await Bun.sleep(25); - expect(validationSettled).toBeFalse(); - applyVerifiedMigrations(initializer, migrations, { releaseId }); - await Bun.sleep(10); - expect(validationSettled).toBeFalse(); + const startup = initializeDatabaseRuntime( + validationBoundary, + migrations, + normalizedOptions + ); + const validation = Effect.runPromise(startup); + void validation.catch(() => null); + expect(await initialSchemaRead.promise).toBeNull(); + expect(initializer.inTransaction).toBeTrue(); initializer.run("COMMIT"); + const diagnostics = await validation; - const validated = await validation; - expect(validated.service.diagnostics.appliedMigrations).toBe(0); - expect(validated.service.diagnostics.startupMode).toBe("validate-only"); + expect(diagnostics.appliedMigrations).toBe(0); + expect(diagnostics.startupMode).toBe("validate-only"); } finally { if (initializer.inTransaction) initializer.run("ROLLBACK"); + validator.close(true); initializer.close(true); } }); diff --git a/greenfield/src/server/database/runtime/databaseStartup.ts b/greenfield/src/server/database/runtime/databaseStartup.ts index 8b41b3413..f344b9a32 100644 --- a/greenfield/src/server/database/runtime/databaseStartup.ts +++ b/greenfield/src/server/database/runtime/databaseStartup.ts @@ -226,6 +226,9 @@ function startOrValidateDatabase( let state = inspectMigrationState(database, migrations); if (state === "empty" && options.startupMode === "validate-only") { + // Acquire the writer slot before rechecking so a concurrent initializer must + // either commit first or remain excluded. This transaction starts a fresh + // snapshot; the already-current validation path below stays deferred. const recheck = database.transaction(() => inspectMigrationState(database, migrations) ); diff --git a/greenfield/src/server/domains/security/authenticationWorkGate.ts b/greenfield/src/server/domains/security/authenticationWorkGate.ts index 77716cc97..89ae9dbbe 100644 --- a/greenfield/src/server/domains/security/authenticationWorkGate.ts +++ b/greenfield/src/server/domains/security/authenticationWorkGate.ts @@ -1,4 +1,4 @@ -import { Context, Data, Effect, Exit, Fiber, FiberSet, Layer, Semaphore } from "effect"; +import { Context, Data, Effect, Fiber, FiberSet, Layer, Semaphore } from "effect"; export type AuthenticationWorkOperation = "gateway" | "password" | "totp" | "webauthn"; type AuthenticationVerificationOperation = Extract< @@ -307,7 +307,8 @@ function trackedWork( const controller = new AbortController(); const workerState = { callerInterrupted: false, started: false }; - const upstreamCompleted = Promise.withResolvers(); + // This milestone ends the verifier deadline before durable settlement begins. + const verificationOutcome = Promise.withResolvers(); type SettlementClaim = Readonly<{ promise: Promise; source: "cancellation" | "failure" | "result" | "timeout"; @@ -348,8 +349,8 @@ function trackedWork( "cancellation", onCancellationBeforeRelease ); - return awaitSettlement(claim).pipe(Effect.orDie); - }); + return awaitSettlement(claim); + }).pipe(Effect.uninterruptible, Effect.orDie); const notifyFailureBeforeRelease = ( failure: E | AuthenticationWorkTimeoutError ): Effect.Effect => @@ -357,12 +358,12 @@ function trackedWork( if (workerState.callerInterrupted) { return Effect.void; } + verificationOutcome.resolve(); const { claim } = beginSettlement("failure", () => onFailureBeforeRelease?.(failure) ); - upstreamCompleted.resolve(); - return Effect.uninterruptible(awaitSettlement(claim)); - }); + return awaitSettlement(claim); + }).pipe(Effect.uninterruptible); const notifyResultBeforeRelease = ( value: T ): Effect.Effect => @@ -370,12 +371,12 @@ function trackedWork( if (workerState.callerInterrupted) { return Effect.void; } + verificationOutcome.resolve(); const { claim } = beginSettlement("result", () => onResultBeforeRelease?.(value) ); - upstreamCompleted.resolve(); - return Effect.uninterruptible(awaitSettlement(claim)); - }); + return awaitSettlement(claim); + }).pipe(Effect.uninterruptible); const activePermit = gate.active.take(1).pipe(Effect.as(true as const)); const abortedBeforeStart = abortSignalEffect(controller.signal).pipe( Effect.as(false as const) @@ -386,7 +387,7 @@ function trackedWork( const claim = settlementState.claim; return claim === undefined ? Effect.void - : awaitSettlement(claim).pipe(Effect.ignore); + : awaitSettlement(claim).pipe(Effect.uninterruptible, Effect.ignore); }); const releaseWorkerPermits = awaitClaimBeforeRelease.pipe( Effect.andThen(releaseAdmission) @@ -454,7 +455,7 @@ function trackedWork( Effect.andThen(awaitQueuedWorker), Effect.andThen(Effect.fail(timeoutFailure)) ) - : Effect.promise(() => upstreamCompleted.promise) + : awaitSettlement(claim) ) ); const continueUnlessTimeoutOwnsSettlement = Effect.suspend(() => @@ -462,25 +463,15 @@ function trackedWork( ? Effect.never : Effect.void ); - const awaitUpstreamMilestone = Effect.promise( - () => upstreamCompleted.promise - ).pipe(Effect.andThen(continueUnlessTimeoutOwnsSettlement)); - const awaitWorkerOutcome = Fiber.await(fiber).pipe( - Effect.flatMap((exit) => - Effect.suspend(() => { - if (settlementState.claim?.source === "timeout") { - return Effect.never; - } - return Exit.isSuccess(exit) - ? Effect.void - : Effect.failCause(exit.cause); - }) - ) - ); const awaitUpstream = Effect.raceFirst( - awaitUpstreamMilestone, - awaitWorkerOutcome + Effect.promise(() => verificationOutcome.promise).pipe( + Effect.andThen(continueUnlessTimeoutOwnsSettlement) + ), + Fiber.await(fiber).pipe( + Effect.andThen(continueUnlessTimeoutOwnsSettlement) + ) ).pipe( + Effect.asVoid, Effect.timeoutOrElse({ duration: timeoutMs, orElse: () => timeoutFallback, diff --git a/greenfield/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts b/greenfield/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts index 998582ee2..ead872ca9 100644 --- a/greenfield/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts +++ b/greenfield/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts @@ -309,6 +309,119 @@ describe("process WebAuthn verification work service", () => { } }); + test.each(["result", "failure"] as const)( + "keeps one timeout settlement when non-cooperative work finishes late with %s", + async (lateOutcome) => { + const runtime = createApplicationRuntime({ + authenticationWork: { + webAuthnMaximumConcurrent: 1, + webAuthnMaximumQueued: 1, + }, + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }); + const finishWork = Promise.withResolvers(); + const releaseSettlement = Promise.withResolvers(); + const settlementStarted = Promise.withResolvers(); + const workStarted = Promise.withResolvers(); + let callerSettled = false; + let cancellationSettlements = 0; + let failureSettlements = 0; + let replacementStarted = false; + let resultSettlements = 0; + + try { + await runtime.initialize(); + const webAuthn = webAuthnRunner(runtime); + const verification = webAuthn( + () => { + workStarted.resolve(); + return finishWork.promise; + }, + { + onCancellationBeforeRelease: () => { + cancellationSettlements += 1; + }, + onFailureBeforeRelease: async (failure) => { + expect(failure).toBeInstanceOf( + AuthenticationWorkTimeoutError + ); + failureSettlements += 1; + settlementStarted.resolve(); + await releaseSettlement.promise; + }, + onResultBeforeRelease: () => { + resultSettlements += 1; + }, + timeoutMs: 20, + } + ); + void verification.then( + () => { + callerSettled = true; + return true; + }, + () => { + callerSettled = true; + return false; + } + ); + const verificationFailure = captureFailure(() => verification); + await workStarted.promise; + await settlementStarted.promise; + + if (lateOutcome === "result") { + finishWork.resolve("late result"); + } else { + finishWork.reject(new Error("late private verifier failure")); + } + await yieldToWorkService(); + + expect(callerSettled).toBeFalse(); + expect(failureSettlements).toBe(1); + expect(resultSettlements).toBe(0); + expect(cancellationSettlements).toBe(0); + + const replacement = webAuthn( + () => { + replacementStarted = true; + return Promise.resolve("replacement"); + }, + { timeoutMs: 5000 } + ); + await yieldToWorkService(); + expect(replacementStarted).toBeFalse(); + expect( + await captureFailure(() => + webAuthn(() => Promise.resolve("overflow"), { + timeoutMs: 5000, + }) + ) + ).toMatchObject({ + _tag: "AuthenticationWorkCapacityError", + operation: "webauthn", + }); + + releaseSettlement.resolve(); + const failure = await verificationFailure; + expect(failure).toBeInstanceOf(AuthenticationWorkTimeoutError); + expect(failure).toMatchObject({ + operation: "webauthn", + timeoutMs: 20, + }); + expect(await replacement).toBe("replacement"); + expect(replacementStarted).toBeTrue(); + expect(failureSettlements).toBe(1); + expect(resultSettlements).toBe(0); + expect(cancellationSettlements).toBe(0); + } finally { + finishWork.resolve("cleanup"); + releaseSettlement.resolve(); + await runtime.dispose(); + } + } + ); + test("redacts defects and retains active capacity after timeout and abort", async () => { const runtime = createApplicationRuntime({ authenticationWork: { diff --git a/greenfield/src/server/platform/runtime/applicationRuntime.ts b/greenfield/src/server/platform/runtime/applicationRuntime.ts index 851e0cc3b..890e93825 100644 --- a/greenfield/src/server/platform/runtime/applicationRuntime.ts +++ b/greenfield/src/server/platform/runtime/applicationRuntime.ts @@ -105,7 +105,7 @@ export interface DashboardDatabaseRuntimeService extends ImmediateDatabaseWriteA readonly orm: () => Promise; } -/** Application runtime whose database and realtime pump share one process scope. */ +/** Process runtime coordinating ordered application and retained database scopes. */ export interface DashboardApplicationRuntime extends ApplicationRuntime { readonly database: DashboardDatabaseRuntimeService; } From c0ce6a51ca3b26e76f61ca0ab1e97a0aaf5f4a18 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Thu, 6 Aug 2026 22:03:39 +0200 Subject: [PATCH 6/6] fix(rewrite): close root isolation review findings --- .oxfmtrc.json | 5 +- .oxlintrc.json | 3 +- README.md | 16 +++-- bun.lock | 14 ++--- bunfig.toml | 2 +- docs/api/endpoints.md | 59 ++++++++++++------- docs/architecture/gateway-and-chat.md | 7 +++ docs/development/local-dev.md | 17 ++++-- docs/setup/new-vps.md | 5 +- greenfield/.oxlintrc.json | 2 +- .../greenfield-rewrite/implementation-plan.md | 2 +- .../runtime-and-delivery.md | 5 +- package.json | 14 ++--- scripts/buildFrontend.ts | 2 +- scripts/frontendBuild.ts | 6 +- 15 files changed, 100 insertions(+), 59 deletions(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 91e730581..b799431b1 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -5,9 +5,12 @@ "**/coverage/**", "**/data/**", "**/dist/**", + "docs/generated/**", + "migrations/**", "**/node_modules/**", "**/*.min.css", - "**/*.min.js" + "**/*.min.js", + "greenfield/**" ], "printWidth": 90, "semi": true, diff --git a/.oxlintrc.json b/.oxlintrc.json index 19b21568e..7ff230b45 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -31,7 +31,8 @@ "**/*.tsbuildinfo", ".git/**", ".vscode/**", - "build/**" + "build/**", + "greenfield/**" ], "options": { "denyWarnings": true, diff --git a/README.md b/README.md index 89db78729..875195452 100644 --- a/README.md +++ b/README.md @@ -43,16 +43,22 @@ dist/ Bun production frontend build output ## Local development -Install all frontend, backend, and tooling dependencies from the repo root: +The repository selects Bun Canary through `.bun-version`. Install or update that channel before +installing dependencies: + +```bash +bun upgrade --canary +``` + +Then install all frontend, backend, and tooling dependencies from the repo root: ```bash bun install --frozen-lockfile ``` -The repository tracks Bun's moving Canary channel in `.bun-version`. Install or -upgrade that runtime before working in the repository; the committed Bun config -keeps the runtime-only `bun-plugin-tailwind` peer from installing a second, -stale Bun executable into `node_modules`. +The application targets the Bun 1.4 runtime API. Immutable release manifests still record the +exact Bun revision used for each build. The committed Bun config keeps the runtime-only +`bun-plugin-tailwind` peer from installing a second, stale Bun executable into `node_modules`. Run the complete local dev stack: diff --git a/bun.lock b/bun.lock index 693494094..7a86bf2c1 100644 --- a/bun.lock +++ b/bun.lock @@ -10,13 +10,13 @@ "@dnd-kit/react": "^0.5.0", "@dnd-kit/sortable": "^10.0.0", "@headlessui/react": "^2.2.10", - "@microlink/react-json-view": "^1.31.25", + "@microlink/react-json-view": "^1.31.26", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.2", "@tailwindcss/typography": "^0.5.20", "@tanstack/query-core": "5.101.4", - "@tanstack/query-db-collection": "^1.2.1", - "@tanstack/react-db": "^0.1.95", + "@tanstack/query-db-collection": "1.2.1", + "@tanstack/react-db": "0.1.95", "@tanstack/react-form": "^1.33.3", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.18", @@ -60,11 +60,11 @@ "@types/react-syntax-highlighter": "^15.5.13", "babel-plugin-react-compiler": "^1.0.0", "bun-plugin-tailwind": "^0.1.2", - "bun-types": "canary", + "bun-types": "1.4.0-canary.20260519T150915", "happy-dom": "^20.11.1", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", - "oxlint-config-presets": "^0.1.17", + "oxlint-config-presets": "^0.1.18", "oxlint-tailwindcss": "^1.6.0", "oxlint-tsgolint": "^7.0.2001", "tailwindcss": "^4.3.3", @@ -157,7 +157,7 @@ "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], - "@microlink/react-json-view": ["@microlink/react-json-view@1.31.25", "", { "dependencies": { "react-base16-styling": "~0.10.0", "react-lifecycles-compat": "~3.0.4", "react-textarea-autosize": "~8.5.9" }, "peerDependencies": { "react": ">= 15", "react-dom": ">= 15" } }, "sha512-eVRAzd6GpIWhUwZkk1BHVtyS3Sgcw9eVJYvV/RGxahmV10TD3Z50cZNg4EQOzlEWDw+FeitTtr/Aj1yp7fRYXA=="], + "@microlink/react-json-view": ["@microlink/react-json-view@1.31.26", "", { "dependencies": { "react-base16-styling": "~0.10.0", "react-lifecycles-compat": "~3.0.4", "react-textarea-autosize": "~8.5.9" }, "peerDependencies": { "react": ">= 15", "react-dom": ">= 15" } }, "sha512-2sYpys438sineOJDkqk70F0rFRnZ0hpNgv0k8sEpUg3qafP5j7HrZWApj8DGi5rG7dlSRKdzgzRk4LNCsxk2MQ=="], "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], @@ -817,7 +817,7 @@ "oxlint": ["oxlint@1.77.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.77.0", "@oxlint/binding-android-arm64": "1.77.0", "@oxlint/binding-darwin-arm64": "1.77.0", "@oxlint/binding-darwin-x64": "1.77.0", "@oxlint/binding-freebsd-x64": "1.77.0", "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", "@oxlint/binding-linux-arm-musleabihf": "1.77.0", "@oxlint/binding-linux-arm64-gnu": "1.77.0", "@oxlint/binding-linux-arm64-musl": "1.77.0", "@oxlint/binding-linux-ppc64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-musl": "1.77.0", "@oxlint/binding-linux-s390x-gnu": "1.77.0", "@oxlint/binding-linux-x64-gnu": "1.77.0", "@oxlint/binding-linux-x64-musl": "1.77.0", "@oxlint/binding-openharmony-arm64": "1.77.0", "@oxlint/binding-win32-arm64-msvc": "1.77.0", "@oxlint/binding-win32-ia32-msvc": "1.77.0", "@oxlint/binding-win32-x64-msvc": "1.77.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg=="], - "oxlint-config-presets": ["oxlint-config-presets@0.1.17", "", { "peerDependencies": { "oxlint": ">=0.15.0" }, "optionalPeers": ["oxlint"] }, "sha512-LMLU+yweNquiQ74koTOn0rP/9FXjkgK9yp6dkFpwTM9lau6oVks2WupOjjrQUB4PGu92UoHu/R7MH9a0mY0Flw=="], + "oxlint-config-presets": ["oxlint-config-presets@0.1.18", "", { "peerDependencies": { "oxlint": ">=0.15.0" }, "optionalPeers": ["oxlint"] }, "sha512-f0Iie2XIjvAopd5kv5xuVUPspCpWy3pJVLeQ1yWfFpBl1QAkiQEcPCOOqpkUKm+k6zqWJL+PyNCI+TimnBUFvw=="], "oxlint-tailwindcss": ["oxlint-tailwindcss@1.6.0", "", { "dependencies": { "@tailwindcss/node": "^4.3.3", "tailwindcss": "^4.3.3" } }, "sha512-Y1ZWJLI8Szu4Bl2OuZWH1lUv9VfDzD14CqlZBCU3Qxiz5AsthRqXWaYTshUPBFF1WXazsFfRBkWHRO+tIezfQA=="], diff --git a/bunfig.toml b/bunfig.toml index 4de6cdc6e..822bc082e 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -14,5 +14,5 @@ coveragePathIgnorePatterns = [ ] [serve.static] -plugins = ["bun-plugin-tailwind", "./scripts/reactCompilerPlugin.ts"] +plugins = ["./scripts/reactCompilerPlugin.ts", "bun-plugin-tailwind"] environment = "PUBLIC_*" diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 16b3c1b38..dd29cc583 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -1,5 +1,10 @@ # Endpoint Reference +> **Current-production parity input — not the greenfield API contract.** This route inventory +> describes the legacy backend and exists so the rewrite does not lose operator-visible behavior. +> Greenfield contracts are defined by their explicit tRPC/raw-HTTP registries and current generated +> references; legacy route names and payloads are not compatibility requirements. + This page lists the backend route table. It is intentionally concise: use the route files under `backend/src/routes/` for exact validation details. @@ -8,7 +13,9 @@ route files under `backend/src/routes/` for exact validation details. | Method | Path | Purpose | | ------ | ------------------------- | ------------------------------------------------------ | | `GET` | `/api/health/live` | Public web-process liveness. | +| `HEAD` | `/api/health/live` | Bodyless public web-process liveness probe. | | `GET` | `/api/health/ready` | Public activation readiness; `503` when not ready. | +| `HEAD` | `/api/health/ready` | Bodyless readiness probe with the same status as GET. | | `GET` | `/api/health/diagnostics` | Authenticated readiness details and dependency status. | | `GET` | `/api/sessions` | Normalized session snapshot from Gateway. | @@ -195,6 +202,13 @@ already-running execution finishes cooperatively. | `POST` | `/api/backup` | Creates config backup. | | `POST` | `/api/restart` | Queues an OpenClaw Gateway restart and waits for its persisted result. | +## Dashboard Settings + +| Method | Path | Purpose | +| ------ | --------------- | ------------------------------------------------------------- | +| `GET` | `/api/settings` | Reads Dashboard preferences plus current Gateway connection. | +| `PUT` | `/api/settings` | Updates the validated Dashboard preference subset atomically. | + ## Files, Config Files, Logs, Media | Method | Path | Purpose | @@ -205,8 +219,9 @@ already-running execution finishes cooperatively. | `GET` | `/api/config-files` | Lists OpenClaw config files. | | `GET` | `/api/config-files/*` | Reads a config file under OpenClaw root. | | `PUT` | `/api/config-files/*` | Writes a config file under OpenClaw root. | -| `GET` | `/api/logs/info` | Lists log files/metadata. | -| `GET` | `/api/logs/content` | Reads log content. | +| `GET` | `/api/logs/dashboard` | Reads the bounded Dashboard service log tail. | +| `GET` | `/api/logs/openclaw/files` | Lists readable OpenClaw log files and metadata. | +| `GET` | `/api/logs/openclaw/content` | Reads a bounded tail from one allowlisted OpenClaw log file. | | `GET` | `/api/media` | Serves or safely previews media bytes from OpenClaw media roots. | | `GET` | `/api/chat/media/outgoing/*` | Proxies an exact managed Gateway media path with backend-held auth. | @@ -227,31 +242,33 @@ upstream download metadata. ## Docker -| Method | Path | Purpose | -| -------- | ------------------------------------------------ | ----------------------------------------- | -| `GET` | `/api/docker/containers` | Lists containers. | -| `GET` | `/api/docker/containers/:containerId` | Reads container details. | -| `POST` | `/api/docker/containers/:containerId/action` | Queues a container start/stop/restart. | -| `GET` | `/api/docker/containers/:containerId/logs` | Reads container logs. | -| `POST` | `/api/docker/exec/start` | Queues a worker-owned container exec job. | -| `GET` | `/api/docker/exec/:jobId` | Reads persisted exec output/state. | -| `POST` | `/api/docker/exec/:jobId/stop` | Requests exec cancellation. | -| `GET` | `/api/docker/images` | Lists images. | -| `DELETE` | `/api/docker/images/:imageId` | Queues image deletion. | -| `GET` | `/api/docker/volumes` | Lists volumes. | -| `DELETE` | `/api/docker/volumes/:volumeName` | Queues volume deletion. | -| `POST` | `/api/docker/prune` | Queues a Docker prune target. | -| `POST` | `/api/docker/stack/action` | Queues a Compose stack action. | -| `GET` | `/api/docker/updater/services` | Lists managed update services. | -| `GET` | `/api/docker/updater/events` | Lists update events. | -| `POST` | `/api/docker/updater/run` | Queues an updater scan. | -| `POST` | `/api/docker/updater/services/:serviceId/update` | Queues one managed service update. | +| Method | Path | Purpose | +| -------- | ------------------------------------------------ | ------------------------------------------- | +| `GET` | `/api/docker/containers` | Lists containers. | +| `GET` | `/api/docker/containers/stats` | Reads the current container stats snapshot. | +| `GET` | `/api/docker/containers/:containerId` | Reads container details. | +| `POST` | `/api/docker/containers/:containerId/action` | Queues a container start/stop/restart. | +| `GET` | `/api/docker/containers/:containerId/logs` | Reads container logs. | +| `POST` | `/api/docker/exec/start` | Queues a worker-owned container exec job. | +| `GET` | `/api/docker/exec/:jobId` | Reads persisted exec output/state. | +| `POST` | `/api/docker/exec/:jobId/stop` | Requests exec cancellation. | +| `GET` | `/api/docker/images` | Lists images. | +| `DELETE` | `/api/docker/images/:imageId` | Queues image deletion. | +| `GET` | `/api/docker/volumes` | Lists volumes. | +| `DELETE` | `/api/docker/volumes/:volumeName` | Queues volume deletion. | +| `POST` | `/api/docker/prune` | Queues a Docker prune target. | +| `POST` | `/api/docker/stack/action` | Queues a Compose stack action. | +| `GET` | `/api/docker/updater/services` | Lists managed update services. | +| `GET` | `/api/docker/updater/events` | Lists update events. | +| `POST` | `/api/docker/updater/run` | Queues an updater scan. | +| `POST` | `/api/docker/updater/services/:serviceId/update` | Queues one managed service update. | ## Pull Requests And Deployments | Method | Path | Purpose | | ------ | -------------------------------------------- | ---------------------------------------------------------- | | `GET` | `/api/pull-requests` | Lists Dashboard PRs. | +| `POST` | `/api/pull-requests/stacks` | Creates one reviewed native GitHub PR stack. | | `POST` | `/api/pull-requests/:number/approve` | Queues merge, optionally followed by deploy. | | `POST` | `/api/pull-requests/:number/reject` | Queues reject/close. | | `POST` | `/api/pull-requests/:number/review-approval` | Queues review approval. | diff --git a/docs/architecture/gateway-and-chat.md b/docs/architecture/gateway-and-chat.md index 955271496..8aace5225 100644 --- a/docs/architecture/gateway-and-chat.md +++ b/docs/architecture/gateway-and-chat.md @@ -1,5 +1,12 @@ # Gateway And Chat Runtime +> **Current-production parity input — not the greenfield protocol specification.** This document +> describes the legacy Dashboard implementation that must be accounted for during parity work. +> Before any greenfield OpenClaw integration, inspect the source and protocol of the OpenClaw +> version currently installed; that installed behavior is authoritative and this implementation +> may be replaced. The Phase 2 greenfield slice qualifies only a one-shot bootstrap credential +> verifier, not the persistent Gateway/chat runtime described below. + Dashboard has two WebSocket layers: ```text diff --git a/docs/development/local-dev.md b/docs/development/local-dev.md index 1cbb45e1a..498916c70 100644 --- a/docs/development/local-dev.md +++ b/docs/development/local-dev.md @@ -6,17 +6,22 @@ and chat all run through the same application paths. ## Install -From the repository or PR worktree: +`.bun-version` selects Bun Canary. Install or update that channel before dependencies: + +```bash +bun upgrade --canary +``` + +Then install from the repository or PR worktree: ```bash bun install --frozen-lockfile ``` -`.bun-version` selects Bun Canary. The root Bun config deliberately does not -auto-install peer dependencies: required library peers are direct dependencies, -while the `bun-plugin-tailwind` Bun peer is supplied by the selected runtime. -This prevents a stable npm Bun executable from shadowing Canary in repository -scripts. +Dashboard targets Bun 1.4 and accepts newer Canary revisions within that runtime version. The +root Bun config deliberately does not auto-install peer dependencies: required library peers are +direct dependencies, while the `bun-plugin-tailwind` Bun peer is supplied by the selected +runtime. This prevents a stable npm Bun executable from shadowing Canary in repository scripts. When Bun 1.4 is stable, re-test installs with peer auto-installation enabled. Remove `[install] peer = false` once `bun-plugin-tailwind` no longer installs a diff --git a/docs/setup/new-vps.md b/docs/setup/new-vps.md index 938d35d46..6144b72ec 100644 --- a/docs/setup/new-vps.md +++ b/docs/setup/new-vps.md @@ -38,13 +38,16 @@ git clone https://github.com/rajohan/Mira-Dashboard.git \ cd /home/ubuntu/projects/mira-dashboard/production/checkout ``` -Select the repository runtime channel: +Select the repository runtime channel before bootstrapping a release: ```bash bun upgrade --canary bun --revision ``` +The repository targets Bun 1.4 while `.bun-version` remains on Canary. Each immutable Dashboard +release records and reuses the exact Bun revision that built it. + ## Configure Secrets Dashboard reads production secrets through Doppler project/config diff --git a/greenfield/.oxlintrc.json b/greenfield/.oxlintrc.json index abcaf541d..a974a69f0 100644 --- a/greenfield/.oxlintrc.json +++ b/greenfield/.oxlintrc.json @@ -37,7 +37,7 @@ "denyWarnings": true, "reportUnusedDisableDirectives": "error", "typeAware": true, - "typeCheck": false + "typeCheck": true }, "plugins": [ "eslint", diff --git a/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md b/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md index 3adaeb134..71854ea4d 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md +++ b/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md @@ -156,7 +156,7 @@ The rewrite is ready only when all of the following are true: redaction, and audit behavior pass security review; - generated docs are complete, deterministic, CI-checked, and visible at `/docs` without secret disclosure; -- oxfmt, oxlint, typed lint, TypeScript, Bun tests, coverage gates, build, bundle budgets, and +- oxfmt, typed Oxlint, TypeScript, Bun tests, coverage gates, build, bundle budgets, and release preflight pass; - web, worker, child jobs, streams, caches, logs, and test/build commands have observed resource bounds below their cgroup limits; diff --git a/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md index 28360bdc7..22666e4b5 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md +++ b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md @@ -330,7 +330,7 @@ Additional safeguards: - use stream backpressure and abort propagation rather than accumulating chunks; - no unbounded `Promise.all` over files, containers, tests, sessions, or API results; - server-side pagination or cursors for every append-only history; -- separate fast lint from memory-heavier type-aware lint and run them sequentially on the VPS; +- run the Bun and browser type-aware lint partitions sequentially on the VPS; - cap Bun test concurrency and isolate tests that leak global runtime state; and - record cgroup OOM/limit exits as failed jobs with an actionable message. @@ -350,8 +350,7 @@ remain colocated with that script. dev local Bun server + worker + frontend development build deterministic browser and server/worker artifacts typecheck root TypeScript solution (browser + Bun), no emit -lint fast oxlint rules -lint:typed oxlint type-aware rules in a separately budgeted process +lint oxlint type-aware rules and type-check diagnostics, partitioned by runtime format / format:check oxfmt test:unit pure domain and utility tests test:database temporary SQLite repository/migration tests diff --git a/package.json b/package.json index 774c44fc2..77953ccdc 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "start:backend": "NODE_ENV=production doppler run --config prd --project rajohan -- bun --cwd backend dist/serverStart.js", "start:worker": "NODE_ENV=production doppler run --config prd --project rajohan -- bun --cwd backend dist/workerStart.js", "release:manifest": "bun scripts/writeReleaseManifest.ts", - "lint": "oxlint .", - "lint:fix": "oxlint . --fix", + "lint": "oxlint . --disable-nested-config", + "lint:fix": "oxlint . --fix --disable-nested-config", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "test": "bun run test:frontend && bun run test:backend", @@ -41,13 +41,13 @@ "@dnd-kit/react": "^0.5.0", "@dnd-kit/sortable": "^10.0.0", "@headlessui/react": "^2.2.10", - "@microlink/react-json-view": "^1.31.25", + "@microlink/react-json-view": "^1.31.26", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.2", "@tailwindcss/typography": "^0.5.20", "@tanstack/query-core": "5.101.4", - "@tanstack/query-db-collection": "^1.2.1", - "@tanstack/react-db": "^0.1.95", + "@tanstack/query-db-collection": "1.2.1", + "@tanstack/react-db": "0.1.95", "@tanstack/react-form": "^1.33.3", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.18", @@ -91,11 +91,11 @@ "@types/react-syntax-highlighter": "^15.5.13", "babel-plugin-react-compiler": "^1.0.0", "bun-plugin-tailwind": "^0.1.2", - "bun-types": "canary", + "bun-types": "1.4.0-canary.20260519T150915", "happy-dom": "^20.11.1", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", - "oxlint-config-presets": "^0.1.17", + "oxlint-config-presets": "^0.1.18", "oxlint-tailwindcss": "^1.6.0", "oxlint-tsgolint": "^7.0.2001", "tailwindcss": "^4.3.3", diff --git a/scripts/buildFrontend.ts b/scripts/buildFrontend.ts index db6c7cac0..6da4c9998 100644 --- a/scripts/buildFrontend.ts +++ b/scripts/buildFrontend.ts @@ -1,3 +1,3 @@ -import { buildFrontend } from "./frontendBuild"; +import { buildFrontend } from "./frontendBuild.ts"; await buildFrontend({ mode: "production" }); diff --git a/scripts/frontendBuild.ts b/scripts/frontendBuild.ts index 96837f074..fa4736839 100644 --- a/scripts/frontendBuild.ts +++ b/scripts/frontendBuild.ts @@ -13,8 +13,8 @@ import { measureFrontendBundle, writeFrontendHtmlAppEntrypoint, writePrecompressedFrontendAssets, -} from "./frontendBuildArtifacts"; -import reactCompilerPlugin from "./reactCompilerPlugin"; +} from "./frontendBuildArtifacts.ts"; +import reactCompilerPlugin from "./reactCompilerPlugin.ts"; type FrontendBuildMode = "development" | "production"; @@ -70,8 +70,8 @@ export async function buildFrontend({ outdir: resolvedOutdir, plugins: [ ...(isProduction ? [productionDevtoolsPlugin] : []), - tailwindPlugin, reactCompilerPlugin, + tailwindPlugin, ], publicPath: "/", sourcemap: isProduction ? "none" : "linked",