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..b799431b1 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -9,7 +9,8 @@ "migrations/**", "**/node_modules/**", "**/*.min.css", - "**/*.min.js" + "**/*.min.js", + "greenfield/**" ], "printWidth": 90, "semi": true, diff --git a/.oxlintrc.json b/.oxlintrc.json index bc8ebc76d..7ff230b45 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -31,13 +31,14 @@ "**/*.tsbuildinfo", ".git/**", ".vscode/**", - "build/**" + "build/**", + "greenfield/**" ], "options": { "denyWarnings": true, "reportUnusedDisableDirectives": "error", "typeAware": true, - "typeCheck": false + "typeCheck": true }, "plugins": [ "eslint", @@ -111,73 +112,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 +159,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/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..7a86bf2c1 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,6 @@ "@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", @@ -24,13 +23,8 @@ "@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,14 +58,10 @@ "@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", "happy-dom": "^20.11.1", - "jsonc-parser": "3.3.1", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "oxlint-config-presets": "^0.1.18", @@ -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,24 +155,10 @@ "@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=="], - "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], "@otplib/core": ["@otplib/core@13.4.1", "", {}, "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA=="], @@ -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=="], @@ -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/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/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/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..a974a69f0 --- /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": true + }, + "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..c6b8b7ffa --- /dev/null +++ b/greenfield/README.md @@ -0,0 +1,43 @@ +# 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. Persistent state stays in the existing +`/production/state` project tree and is not moved outside the Dashboard project. 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 90% rename from docs/architecture/greenfield-rewrite/application-architecture.md rename to greenfield/docs/architecture/greenfield-rewrite/application-architecture.md index ab1abad32..ea546abc4 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 @@ -386,26 +394,41 @@ 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 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 same `ManagedRuntime` coordinates listener shutdown. An external `stop(true)` request crosses +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 +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 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 both runtime scopes alive for supervisor containment rather than +closing a database beneath potentially active requests. ## Realtime Architecture diff --git a/docs/architecture/greenfield-rewrite/data-and-security.md b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md similarity index 85% rename from docs/architecture/greenfield-rewrite/data-and-security.md rename to greenfield/docs/architecture/greenfield-rewrite/data-and-security.md index 59bcff16a..b7dfa1df3 100644 --- a/docs/architecture/greenfield-rewrite/data-and-security.md +++ b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md @@ -6,12 +6,26 @@ ### 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. 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. 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. @@ -59,9 +73,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 +200,46 @@ 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, resolve only beneath +`/production/state/backups`, and remain restore-tested release artifacts. ## Worker and Privileged Operations @@ -229,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/docs/architecture/greenfield-rewrite/implementation-plan.md b/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md similarity index 94% rename from docs/architecture/greenfield-rewrite/implementation-plan.md rename to greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md index a6039251e..71854ea4d 100644 --- a/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,13 +147,16 @@ 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 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; @@ -191,9 +196,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 91% rename from docs/architecture/greenfield-rewrite/progress.md rename to greenfield/docs/architecture/greenfield-rewrite/progress.md index 41e6fc2a1..123d189e0 100644 --- a/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. | -| 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. | -| 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. | +| 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. | ### 2026-08-03 — Phase 0 started @@ -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 @@ -705,5 +711,41 @@ 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 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, + 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. 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 + 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/docs/architecture/greenfield-rewrite/runtime-and-delivery.md b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md similarity index 75% rename from docs/architecture/greenfield-rewrite/runtime-and-delivery.md rename to greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md index 285f89f16..22666e4b5 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. @@ -259,13 +262,21 @@ 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 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: @@ -319,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. @@ -327,19 +338,19 @@ 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) -lint fast oxlint rules -lint:typed oxlint type-aware rules in a separately budgeted process +typecheck root TypeScript solution (browser + Bun), no emit +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 @@ -348,6 +359,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 +397,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,25 +405,59 @@ 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. + +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, @@ -422,14 +468,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 @@ -448,7 +497,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 +511,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 83% rename from docs/generated/procedures.md rename to greenfield/docs/generated/procedures.md index 0b8fb9d41..f15d215f4 100644 --- a/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/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 93% rename from docs/security/greenfield-phase-two-threat-model.md rename to greenfield/docs/security/greenfield-phase-two-threat-model.md index dbeca5efe..ce06e2d87 100644 --- a/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 @@ -101,11 +101,12 @@ 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` | | 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 @@ -154,9 +155,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 97% rename from migrations/20260804022252_dashboard-foundation/migration.sql rename to greenfield/migrations/20260804022252_dashboard-foundation/migration.sql index b0371419f..3edc47571 100644 --- a/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/migrations/20260804022252_dashboard-foundation/snapshot.json b/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json similarity index 98% rename from migrations/20260804022252_dashboard-foundation/snapshot.json rename to greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json index e7e1964a9..9cc323c76 100644 --- a/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/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/greenfield/src/app/dashboardServer.test.ts b/greenfield/src/app/dashboardServer.test.ts new file mode 100644 index 000000000..bfbd38019 --- /dev/null +++ b/greenfield/src/app/dashboardServer.test.ts @@ -0,0 +1,164 @@ +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"; + +import { listAutomationPrincipalsResultSchema } from "../contracts/automationSecurity.ts"; +import { createWebAuthnRelyingPartyConfiguration } from "../server/domains/security/mfa/webauthn/relyingPartyConfiguration.ts"; +import { + authenticationTestNow, + authenticationTestPrincipalId, + 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 { runTestImmediateDatabaseWrite } from "../server/test/support/databaseWriteAdmission.ts"; +import { migrationsDirectory } from "../server/test/support/freshDatabase.ts"; +import { + createTestApplicationRuntime, + createTestStructuredLogger, +} from "../server/test/support/requestContext.ts"; +import { + createDashboardServer, + validateDashboardWebAuthnBrowserOrigin, +} from "./dashboardServer.ts"; + +describe("Dashboard security composition", () => { + test("requires the HTTP browser origin in the WebAuthn allowlist", () => { + const relyingParty = createWebAuthnRelyingPartyConfiguration({ + allowedOrigins: ["https://dashboard.example"], + rpId: "dashboard.example", + rpName: "Mira Dashboard", + }); + + expect( + validateDashboardWebAuthnBrowserOrigin( + "https://dashboard.example", + relyingParty + ) + ).toBe("https://dashboard.example"); + expect(() => + validateDashboardWebAuthnBrowserOrigin( + "https://admin.dashboard.example", + relyingParty + ) + ).toThrow( + "Dashboard browser origin is absent from the WebAuthn origin allowlist" + ); + }); + + 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-") + ); + 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( + `/trpc/automationSecurity.listPrincipals?input=${input}`, + server.url + ), + { + headers: { + cookie: `${dashboardSessionCookieName}=${fixture.session.token}`, + }, + } + ); + const body = (await response.json()) as { + readonly error?: unknown; + readonly result?: { readonly data?: { readonly json?: unknown } }; + }; + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(body.error).toBeUndefined(); + const result = v.parse( + listAutomationPrincipalsResultSchema, + body.result?.data?.json + ); + expect( + result.principals.find(({ id }) => id === authenticationTestPrincipalId) + ).toMatchObject({ + activeCredentialCount: 1, + capabilities: ["reports:read"], + disabled: false, + id: authenticationTestPrincipalId, + }); + } finally { + 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 new file mode 100644 index 000000000..298849588 --- /dev/null +++ b/greenfield/src/app/dashboardServer.ts @@ -0,0 +1,225 @@ +import { createAuthenticationLifecycleService } from "../server/domains/security/authenticationLifecycle.ts"; +import { createAuthenticationLifecycleRepository } from "../server/domains/security/authenticationLifecycleRepository.ts"; +import { + authenticationWorkBudgetMaximumUnits, + authenticationWorkBudgetWindowMs, + totpWorkBudgetMaximumUnits, + totpWorkBudgetWindowMs, + webAuthnWorkBudgetMaximumUnits, + webAuthnWorkBudgetWindowMs, +} from "../server/domains/security/authenticationRateLimit.ts"; +import { createAuthenticationWorkBudget } from "../server/domains/security/authenticationWorkBudget.ts"; +import { createAutomationSecurityLifecycleService } from "../server/domains/security/automation/lifecycle.ts"; +import { createAutomationLifecycleRepository } from "../server/domains/security/automation/lifecycleRepository.ts"; +import { createMfaAccountLifecycleService } from "../server/domains/security/mfa/accountLifecycle.ts"; +import { createMfaLifecycleRepository } from "../server/domains/security/mfa/lifecycleRepository.ts"; +import { createMfaLoginLifecycleService } from "../server/domains/security/mfa/loginLifecycle.ts"; +import type { TotpSecretCipher } from "../server/domains/security/mfa/totpSecretCipher.ts"; +import { createWebAuthnAdapter } from "../server/domains/security/mfa/webauthn/adapter.ts"; +import type { WebAuthnRelyingPartyConfiguration } from "../server/domains/security/mfa/webauthn/relyingPartyConfiguration.ts"; +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"; + +/** Production composition inputs above the generic Bun/tRPC server primitive. */ +export interface DashboardServerOptions extends Omit< + ServerOptions, + | "authenticateCredential" + | "applicationRuntime" + | "authenticationLifecycle" + | "automationSecurityLifecycle" + | "browserOrigin" + | "hostname" + | "mfaAccountLifecycle" + | "mfaLoginLifecycle" +> { + readonly applicationRuntime: DashboardApplicationRuntime; + readonly authenticationLeaseDurationMs?: number; + /** Canonical public origin used by browser Origin checks behind the proxy. */ + readonly browserOrigin: string; + /** Explicit native WebSocket endpoint used only for one-shot bootstrap verification. */ + readonly gatewayUrl: string; + readonly gatewayVerificationTimeoutMs?: number; + /** Shared composition clock for deterministic lifecycle and request-auth behavior. */ + readonly now?: () => Date; + readonly recentAuthenticationWindowMs?: number; + readonly sessionIdleDurationMs?: number; + readonly totpSecretCipher: TotpSecretCipher; + readonly trustedProxyAddresses?: readonly string[]; + /** Explicit WebAuthn trust configuration; request host headers are never used. */ + readonly webAuthnRelyingParty?: WebAuthnRelyingPartyConfiguration; + readonly webAuthnVerificationTimeoutMs?: number; +} + +/** + * Ensures the HTTP and WebAuthn browser trust boundaries cannot diverge. + * @param browserOrigin Explicit public Dashboard browser origin. + * @param relyingParty Optional validated WebAuthn trust configuration. + * @returns The canonical Dashboard browser origin. + */ +export function validateDashboardWebAuthnBrowserOrigin( + browserOrigin: string, + relyingParty?: WebAuthnRelyingPartyConfiguration +): string { + const canonicalOrigin = parseBrowserOrigin(browserOrigin); + if ( + relyingParty !== undefined && + !relyingParty.allowedOrigins.includes(canonicalOrigin) + ) { + throw new TypeError( + "Dashboard browser origin is absent from the WebAuthn origin allowlist" + ); + } + return canonicalOrigin; +} + +/** + * 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 async function createDashboardServer( + options: DashboardServerOptions +): Promise { + 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 databaseRuntime = options.applicationRuntime.database; + const database = await databaseRuntime.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, databaseRuntime); + 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, + databaseRuntime + ), + sessionIdleDurationMs: options.sessionIdleDurationMs, + verifyGatewayCredential, + }); + const automationSecurityLifecycle = createAutomationSecurityLifecycleService({ + ...(options.now !== undefined && { now: options.now }), + recentAuthenticationWindowMs: options.recentAuthenticationWindowMs, + repository: createAutomationLifecycleRepository(database, databaseRuntime), + sessionIdleDurationMs: options.sessionIdleDurationMs, + }); + const serverOptions: ServerOptions = { + 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, + }; + serverOwnsRuntimeCleanup = true; + return await createServer(serverOptions); + } 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/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 97% rename from src/app/server.ts rename to greenfield/src/app/server.ts index a7f725076..b0f549a25 100644 --- a/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/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 96% rename from src/contracts/accountSecurity.ts rename to greenfield/src/contracts/accountSecurity.ts index 20a7b3cfc..08c330ed1 100644 --- a/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/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 97% rename from src/contracts/auth.ts rename to greenfield/src/contracts/auth.ts index 19a4aefc2..077650b59 100644 --- a/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/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 99% rename from src/contracts/automationSecurity.ts rename to greenfield/src/contracts/automationSecurity.ts index 638cef64a..742d46e85 100644 --- a/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/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/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/src/server/database/migrations/applyVerifiedMigrations.ts b/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts similarity index 57% rename from src/server/database/migrations/applyVerifiedMigrations.ts rename to greenfield/src/server/database/migrations/applyVerifiedMigrations.ts index 1c3cbfd9c..7219a3f4a 100644 --- a/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,8 +28,13 @@ 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 maximumReviewedSchemaObjectCount = 4096; const migrationAppliedAtSchema = timestampMillisecondsSchema( "Migration appliedAt must be valid Date milliseconds" ); @@ -69,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; @@ -96,7 +105,41 @@ function expectedSchemaObjects( for (const migration of migrations) { runMigrationStatements(expectedDatabase, migration.statements); } - return applicationSchemaObjects(expectedDatabase); + return applicationSchemaObjects( + expectedDatabase, + maximumReviewedSchemaObjectCount + ); + } finally { + expectedDatabase.close(true); + } +} + +/** + * 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 }); + 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); } @@ -107,15 +150,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 @@ -125,12 +171,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; @@ -156,6 +203,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 || @@ -198,9 +298,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" ); @@ -208,7 +309,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"); } @@ -216,18 +319,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, @@ -239,7 +342,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"); @@ -252,3 +355,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/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/greenfield/src/server/database/migrations/loadVerifiedMigrations.test.ts b/greenfield/src/server/database/migrations/loadVerifiedMigrations.test.ts new file mode 100644 index 000000000..a8568fff5 --- /dev/null +++ b/greenfield/src/server/database/migrations/loadVerifiedMigrations.test.ts @@ -0,0 +1,589 @@ +import { afterEach, describe, expect, test } from "bun:test"; +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, + migrationArtifactByteLimits, + type MigrationArtifactVerificationTestHooks, + type MigrationArtifactVerificationTestStage, +} from "./loadVerifiedMigrations.ts"; +import { migrationManifest } from "./manifest.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +async function copyMigrationGraph(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), "mira-migrations-")); + temporaryDirectories.push(directory); + await cp(migrationsDirectory, directory, { recursive: true }); + return directory; +} + +function reviewedMigration() { + const migration = migrationManifest[0]; + if (!migration) { + throw new Error("Expected the migration manifest to contain a foundation node"); + } + return migration; +} + +async function expectRejection( + operation: Promise, + expectedMessage: string +): Promise { + let rejection: unknown; + + try { + await operation; + } catch (error) { + rejection = error; + } + + 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", () => { + test("loads the exact reviewed graph in runtime order", async () => { + const migrations = await loadVerifiedMigrations({ + directory: migrationsDirectory, + }); + + expect(migrations.map((migration) => migration.id)).toEqual( + migrationManifest.map((migration) => migration.id) + ); + expect(migrations[0]?.statements.length).toBeGreaterThan(1); + }); + + test("rejects a tampered snapshot", async () => { + const directory = await copyMigrationGraph(); + const migrationId = reviewedMigration().id; + + await writeFile(`${directory}/${migrationId}/snapshot.json`, "{}\n"); + + await expectRejection( + loadVerifiedMigrations({ directory }), + `Migration snapshot checksum mismatch: ${migrationId}` + ); + }); + + test("rejects tampered migration SQL", async () => { + const directory = await copyMigrationGraph(); + const migrationId = reviewedMigration().id; + + await writeFile(`${directory}/${migrationId}/migration.sql`, "SELECT 1;\n"); + + await expectRejection( + loadVerifiedMigrations({ directory }), + `Migration SQL checksum mismatch: ${migrationId}` + ); + }); + + test("rejects duplicate manifest ids", async () => { + const directory = await copyMigrationGraph(); + const migration = reviewedMigration(); + + await expectRejection( + loadVerifiedMigrations({ + directory, + manifest: [migration, { ...migration }], + }), + "Migration manifest contains an invalid or duplicate folder name" + ); + }); + + test("reports duplicate ids before malformed checksums", async () => { + const directory = await copyMigrationGraph(); + const migration = reviewedMigration(); + + await expectRejection( + loadVerifiedMigrations({ + directory, + manifest: [ + { ...migration, migrationSha256: "not-a-checksum" }, + migration, + ], + }), + "Migration manifest contains an invalid or duplicate folder name" + ); + }); + + test("rejects an unknown manifest shape with the folder-name error", async () => { + const directory = await copyMigrationGraph(); + + await expectRejection( + loadVerifiedMigrations({ + directory, + manifest: { entries: [reviewedMigration()] }, + }), + "Migration manifest contains an invalid or duplicate folder name" + ); + }); + + 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(); + + await expectRejection( + loadVerifiedMigrations({ + directory, + manifest: [ + migration, + { + ...migration, + id: "20200101000000_dashboard-followup", + }, + ], + }), + "Migration manifest is not in runtime application order" + ); + }); + + test("reports runtime order before malformed checksums", async () => { + const directory = await copyMigrationGraph(); + const migration = reviewedMigration(); + + await expectRejection( + loadVerifiedMigrations({ + directory, + manifest: [ + { ...migration, migrationSha256: "not-a-checksum" }, + { + ...migration, + id: "20200101000000_dashboard-followup", + }, + ], + }), + "Migration manifest is not in runtime application order" + ); + }); + + test("rejects malformed SQL checksums", async () => { + const directory = await copyMigrationGraph(); + const migration = reviewedMigration(); + + await expectRejection( + loadVerifiedMigrations({ + directory, + manifest: [{ ...migration, migrationSha256: "not-a-checksum" }], + }), + "Migration manifest contains an invalid SHA-256 checksum" + ); + }); + + test("rejects malformed snapshot checksums", async () => { + const directory = await copyMigrationGraph(); + const migration = reviewedMigration(); + + await expectRejection( + loadVerifiedMigrations({ + directory, + manifest: [{ ...migration, snapshotSha256: "not-a-checksum" }], + }), + "Migration manifest contains an invalid SHA-256 checksum" + ); + }); + + test("rejects a non-string checksum from an unknown manifest", async () => { + const directory = await copyMigrationGraph(); + const migration = reviewedMigration(); + + await expectRejection( + loadVerifiedMigrations({ + directory, + manifest: [{ ...migration, migrationSha256: 1 }], + }), + "Migration manifest contains an invalid SHA-256 checksum" + ); + }); + + test("rejects unreviewed migration folders", async () => { + const directory = await copyMigrationGraph(); + await cp( + `${directory}/${reviewedMigration().id}`, + `${directory}/20260803215711_unreviewed`, + { recursive: true } + ); + + await expectRejection( + loadVerifiedMigrations({ directory }), + "Migration directory does not match the reviewed manifest" + ); + }); + + test("rejects malformed migration folder names from the filesystem", async () => { + const directory = await copyMigrationGraph(); + await cp( + `${directory}/${reviewedMigration().id}`, + `${directory}/not-a-migration`, + { recursive: true } + ); + + await expectRejection( + loadVerifiedMigrations({ directory }), + "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/src/server/database/migrations/loadVerifiedMigrations.ts b/greenfield/src/server/database/migrations/loadVerifiedMigrations.ts similarity index 56% rename from src/server/database/migrations/loadVerifiedMigrations.ts rename to greenfield/src/server/database/migrations/loadVerifiedMigrations.ts index 74de2fcc6..f06293272 100644 --- a/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/src/server/database/migrations/manifest.ts b/greenfield/src/server/database/migrations/manifest.ts similarity index 77% rename from src/server/database/migrations/manifest.ts rename to greenfield/src/server/database/migrations/manifest.ts index 5447b5709..4b3291926 100644 --- a/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 }); + + try { + database.run("PRAGMA foreign_keys = ON"); + expect(() => + applyVerifiedMigrations(database, migrations, { + appliedAt: toDate(Number.NaN), + releaseId: "1".repeat(40), + }) + ).toThrow("Migration appliedAt must be valid Date milliseconds"); + expect( + database + .query<{ name: string }, []>(` + SELECT name + FROM sqlite_schema + WHERE name NOT GLOB 'sqlite_*' + `) + .all() + ).toEqual([]); + } finally { + 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/src/server/database/migrations/migrationGraph.test.ts b/greenfield/src/server/database/migrations/migrationGraph.test.ts similarity index 92% rename from src/server/database/migrations/migrationGraph.test.ts rename to greenfield/src/server/database/migrations/migrationGraph.test.ts index 6b2c7a716..3b014184c 100644 --- a/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, @@ -74,6 +103,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 +189,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 +262,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 new file mode 100644 index 000000000..10548f6a4 --- /dev/null +++ b/greenfield/src/server/database/migrations/migrationLedgerValidation.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from "bun:test"; + +import { + migrationsDirectory, + openFreshMigratedDatabase, +} from "../../test/support/freshDatabase.ts"; +import { applyVerifiedMigrations } from "./applyVerifiedMigrations.ts"; +import { loadVerifiedMigrations } from "./loadVerifiedMigrations.ts"; + +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) { + throw new Error("Expected the migration graph to contain a foundation node"); + } + const corruptions = [ + "UPDATE schema_migrations SET applied_at = -1 WHERE id = ?", + "UPDATE schema_migrations SET id = 'invalid' WHERE id = ?", + `UPDATE schema_migrations SET release_id = '${"A".repeat(40)}' WHERE id = ?`, + ] as const; + + 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), + }) + ).toThrow("Database migration history does not match the reviewed manifest"); + } finally { + database.sqlite.close(true); + } + } +}); 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 75% rename from src/server/database/migrations/validation.ts rename to greenfield/src/server/database/migrations/validation.ts index eb1cc144b..e74dbfed6 100644 --- a/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/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/greenfield/src/server/database/runtime/databaseErrors.ts b/greenfield/src/server/database/runtime/databaseErrors.ts new file mode 100644 index 000000000..27882fbfa --- /dev/null +++ b/greenfield/src/server/database/runtime/databaseErrors.ts @@ -0,0 +1,132 @@ +import { Schema } from "effect"; + +const TaggedErrorClass = Schema.TaggedError; + +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 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 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 TaggedErrorClass( + "mira-dashboard/server/database/runtime/DatabaseRuntimeLockTimeoutError" +)("DatabaseRuntimeLockTimeoutError", { + message: Schema.String, + timeoutMs: Schema.Number, +}) {} + +/** Expected write failure after bounded asynchronous SQLite admission retry. */ +export class DatabaseRuntimeWriteAdmissionTimeoutError extends 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 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 TaggedErrorClass( + "mira-dashboard/server/database/runtime/DatabaseRuntimeSnapshotRequiredError" +)("DatabaseRuntimeSnapshotRequiredError", { + message: Schema.String, +}) {} + +/** Sanitized failure from a process-owned passive WAL checkpoint. */ +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 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, + DatabaseRuntimeWriteAdmissionTimeoutError, + DatabaseRuntimeWriteContentionError, +]); + +/** Runtime guard for failures crossing the database layer boundary. */ +export const isDatabaseRuntimeError = Schema.is(databaseRuntimeErrorSchema); + +export type DatabaseRuntimeError = + | DatabaseRuntimeAcquisitionError + | DatabaseRuntimeCheckpointError + | 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 new file mode 100644 index 000000000..64b309e1d --- /dev/null +++ b/greenfield/src/server/database/runtime/databasePath.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmod, + link, + mkdir, + mkdtemp, + rename, + rm, + stat, + 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("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, 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 () => { + 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" }); + }); + + 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"); + 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 new file mode 100644 index 000000000..98509fef2 --- /dev/null +++ b/greenfield/src/server/database/runtime/databasePath.ts @@ -0,0 +1,289 @@ +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 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 }); +} + +function sameIdentity( + actual: DatabasePathIdentity, + expected: DatabasePathIdentity +): boolean { + 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; + 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(); + } + await assertProtectedStateDirectoryAncestors(canonicalDirectory, stat, userId); + 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..d33b67771 --- /dev/null +++ b/greenfield/src/server/database/runtime/databasePolicy.ts @@ -0,0 +1,368 @@ +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, + DatabaseRuntimeWriteAdmissionTimeoutError, + DatabaseRuntimeWriteContentionError, +} from "./databaseErrors.ts"; + +export const databaseRuntimePolicy = Object.freeze({ + busyTimeoutMs: 0, + migrationLockRetryBaseDelayMs: 10, + migrationLockRetryMaximumDelayMs: 250, + migrationLockTimeoutMs: 5000, + synchronousLevel: 2, + walAutoCheckpointPages: 1000, + writeAdmissionRetryBaseDelayMs: 10, + writeAdmissionRetryMaximumDelayMs: 250, + writeAdmissionTimeoutMs: 5000, +}); + +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( + 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), maximumDelayMs); + 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( + databaseRuntimePolicy.migrationLockRetryBaseDelayMs, + databaseRuntimePolicy.migrationLockRetryMaximumDelayMs + ), + }), + 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, + }) + ) + ) + ); +} + +/** + * 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. + * @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..276ac6117 --- /dev/null +++ b/greenfield/src/server/database/runtime/databaseService.test.ts @@ -0,0 +1,555 @@ +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 { 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, + 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); +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 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" +): 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("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([ + 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 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 }); + 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"); + const validator = new Database(databasePath, { strict: true }); + const initialSchemaRead = Promise.withResolvers(); + let initialSchemaReadRecorded = false; + + try { + 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); + }, + }); + const normalizedOptions = normalizeDatabaseRuntimeOptions( + options(stateDirectory, "validate-only") + ); + 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; + + expect(diagnostics.appliedMigrations).toBe(0); + expect(diagnostics.startupMode).toBe("validate-only"); + } finally { + if (initializer.inTransaction) initializer.run("ROLLBACK"); + validator.close(true); + initializer.close(true); + } + }); + + 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("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)); + 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); + }); +}); + +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 new file mode 100644 index 000000000..5ae53a41f --- /dev/null +++ b/greenfield/src/server/database/runtime/databaseService.ts @@ -0,0 +1,231 @@ +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, + retryDatabaseWriteOperation, +} 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; + readonly runImmediateWrite: ( + operation: (markTransactionStarted: () => void) => A + ) => Effect.Effect; +} + +/** 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, + runImmediateWrite: retryDatabaseWriteOperation, + }); + 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..f344b9a32 --- /dev/null +++ b/greenfield/src/server/database/runtime/databaseStartup.ts @@ -0,0 +1,280 @@ +import type { Database } from "bun:sqlite"; + +import { Effect } from "effect"; +import * as v from "valibot"; + +import { + fullCommitShaSchema, + lowercaseSha256Schema, +} from "../../../shared/validation.ts"; +import { + applyVerifiedMigrations, + validateVerifiedMigrations, +} 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 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; + +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 hasApplicationSchemaObjects(database: Database): boolean { + const row: unknown = database + .query(` + SELECT 1 AS present + FROM sqlite_schema + WHERE name NOT GLOB 'sqlite_*' + LIMIT 1 + `) + .get(); + const validation = v.safeParse(schemaObjectPresenceRowSchema, row, { + abortEarly: true, + }); + if (!validation.success) throw invalidHistory(); + 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, + maximumRows: number +): readonly MigrationHistoryRow[] { + const rows: unknown = database + .query(` + SELECT checksum, id + FROM schema_migrations + ORDER BY id + LIMIT ? + `) + .all(maximumRows + 1); + const validation = v.safeParse(migrationHistoryRowsSchema, rows, { + abortEarly: true, + }); + if (!validation.success || validation.output.length > maximumRows) { + 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" { + if (!hasApplicationSchemaObjects(database)) return "empty"; + if (!hasMigrationHistoryTable(database)) throw invalidHistory(); + + const history = readMigrationHistory(database, migrations.length); + 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") { + // 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) + ); + state = recheck.immediate(); + if (state === "empty") throw emptyDatabase(); + } + if (state === "pending") { + assertDatabaseIntegrity(database); + throw new DatabaseRuntimeSnapshotRequiredError({ + message: "Database migration requires a verified release snapshot", + }); + } + + 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({ + 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/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/greenfield/src/server/database/schema/schemaMigrations.ts b/greenfield/src/server/database/schema/schemaMigrations.ts new file mode 100644 index 000000000..e98bef99e --- /dev/null +++ b/greenfield/src/server/database/schema/schemaMigrations.ts @@ -0,0 +1,33 @@ +import { sql } from "drizzle-orm"; +import { check, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +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/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 97% rename from src/server/database/validation/rowSchemas.test.ts rename to greenfield/src/server/database/validation/rowSchemas.test.ts index ed9edb3ec..1f87ba8d9 100644 --- a/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/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 82% rename from src/server/database/validation/schemaMigrations.ts rename to greenfield/src/server/database/validation/schemaMigrations.ts index a437f2451..18ddf0c55 100644 --- a/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/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 92% rename from src/server/domains/monitoring/repository.ts rename to greenfield/src/server/domains/monitoring/repository.ts index 7340db373..e633b0a2f 100644 --- a/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/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 55% rename from src/server/domains/monitoring/service.ts rename to greenfield/src/server/domains/monitoring/service.ts index 844193e3e..a21d96e01 100644 --- a/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, @@ -30,6 +34,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( @@ -65,6 +70,7 @@ export interface MonitoringServiceDependencies { } export type MonitoringSubmissionError = + | DatabaseRuntimeWriteUnavailableError | MonitoringRunConflictError | MonitoringSnapshotValidationError; @@ -80,13 +86,27 @@ 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, runId: Schema.String, }) {} +class MonitoringUnexpectedSubmissionError extends Data.TaggedError( + "MonitoringUnexpectedSubmissionError" +)<{ + readonly cause: unknown; +}> {} + +function isMonitoringSubmissionError(error: unknown): error is MonitoringSubmissionError { + return ( + error instanceof MonitoringSnapshotValidationError || + error instanceof MonitoringRunConflictError || + isDatabaseRuntimeWriteUnavailableError(error) + ); +} + function emptyCounts(): MutableSubmissionCounts { return { createdIncidents: 0, @@ -124,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 ( @@ -139,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; }; @@ -239,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(() => { @@ -270,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/src/server/domains/monitoring/serviceBoundary.test.ts b/greenfield/src/server/domains/monitoring/serviceBoundary.test.ts similarity index 85% rename from src/server/domains/monitoring/serviceBoundary.test.ts rename to greenfield/src/server/domains/monitoring/serviceBoundary.test.ts index b072a309c..75ee15e93 100644 --- a/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/src/server/domains/monitoring/serviceLifecycle.test.ts b/greenfield/src/server/domains/monitoring/serviceLifecycle.test.ts similarity index 96% rename from src/server/domains/monitoring/serviceLifecycle.test.ts rename to greenfield/src/server/domains/monitoring/serviceLifecycle.test.ts index d0090f883..445917be1 100644 --- a/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/src/server/domains/monitoring/serviceOrdering.test.ts b/greenfield/src/server/domains/monitoring/serviceOrdering.test.ts similarity index 89% rename from src/server/domains/monitoring/serviceOrdering.test.ts rename to greenfield/src/server/domains/monitoring/serviceOrdering.test.ts index 62c2861ce..20a6fd4f4 100644 --- a/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/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 89% rename from src/server/domains/monitoring/testSupport/monitoringService.ts rename to greenfield/src/server/domains/monitoring/testSupport/monitoringService.ts index f40bdd262..9b32492c7 100644 --- a/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/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 97% rename from src/server/domains/security/authPublicRoutes.ts rename to greenfield/src/server/domains/security/authPublicRoutes.ts index 2d8c70c09..b99f72479 100644 --- a/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/src/server/domains/security/authSessionRoutes.ts b/greenfield/src/server/domains/security/authSessionRoutes.ts similarity index 94% rename from src/server/domains/security/authSessionRoutes.ts rename to greenfield/src/server/domains/security/authSessionRoutes.ts index 8abdffd05..60f8857e3 100644 --- a/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/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 94% rename from src/server/domains/security/authenticationLifecycle.sessions.test.ts rename to greenfield/src/server/domains/security/authenticationLifecycle.sessions.test.ts index 80d87d2da..194790484 100644 --- a/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/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 86% rename from src/server/domains/security/authenticationLifecycleBootstrap.ts rename to greenfield/src/server/domains/security/authenticationLifecycleBootstrap.ts index 33b809ca2..46f2ad0dc 100644 --- a/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/src/server/domains/security/authenticationLifecycleContext.ts b/greenfield/src/server/domains/security/authenticationLifecycleContext.ts similarity index 98% rename from src/server/domains/security/authenticationLifecycleContext.ts rename to greenfield/src/server/domains/security/authenticationLifecycleContext.ts index 8725ea89b..417902e09 100644 --- a/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/src/server/domains/security/authenticationLifecycleLogin.ts b/greenfield/src/server/domains/security/authenticationLifecycleLogin.ts similarity index 96% rename from src/server/domains/security/authenticationLifecycleLogin.ts rename to greenfield/src/server/domains/security/authenticationLifecycleLogin.ts index 6c0c5bfa7..1fa48e918 100644 --- a/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/src/server/domains/security/authenticationLifecyclePassword.ts b/greenfield/src/server/domains/security/authenticationLifecyclePassword.ts similarity index 98% rename from src/server/domains/security/authenticationLifecyclePassword.ts rename to greenfield/src/server/domains/security/authenticationLifecyclePassword.ts index 96406b3e0..5008fe190 100644 --- a/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/src/server/domains/security/authenticationLifecycleRepository.test.ts b/greenfield/src/server/domains/security/authenticationLifecycleRepository.test.ts similarity index 90% rename from src/server/domains/security/authenticationLifecycleRepository.test.ts rename to greenfield/src/server/domains/security/authenticationLifecycleRepository.test.ts index d52502efa..95546e6db 100644 --- a/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/src/server/domains/security/authenticationLifecycleRepository.ts b/greenfield/src/server/domains/security/authenticationLifecycleRepository.ts similarity index 89% rename from src/server/domains/security/authenticationLifecycleRepository.ts rename to greenfield/src/server/domains/security/authenticationLifecycleRepository.ts index c1640ae38..b0237229e 100644 --- a/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/src/server/domains/security/authenticationLifecycleSessions.ts b/greenfield/src/server/domains/security/authenticationLifecycleSessions.ts similarity index 95% rename from src/server/domains/security/authenticationLifecycleSessions.ts rename to greenfield/src/server/domains/security/authenticationLifecycleSessions.ts index 2d1cf392c..04e5136d1 100644 --- a/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/src/server/domains/security/authenticationLifecycleTypes.ts b/greenfield/src/server/domains/security/authenticationLifecycleTypes.ts similarity index 95% rename from src/server/domains/security/authenticationLifecycleTypes.ts rename to greenfield/src/server/domains/security/authenticationLifecycleTypes.ts index 39e371544..c78d0e3f5 100644 --- a/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/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 99% rename from src/server/domains/security/authenticationWorkGate.test.ts rename to greenfield/src/server/domains/security/authenticationWorkGate.test.ts index ed5873273..c9ef4ce26 100644 --- a/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/src/server/domains/security/authenticationWorkGate.ts b/greenfield/src/server/domains/security/authenticationWorkGate.ts similarity index 70% rename from src/server/domains/security/authenticationWorkGate.ts rename to greenfield/src/server/domains/security/authenticationWorkGate.ts index 7e01bbb70..89ae9dbbe 100644 --- a/src/server/domains/security/authenticationWorkGate.ts +++ b/greenfield/src/server/domains/security/authenticationWorkGate.ts @@ -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,53 +307,91 @@ function trackedWork( const controller = new AbortController(); const workerState = { callerInterrupted: false, started: false }; - const failureState = { notified: false }; - const cancellationState = { notified: false }; + // This milestone ends the verifier deadline before durable settlement begins. + const verificationOutcome = 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.uninterruptible, 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 notifyResultBeforeRelease = (value: T): Effect.Effect => - Effect.sync(() => { - if ( - controller.signal.aborted || - workerState.callerInterrupted || - failureState.notified || - onResultBeforeRelease === undefined - ) { - return; + verificationOutcome.resolve(); + const { claim } = beginSettlement("failure", () => + onFailureBeforeRelease?.(failure) + ); + return awaitSettlement(claim); + }).pipe(Effect.uninterruptible); + const notifyResultBeforeRelease = ( + value: T + ): Effect.Effect => + Effect.suspend(() => { + if (workerState.callerInterrupted) { + return Effect.void; } - onResultBeforeRelease(value); - }); + verificationOutcome.resolve(); + const { claim } = beginSettlement("result", () => + onResultBeforeRelease?.(value) + ); + 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) ); 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.uninterruptible, Effect.ignore); + }); + const releaseWorkerPermits = awaitClaimBeforeRelease.pipe( + Effect.andThen(releaseAdmission) + ); const runtimeStopping = abortControllerEffect( controller, "Authentication runtime is stopping", @@ -350,8 +400,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 +418,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 +443,41 @@ 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)) + ) + : awaitSettlement(claim) + ) + ); + const continueUnlessTimeoutOwnsSettlement = Effect.suspend(() => + settlementState.claim?.source === "timeout" + ? Effect.never + : Effect.void ); - joined = joined.pipe( + const awaitUpstream = Effect.raceFirst( + Effect.promise(() => verificationOutcome.promise).pipe( + Effect.andThen(continueUnlessTimeoutOwnsSettlement) + ), + Fiber.await(fiber).pipe( + Effect.andThen(continueUnlessTimeoutOwnsSettlement) + ) + ).pipe( + Effect.asVoid, Effect.timeoutOrElse({ duration: timeoutMs, orElse: () => timeoutFallback, }) ); + callerWait = awaitUpstream.pipe(Effect.andThen(joined)); } const requestAbortReason = new DOMException( "Authentication request aborted", @@ -410,7 +487,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 +504,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 +557,7 @@ function verificationWork( onFailureBeforeRelease, (result) => { if (result.kind === "verified") { - onResultBeforeRelease?.(result.value); + return onResultBeforeRelease?.(result.value); } } ).pipe(Effect.map(({ value }) => value)); @@ -498,10 +577,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 new file mode 100644 index 000000000..ead872ca9 --- /dev/null +++ b/greenfield/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts @@ -0,0 +1,883 @@ +import { describe, expect, test } from "bun:test"; + +import { Effect, Layer, Stream } from "effect"; + +import { RealtimeEventPumpService } from "../../platform/realtime/eventPumpService.ts"; +import { createApplicationRuntime } from "../../platform/runtime/applicationRuntime.ts"; +import { captureFailure } from "../../test/support/promise.ts"; +import { createTestStructuredLogger } from "../../test/support/requestContext.ts"; +import { + type AuthenticationVerificationWorkOptions, + AuthenticationUpstreamUnavailableError, + AuthenticationWorkSettlementError, + AuthenticationWorkTimeoutError, +} from "./authenticationWorkGate.ts"; + +const inertRealtimeLayer = Layer.succeed( + RealtimeEventPumpService, + RealtimeEventPumpService.of({ + metricsSnapshot: Effect.die("WebAuthn work tests do not use metrics"), + stream: () => Stream.empty, + wake: Effect.void, + }) +); + +const testStructuredLogger = createTestStructuredLogger(); + +async function yieldToWorkService(): Promise { + await Bun.sleep(0); +} + +function webAuthnRunner(runtime: ReturnType) { + return ( + work: (signal: AbortSignal) => Promise, + options: AuthenticationVerificationWorkOptions + ): Promise => + runtime.services.authentication.runWebAuthnVerification(work, options); +} + +describe("process WebAuthn verification work service", () => { + test("uses an independent default two-active/four-queued gate", async () => { + const runtime = createApplicationRuntime({ + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }); + const releaseActive = Promise.withResolvers(); + const activeStarted = Promise.withResolvers(); + let starts = 0; + + try { + await runtime.initialize(); + const authentication = runtime.services.authentication; + const runWebAuthn = (value: number): Promise => + authentication.runWebAuthnVerification( + async (signal) => { + expect(signal.aborted).toBeFalse(); + starts += 1; + if (starts === 2) activeStarted.resolve(); + if (starts <= 2) await releaseActive.promise; + return value; + }, + { timeoutMs: 5000 } + ); + const active = [runWebAuthn(1), runWebAuthn(2)]; + await activeStarted.promise; + const queued = [ + runWebAuthn(3), + runWebAuthn(4), + runWebAuthn(5), + runWebAuthn(6), + ]; + await yieldToWorkService(); + + expect(starts).toBe(2); + expect( + await authentication.runGatewayVerification( + () => Promise.resolve("gateway"), + { timeoutMs: 500 } + ) + ).toBe("gateway"); + expect(await captureFailure(() => runWebAuthn(7))).toMatchObject({ + _tag: "AuthenticationWorkCapacityError", + operation: "webauthn", + }); + + releaseActive.resolve(); + expect(await Promise.all([...active, ...queued])).toEqual([1, 2, 3, 4, 5, 6]); + expect(starts).toBe(6); + } finally { + releaseActive.resolve(); + await runtime.dispose(); + } + }); + + test("releases queued admission when the WebAuthn caller aborts", async () => { + const runtime = createApplicationRuntime({ + authenticationWork: { + webAuthnMaximumConcurrent: 1, + webAuthnMaximumQueued: 1, + }, + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }); + const releaseFirst = Promise.withResolvers(); + const firstStarted = Promise.withResolvers(); + let queuedCancellationSettlements = 0; + + try { + await runtime.initialize(); + const webAuthn = webAuthnRunner(runtime); + const first = webAuthn( + async () => { + firstStarted.resolve(); + await releaseFirst.promise; + return "first"; + }, + { timeoutMs: 5000 } + ); + await firstStarted.promise; + const controller = new AbortController(); + const queued = webAuthn(() => Promise.resolve("cancelled"), { + onCancellationBeforeRelease: () => { + queuedCancellationSettlements += 1; + }, + signal: controller.signal, + timeoutMs: 5000, + }); + await yieldToWorkService(); + + const cancellation = new Error("request cancelled"); + controller.abort(cancellation); + expect(await captureFailure(() => queued)).toBe(cancellation); + expect(queuedCancellationSettlements).toBe(0); + + const replacement = webAuthn(() => Promise.resolve("replacement"), { + timeoutMs: 5000, + }); + await yieldToWorkService(); + expect( + await captureFailure(() => + webAuthn(() => Promise.resolve("overflow"), { + timeoutMs: 5000, + }) + ) + ).toMatchObject({ + _tag: "AuthenticationWorkCapacityError", + operation: "webauthn", + }); + releaseFirst.resolve(); + expect(await first).toBe("first"); + expect(await replacement).toBe("replacement"); + } finally { + releaseFirst.resolve(); + await runtime.dispose(); + } + }); + + 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.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: { + webAuthnMaximumConcurrent: 1, + webAuthnMaximumQueued: 0, + }, + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }); + const timedWork = Promise.withResolvers(); + const abortedWork = Promise.withResolvers(); + const abortedWorkStarted = Promise.withResolvers(); + let timedSignal: AbortSignal | undefined; + let abortedSignal: AbortSignal | undefined; + let cancellationSettlements = 0; + let timeoutSettled = false; + + try { + await runtime.initialize(); + const webAuthn = webAuthnRunner(runtime); + const unavailable = await captureFailure(() => + webAuthn(() => Promise.reject(new Error("sensitive verifier detail")), { + timeoutMs: 500, + }) + ); + expect(unavailable).toBeInstanceOf(AuthenticationUpstreamUnavailableError); + expect(unavailable).toMatchObject({ operation: "webauthn" }); + expect(String(unavailable)).not.toContain("sensitive verifier detail"); + + const timedOut = await captureFailure(() => + webAuthn( + (signal) => { + timedSignal = signal; + return timedWork.promise; + }, + { + onFailureBeforeRelease: (failure) => { + expect(failure).toBeInstanceOf( + AuthenticationWorkTimeoutError + ); + timeoutSettled = true; + }, + timeoutMs: 50, + } + ) + ); + expect(timedOut).toBeInstanceOf(AuthenticationWorkTimeoutError); + expect(timedOut).toMatchObject({ operation: "webauthn", timeoutMs: 50 }); + expect(timeoutSettled).toBeTrue(); + expect(timedSignal?.aborted).toBeTrue(); + expect( + await captureFailure(() => + webAuthn(() => Promise.resolve(true), { timeoutMs: 500 }) + ) + ).toMatchObject({ operation: "webauthn" }); + + timedWork.resolve(false); + await yieldToWorkService(); + const controller = new AbortController(); + const aborted = webAuthn( + (signal) => { + abortedSignal = signal; + abortedWorkStarted.resolve(); + return abortedWork.promise; + }, + { + onCancellationBeforeRelease: () => { + cancellationSettlements += 1; + }, + signal: controller.signal, + timeoutMs: 5000, + } + ); + await abortedWorkStarted.promise; + const cancellation = new Error("request cancelled"); + controller.abort(cancellation); + expect(await captureFailure(() => aborted)).toBe(cancellation); + expect(abortedSignal?.aborted).toBeTrue(); + expect(cancellationSettlements).toBe(0); + expect( + await captureFailure(() => + webAuthn(() => Promise.resolve(true), { timeoutMs: 500 }) + ) + ).toMatchObject({ operation: "webauthn" }); + + abortedWork.resolve(false); + await yieldToWorkService(); + expect(cancellationSettlements).toBe(1); + expect( + await webAuthn(() => Promise.resolve(true), { timeoutMs: 500 }) + ).toBeTrue(); + } finally { + timedWork.resolve(false); + abortedWork.resolve(false); + await runtime.dispose(); + } + }); + + test("runs in-gate rechecks and settlements before releasing capacity", async () => { + const runtime = createApplicationRuntime({ + authenticationWork: { + webAuthnMaximumConcurrent: 1, + webAuthnMaximumQueued: 1, + }, + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }); + 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; + + try { + await runtime.initialize(); + const webAuthn = webAuthnRunner(runtime); + const result = webAuthn( + async () => { + order.push("result-work"); + resultStarted.resolve(); + await releaseResult.promise; + return "verified"; + }, + { + onResultBeforeRelease: async () => { + order.push("result-settlement-started"); + resultSettlementStarted.resolve(); + await releaseResultSettlement.promise; + order.push("result-settled"); + }, + timeoutMs: 5000, + } + ); + await resultStarted.promise; + const skipped = webAuthn( + () => { + skippedWorkCalls += 1; + return Promise.resolve("unexpected"); + }, + { + onBeforeStart: () => { + order.push("queued-recheck"); + return { proceed: false, value: "stale" }; + }, + onResultBeforeRelease: () => { + order.push("skipped-settled"); + }, + timeoutMs: 5000, + } + ); + await yieldToWorkService(); + 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-settlement-started", + "result-settled", + "queued-recheck", + ]); + + const failed = webAuthn( + async () => { + order.push("failure-work"); + failureStarted.resolve(); + await releaseFailure.promise; + throw new Error("verifier detail"); + }, + { + onFailureBeforeRelease: async () => { + order.push("failure-settlement-started"); + failureSettlementStarted.resolve(); + await releaseFailureSettlement.promise; + order.push("failure-settled"); + }, + timeoutMs: 5000, + } + ); + await failureStarted.promise; + const afterFailure = webAuthn( + () => { + order.push("after-failure-start"); + return Promise.resolve("after"); + }, + { timeoutMs: 5000 } + ); + releaseFailure.resolve(); + await failureSettlementStarted.promise; + await yieldToWorkService(); + expect(order).not.toContain("after-failure-start"); + releaseFailureSettlement.resolve(); + expect(await captureFailure(() => failed)).toBeInstanceOf( + AuthenticationUpstreamUnavailableError + ); + expect(await afterFailure).toBe("after"); + expect(order.indexOf("failure-settled")).toBeLessThan( + order.indexOf("after-failure-start") + ); + } 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(); + } + }); + + test("rejects invalid WebAuthn process work limits", () => { + expect(() => + createApplicationRuntime({ + authenticationWork: { webAuthnMaximumConcurrent: 0 }, + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }) + ).toThrow("WebAuthn verification concurrency limit is invalid"); + expect(() => + createApplicationRuntime({ + authenticationWork: { webAuthnMaximumQueued: -1 }, + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }) + ).toThrow("WebAuthn verification queue limit is invalid"); + expect(() => + createApplicationRuntime({ + authenticationWork: { webAuthnMaximumQueued: 1.5 }, + logger: testStructuredLogger, + realtimeEventPumpLayer: inertRealtimeLayer, + }) + ).toThrow(RangeError); + }); +}); diff --git a/src/server/domains/security/automation/credentialOperations.ts b/greenfield/src/server/domains/security/automation/credentialOperations.ts similarity index 97% rename from src/server/domains/security/automation/credentialOperations.ts rename to greenfield/src/server/domains/security/automation/credentialOperations.ts index 90e0f8a37..bd887cd3f 100644 --- a/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/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 88% rename from src/server/domains/security/automation/lifecycleCredential.test.ts rename to greenfield/src/server/domains/security/automation/lifecycleCredential.test.ts index b572e46c3..1ce858324 100644 --- a/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/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 91% rename from src/server/domains/security/automation/lifecyclePrincipal.test.ts rename to greenfield/src/server/domains/security/automation/lifecyclePrincipal.test.ts index 1d17b0a45..793f3bd35 100644 --- a/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/src/server/domains/security/automation/lifecycleRepository.test.ts b/greenfield/src/server/domains/security/automation/lifecycleRepository.test.ts similarity index 95% rename from src/server/domains/security/automation/lifecycleRepository.test.ts rename to greenfield/src/server/domains/security/automation/lifecycleRepository.test.ts index 1d12efb77..261775282 100644 --- a/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/src/server/domains/security/automation/lifecycleRepository.ts b/greenfield/src/server/domains/security/automation/lifecycleRepository.ts similarity index 70% rename from src/server/domains/security/automation/lifecycleRepository.ts rename to greenfield/src/server/domains/security/automation/lifecycleRepository.ts index ef8079fc4..72e8cb268 100644 --- a/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/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..1ad56104a 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"; @@ -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/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 91% rename from src/server/domains/security/automation/lifecycleTypes.ts rename to greenfield/src/server/domains/security/automation/lifecycleTypes.ts index 6fc002f31..343c4450b 100644 --- a/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/src/server/domains/security/automation/principalOperations.ts b/greenfield/src/server/domains/security/automation/principalOperations.ts similarity index 97% rename from src/server/domains/security/automation/principalOperations.ts rename to greenfield/src/server/domains/security/automation/principalOperations.ts index c4aeac8a8..faa592d72 100644 --- a/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/src/server/domains/security/automation/procedures.test.ts b/greenfield/src/server/domains/security/automation/procedures.test.ts similarity index 89% rename from src/server/domains/security/automation/procedures.test.ts rename to greenfield/src/server/domains/security/automation/procedures.test.ts index b881dc6f8..9e0123aa8 100644 --- a/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/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 91% rename from src/server/domains/security/automation/routes.ts rename to greenfield/src/server/domains/security/automation/routes.ts index 63c0f55ba..61870ced2 100644 --- a/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/src/server/domains/security/automation/testSupport/lifecycle.ts b/greenfield/src/server/domains/security/automation/testSupport/lifecycle.ts similarity index 97% rename from src/server/domains/security/automation/testSupport/lifecycle.ts rename to greenfield/src/server/domains/security/automation/testSupport/lifecycle.ts index dea49e1a3..229f47603 100644 --- a/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/src/server/domains/security/mfa/accountLifecycle.factors.test.ts b/greenfield/src/server/domains/security/mfa/accountLifecycle.factors.test.ts similarity index 98% rename from src/server/domains/security/mfa/accountLifecycle.factors.test.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycle.factors.test.ts index 1dc096e28..d02d89d2c 100644 --- a/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/src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts b/greenfield/src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts similarity index 98% rename from src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts index 93dfe52de..00e4829e7 100644 --- a/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/src/server/domains/security/mfa/accountLifecycle.proofs.test.ts b/greenfield/src/server/domains/security/mfa/accountLifecycle.proofs.test.ts similarity index 99% rename from src/server/domains/security/mfa/accountLifecycle.proofs.test.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycle.proofs.test.ts index ae86f6821..dd7414f34 100644 --- a/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/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 96% rename from src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts index d72a8bc8b..7ade216b7 100644 --- a/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/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 96% rename from src/server/domains/security/mfa/accountLifecycleCrypto.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycleCrypto.ts index bb3ed4264..bbb2e60da 100644 --- a/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/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 99% rename from src/server/domains/security/mfa/accountLifecycleTypes.ts rename to greenfield/src/server/domains/security/mfa/accountLifecycleTypes.ts index 199ee227d..7f01084a9 100644 --- a/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/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 97% rename from src/server/domains/security/mfa/accountMfaDisable.ts rename to greenfield/src/server/domains/security/mfa/accountMfaDisable.ts index 5c73a7c17..b050357d7 100644 --- a/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/src/server/domains/security/mfa/accountPasswordReauthentication.ts b/greenfield/src/server/domains/security/mfa/accountPasswordReauthentication.ts similarity index 97% rename from src/server/domains/security/mfa/accountPasswordReauthentication.ts rename to greenfield/src/server/domains/security/mfa/accountPasswordReauthentication.ts index e58a8e70b..2ddde4c50 100644 --- a/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/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 98% rename from src/server/domains/security/mfa/accountRecoveryCodeRotation.ts rename to greenfield/src/server/domains/security/mfa/accountRecoveryCodeRotation.ts index af4ff3fea..e7a7ff7fd 100644 --- a/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/src/server/domains/security/mfa/accountRecoveryStepUp.ts b/greenfield/src/server/domains/security/mfa/accountRecoveryStepUp.ts similarity index 98% rename from src/server/domains/security/mfa/accountRecoveryStepUp.ts rename to greenfield/src/server/domains/security/mfa/accountRecoveryStepUp.ts index 3d43b582f..b5fd3b44b 100644 --- a/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/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 98% rename from src/server/domains/security/mfa/accountTotpEnrollmentBegin.ts rename to greenfield/src/server/domains/security/mfa/accountTotpEnrollmentBegin.ts index 7e1a4d37f..b626c0e52 100644 --- a/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/src/server/domains/security/mfa/accountTotpEnrollmentConfirmation.ts b/greenfield/src/server/domains/security/mfa/accountTotpEnrollmentConfirmation.ts similarity index 97% rename from src/server/domains/security/mfa/accountTotpEnrollmentConfirmation.ts rename to greenfield/src/server/domains/security/mfa/accountTotpEnrollmentConfirmation.ts index b6fc99d2f..d71abd166 100644 --- a/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/src/server/domains/security/mfa/accountTotpFactorRemoval.ts b/greenfield/src/server/domains/security/mfa/accountTotpFactorRemoval.ts similarity index 96% rename from src/server/domains/security/mfa/accountTotpFactorRemoval.ts rename to greenfield/src/server/domains/security/mfa/accountTotpFactorRemoval.ts index 41613d4ef..e8fe4b2a5 100644 --- a/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/src/server/domains/security/mfa/accountTotpStepUp.ts b/greenfield/src/server/domains/security/mfa/accountTotpStepUp.ts similarity index 97% rename from src/server/domains/security/mfa/accountTotpStepUp.ts rename to greenfield/src/server/domains/security/mfa/accountTotpStepUp.ts index 8ec2ab593..ac12d036f 100644 --- a/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/src/server/domains/security/mfa/accountWebAuthnCredentialRemoval.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnCredentialRemoval.ts similarity index 96% rename from src/server/domains/security/mfa/accountWebAuthnCredentialRemoval.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnCredentialRemoval.ts index b095d9e63..b9c03a345 100644 --- a/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/src/server/domains/security/mfa/accountWebAuthnEnrollmentBegin.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentBegin.ts similarity index 98% rename from src/server/domains/security/mfa/accountWebAuthnEnrollmentBegin.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentBegin.ts index 27449f249..52030ac82 100644 --- a/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/src/server/domains/security/mfa/accountWebAuthnEnrollmentConfirmation.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentConfirmation.ts similarity index 93% rename from src/server/domains/security/mfa/accountWebAuthnEnrollmentConfirmation.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentConfirmation.ts index 63d490eeb..24d91b863 100644 --- a/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/src/server/domains/security/mfa/accountWebAuthnEnrollmentSettlement.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentSettlement.ts similarity index 95% rename from src/server/domains/security/mfa/accountWebAuthnEnrollmentSettlement.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnEnrollmentSettlement.ts index 0f7e3667c..c44ea760e 100644 --- a/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/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 98% rename from src/server/domains/security/mfa/accountWebAuthnRoutes.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnRoutes.ts index 2451411e1..63a55f323 100644 --- a/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/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 94% rename from src/server/domains/security/mfa/accountWebAuthnStepUp.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnStepUp.ts index aef9e8571..0994c1d31 100644 --- a/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/src/server/domains/security/mfa/accountWebAuthnStepUpBegin.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpBegin.ts similarity index 98% rename from src/server/domains/security/mfa/accountWebAuthnStepUpBegin.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpBegin.ts index 8452a33c3..b8033bc15 100644 --- a/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/src/server/domains/security/mfa/accountWebAuthnStepUpSettlement.ts b/greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpSettlement.ts similarity index 95% rename from src/server/domains/security/mfa/accountWebAuthnStepUpSettlement.ts rename to greenfield/src/server/domains/security/mfa/accountWebAuthnStepUpSettlement.ts index 18c803119..1937fbbdd 100644 --- a/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/src/server/domains/security/mfa/lifecycleRepository.test.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepository.test.ts similarity index 89% rename from src/server/domains/security/mfa/lifecycleRepository.test.ts rename to greenfield/src/server/domains/security/mfa/lifecycleRepository.test.ts index 8922835ab..5945c3d80 100644 --- a/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/src/server/domains/security/mfa/lifecycleRepository.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepository.ts similarity index 84% rename from src/server/domains/security/mfa/lifecycleRepository.ts rename to greenfield/src/server/domains/security/mfa/lifecycleRepository.ts index 2df782016..8e6210415 100644 --- a/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/src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts b/greenfield/src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts similarity index 80% rename from src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts rename to greenfield/src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts index 3687941cf..b9e8af1ec 100644 --- a/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/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 99% rename from src/server/domains/security/mfa/lifecycleRepositoryTypes.ts rename to greenfield/src/server/domains/security/mfa/lifecycleRepositoryTypes.ts index 8fc0a63e5..e0d6df211 100644 --- a/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/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 87% rename from src/server/domains/security/mfa/loginCoordinator.ts rename to greenfield/src/server/domains/security/mfa/loginCoordinator.ts index cb8027f11..9684bf34e 100644 --- a/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/src/server/domains/security/mfa/loginLifecycle.pending.test.ts b/greenfield/src/server/domains/security/mfa/loginLifecycle.pending.test.ts similarity index 96% rename from src/server/domains/security/mfa/loginLifecycle.pending.test.ts rename to greenfield/src/server/domains/security/mfa/loginLifecycle.pending.test.ts index 2c032c91e..68d869f53 100644 --- a/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/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 99% rename from src/server/domains/security/mfa/loginLifecycle.totp.test.ts rename to greenfield/src/server/domains/security/mfa/loginLifecycle.totp.test.ts index b7c7a69d2..52d09c93b 100644 --- a/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/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 96% rename from src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts rename to greenfield/src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts index e7013e574..6f22bffeb 100644 --- a/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/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 97% rename from src/server/domains/security/mfa/loginLifecycleTypes.ts rename to greenfield/src/server/domains/security/mfa/loginLifecycleTypes.ts index e2ca91751..7a0b00ea4 100644 --- a/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/src/server/domains/security/mfa/loginPendingLifecycle.ts b/greenfield/src/server/domains/security/mfa/loginPendingLifecycle.ts similarity index 97% rename from src/server/domains/security/mfa/loginPendingLifecycle.ts rename to greenfield/src/server/domains/security/mfa/loginPendingLifecycle.ts index b2cf48d9b..9127dcee3 100644 --- a/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/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 95% rename from src/server/domains/security/mfa/loginWebAuthnProof.ts rename to greenfield/src/server/domains/security/mfa/loginWebAuthnProof.ts index 79a6694d6..544643df8 100644 --- a/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/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 96% rename from src/server/domains/security/mfa/testSupport/accountLifecycle.ts rename to greenfield/src/server/domains/security/mfa/testSupport/accountLifecycle.ts index 8a24646e9..31f92ec56 100644 --- a/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/src/server/domains/security/mfa/testSupport/loginLifecycle.ts b/greenfield/src/server/domains/security/mfa/testSupport/loginLifecycle.ts similarity index 96% rename from src/server/domains/security/mfa/testSupport/loginLifecycle.ts rename to greenfield/src/server/domains/security/mfa/testSupport/loginLifecycle.ts index 62e5c9e9a..10649e9e3 100644 --- a/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/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 97% rename from src/server/domains/security/procedures.test.ts rename to greenfield/src/server/domains/security/procedures.test.ts index fca8b1702..45da97696 100644 --- a/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/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/greenfield/src/server/domains/security/testSupport/authentication.ts b/greenfield/src/server/domains/security/testSupport/authentication.ts new file mode 100644 index 000000000..79ce452a1 --- /dev/null +++ b/greenfield/src/server/domains/security/testSupport/authentication.ts @@ -0,0 +1,141 @@ +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"; +import { automationCredentials } from "../../../database/schema/automationCredentials.ts"; +import { automationPrincipalCapabilities } from "../../../database/schema/automationPrincipalCapabilities.ts"; +import { automationPrincipals } from "../../../database/schema/automationPrincipals.ts"; +import { users } from "../../../database/schema/users.ts"; +import { authSessionInsertSchema } from "../../../database/validation/authSessions.ts"; +import { automationCredentialInsertSchema } from "../../../database/validation/automationCredentials.ts"; +import { automationPrincipalCapabilityInsertSchema } from "../../../database/validation/automationPrincipalCapabilities.ts"; +import { automationPrincipalInsertSchema } from "../../../database/validation/automationPrincipals.ts"; +import { userInsertSchema } from "../../../database/validation/users.ts"; +import { generateOpaqueToken } from "../../../shared/opaqueToken.ts"; +import { openFreshMigratedDatabase } from "../../../test/support/freshDatabase.ts"; +import { testDashboardPasswordHash } from "../../../test/support/securityPassword.ts"; +import type { TotpSecretCipher } from "../mfa/totpSecretCipher.ts"; +import { createRequestAuthenticationRepository } from "../requestAuthenticationRepository.ts"; + +export const authenticationTestNow = parseISO("2026-08-05T01:00:00.000Z"); +export const authenticationTestUserId = "019fc968-1a9b-7770-8f1b-d5b863b0e7b4"; +export const authenticationTestCredentialId = "019fc968-1a9b-7771-9f1b-d5b863b0e7b4"; +export const authenticationTestPrincipalId = "openclaw-task-tracking"; + +/** Fail-closed cipher used by composition tests that do not exercise TOTP. */ +export const testTotpSecretCipher: TotpSecretCipher = Object.freeze({ + activeKeyId: "test-primary", + decrypt: () => Promise.reject(new Error("Test TOTP secret is unavailable")), + encrypt: () => Promise.reject(new Error("Test TOTP encryption is unavailable")), + 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. + * @returns Fresh authentication fixture with its repository and generated tokens. + */ +export async function openAuthenticationTestDatabase(now = authenticationTestNow) { + const database = await openFreshMigratedDatabase(); + + try { + const seeded = seedAuthenticationTestDatabase(database.orm, now); + + return { + ...seeded, + database, + repository: createRequestAuthenticationRepository(database.orm), + }; + } catch (error) { + database.sqlite.close(true); + throw error; + } +} diff --git a/src/server/domains/security/testSupport/authenticationLifecycle.ts b/greenfield/src/server/domains/security/testSupport/authenticationLifecycle.ts similarity index 94% rename from src/server/domains/security/testSupport/authenticationLifecycle.ts rename to greenfield/src/server/domains/security/testSupport/authenticationLifecycle.ts index f709c8e11..982c919df 100644 --- a/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/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 59% rename from src/server/platform/errors/safeFailure.test.ts rename to greenfield/src/server/platform/errors/safeFailure.test.ts index 70d718c75..0784f1648 100644 --- a/src/server/platform/errors/safeFailure.test.ts +++ b/greenfield/src/server/platform/errors/safeFailure.test.ts @@ -2,6 +2,17 @@ import { expect, test } from "bun:test"; import { Cause, Data } from "effect"; +import { + DatabaseRuntimeCheckpointError, + DatabaseRuntimeCloseError, + DatabaseRuntimeLockTimeoutError, + 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")<{ @@ -109,3 +120,54 @@ 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", + }), + new DatabaseRuntimeWriteAdmissionTimeoutError({ + message: "write admission timed out", + timeoutMs: 5000, + }), + new DatabaseRuntimeWriteContentionError({ + message: "write contention", + }), + ]; + + expect(failures.map((failure) => describeSafeFailure(failure).tag)).toEqual([ + "DatabaseRuntimeCheckpointError", + "DatabaseRuntimeCloseError", + "DatabaseRuntimeLockTimeoutError", + "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/src/server/platform/errors/safeFailure.ts b/greenfield/src/server/platform/errors/safeFailure.ts similarity index 89% rename from src/server/platform/errors/safeFailure.ts rename to greenfield/src/server/platform/errors/safeFailure.ts index 8420dbd1b..1c9f4d630 100644 --- a/src/server/platform/errors/safeFailure.ts +++ b/greenfield/src/server/platform/errors/safeFailure.ts @@ -7,7 +7,16 @@ const knownFailureTags = new Set([ "ApplicationListenerStopTimeoutError", "AuthenticationUpstreamUnavailableError", "AuthenticationWorkCapacityError", + "AuthenticationWorkSettlementError", "AuthenticationWorkTimeoutError", + "DatabaseRuntimeCheckpointError", + "DatabaseRuntimeCloseError", + "DatabaseRuntimeLockTimeoutError", + "DatabaseRuntimePathError", + "DatabaseRuntimeSnapshotRequiredError", + "DatabaseRuntimeStartupError", + "DatabaseRuntimeWriteAdmissionTimeoutError", + "DatabaseRuntimeWriteContentionError", "MonitoringRunConflictError", "MonitoringSnapshotValidationError", "RealtimeEventCursorStreamError", 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 72% rename from src/server/platform/runtime/applicationRuntime.ts rename to greenfield/src/server/platform/runtime/applicationRuntime.ts index 27ae69efa..890e93825 100644 --- a/src/server/platform/runtime/applicationRuntime.ts +++ b/greenfield/src/server/platform/runtime/applicationRuntime.ts @@ -1,5 +1,25 @@ -import { Data, Effect, Exit, Fiber, Layer, ManagedRuntime, Stream } from "effect"; +import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; +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, type AuthenticationWorkRuntimeService, @@ -10,9 +30,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 +100,16 @@ export interface ApplicationRuntime { shutdownListener(options: ApplicationListenerShutdownOptions): Promise; } +/** Runtime-owned database access exposed only to the Dashboard composition root. */ +export interface DashboardDatabaseRuntimeService extends ImmediateDatabaseWriteAdmission { + readonly orm: () => Promise; +} + +/** Process runtime coordinating ordered application and retained database scopes. */ +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 +117,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 +245,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 +383,110 @@ export function createApplicationRuntime( async initialize() { await runtime.context(); }, - logger: options.logger, + logger, services, shutdownListener(options: ApplicationListenerShutdownOptions) { return runtime.runPromise(coordinatedListenerShutdown(options)); }, }); } + +function databaseBackedRealtimeEventPumpLayer( + databaseOrm: Effect.Effect +): Layer.Layer { + return Layer.unwrap( + databaseOrm.pipe( + Effect.map((orm) => + realtimeEventPumpLayer({ + makePump: (pumpRuntime) => + new RealtimeEventPump({ + ...pumpRuntime, + store: createRealtimeEventStore(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 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 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( + databaseBackedRealtimeLayer, + authenticationWorkLayer(options.authenticationWork), + createEffectLoggerLayer(options.logger) + ) + ); + const applicationRuntime = createApplicationRuntimeFromManagedRuntime( + runtime, + options.logger + ); + const database: DashboardDatabaseRuntimeService = Object.freeze({ + orm: () => + 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 new file mode 100644 index 000000000..1b7c54a82 --- /dev/null +++ b/greenfield/src/server/platform/runtime/dashboardApplicationRuntime.test.ts @@ -0,0 +1,219 @@ +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 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"; +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(); + } + } + }); + + 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/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 82% rename from src/server/test/contracts/trpcErrors.test.ts rename to greenfield/src/server/test/contracts/trpcErrors.test.ts index 9c71e21ea..bd854d5d7 100644 --- a/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/src/server/test/support/authenticationWorkGate.ts b/greenfield/src/server/test/support/authenticationWorkGate.ts similarity index 97% rename from src/server/test/support/authenticationWorkGate.ts rename to greenfield/src/server/test/support/authenticationWorkGate.ts index cba48aea1..c662bebac 100644 --- a/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/src/server/test/support/automationHttpSystem.ts b/greenfield/src/server/test/support/automationHttpSystem.ts similarity index 98% rename from src/server/test/support/automationHttpSystem.ts rename to greenfield/src/server/test/support/automationHttpSystem.ts index 752149be6..5dc22156b 100644 --- a/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/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/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 98% rename from src/server/test/support/mfaHttpSystem.ts rename to greenfield/src/server/test/support/mfaHttpSystem.ts index 2ef899406..09f92cd77 100644 --- a/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( 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; }); } @@ -227,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)), }); } @@ -246,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" })), }); } @@ -295,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" })), @@ -325,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" })), @@ -339,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)), }); } @@ -405,6 +418,39 @@ export function createTestApplicationRuntime( }); } +/** + * Attaches a test-owned migrated handle to an existing runtime stub or focused runtime. + * Production composition obtains the same shape from its scoped database Layer. + * @param applicationRuntime Runtime exercised by the test. + * @param database Migrated test database retained by the fixture. + * @returns A Dashboard runtime whose database accessor returns the exact supplied ORM. + */ +export function withTestDashboardDatabase( + applicationRuntime: ApplicationRuntime, + database: SQLiteBunDatabase +): DashboardApplicationRuntime { + return Object.freeze({ + ...applicationRuntime, + database: Object.freeze({ + orm: () => Promise.resolve(database), + run: runTestImmediateDatabaseWrite, + }), + }); +} + +/** + * 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/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 99% rename from src/server/test/system/serverAuthenticationTransport.test.ts rename to greenfield/src/server/test/system/serverAuthenticationTransport.test.ts index 1ecb19e4e..5a11bc30d 100644 --- a/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/src/server/test/system/serverAutomationSecurity.test.ts b/greenfield/src/server/test/system/serverAutomationSecurity.test.ts similarity index 96% rename from src/server/test/system/serverAutomationSecurity.test.ts rename to greenfield/src/server/test/system/serverAutomationSecurity.test.ts index 222e4f54f..d8ea78f5c 100644 --- a/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/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 97% rename from src/server/test/system/serverGatewayCredentialVerification.test.ts rename to greenfield/src/server/test/system/serverGatewayCredentialVerification.test.ts index 799fd80f5..7446b01fc 100644 --- a/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/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 98% rename from src/server/test/system/serverRealtime.test.ts rename to greenfield/src/server/test/system/serverRealtime.test.ts index 4abf705c2..f443e72ed 100644 --- a/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/src/server/test/system/serverShutdown.test.ts b/greenfield/src/server/test/system/serverShutdown.test.ts similarity index 69% rename from src/server/test/system/serverShutdown.test.ts rename to greenfield/src/server/test/system/serverShutdown.test.ts index 0557e8f17..4dd442b66 100644 --- a/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); 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 80% rename from src/server/trpc/procedureErrorPolicy.test.ts rename to greenfield/src/server/trpc/procedureErrorPolicy.test.ts index 11c01fe0c..84944b106 100644 --- a/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/src/server/trpc/procedureErrorPolicy.ts b/greenfield/src/server/trpc/procedureErrorPolicy.ts similarity index 81% rename from src/server/trpc/procedureErrorPolicy.ts rename to greenfield/src/server/trpc/procedureErrorPolicy.ts index 22665fb86..0e6d50dd9 100644 --- a/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", }); 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..77953ccdc 100644 --- a/package.json +++ b/package.json @@ -14,21 +14,15 @@ "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", + "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", @@ -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", @@ -62,7 +45,6 @@ "@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", @@ -72,13 +54,8 @@ "@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,14 +89,10 @@ "@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", "happy-dom": "^20.11.1", - "jsonc-parser": "3.3.1", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "oxlint-config-presets": "^0.1.18", 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/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/src/app/dashboardServer.test.ts b/src/app/dashboardServer.test.ts deleted file mode 100644 index 9811aff01..000000000 --- a/src/app/dashboardServer.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import * as v from "valibot"; - -import { listAutomationPrincipalsResultSchema } from "../contracts/automationSecurity.ts"; -import { createWebAuthnRelyingPartyConfiguration } from "../server/domains/security/mfa/webauthn/relyingPartyConfiguration.ts"; -import { - authenticationTestNow, - authenticationTestPrincipalId, - openAuthenticationTestDatabase, - testTotpSecretCipher, -} from "../server/domains/security/testSupport/authentication.ts"; -import { createReadinessController } from "../server/platform/readiness/readinessState.ts"; -import { dashboardSessionCookieName } from "../server/rawHttp/authenticationCredentials.ts"; -import { createTestApplicationRuntime } from "../server/test/support/requestContext.ts"; -import { - createDashboardServer, - validateDashboardWebAuthnBrowserOrigin, -} from "./dashboardServer.ts"; - -describe("Dashboard security composition", () => { - test("requires the HTTP browser origin in the WebAuthn allowlist", () => { - const relyingParty = createWebAuthnRelyingPartyConfiguration({ - allowedOrigins: ["https://dashboard.example"], - rpId: "dashboard.example", - rpName: "Mira Dashboard", - }); - - expect( - validateDashboardWebAuthnBrowserOrigin( - "https://dashboard.example", - relyingParty - ) - ).toBe("https://dashboard.example"); - expect(() => - validateDashboardWebAuthnBrowserOrigin( - "https://admin.dashboard.example", - relyingParty - ) - ).toThrow( - "Dashboard browser origin is absent from the WebAuthn origin allowlist" - ); - }); - - 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, - }); - - try { - const input = encodeURIComponent(JSON.stringify({ json: {} })); - const response = await fetch( - new URL( - `/trpc/automationSecurity.listPrincipals?input=${input}`, - server.url - ), - { - headers: { - cookie: `${dashboardSessionCookieName}=${fixture.session.token}`, - }, - } - ); - const body = (await response.json()) as { - readonly error?: unknown; - readonly result?: { readonly data?: { readonly json?: unknown } }; - }; - - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe("no-store"); - expect(body.error).toBeUndefined(); - const result = v.parse( - listAutomationPrincipalsResultSchema, - body.result?.data?.json - ); - expect( - result.principals.find(({ id }) => id === authenticationTestPrincipalId) - ).toMatchObject({ - activeCredentialCount: 1, - capabilities: ["reports:read"], - disabled: false, - id: authenticationTestPrincipalId, - }); - } finally { - await server.stop(true); - fixture.database.sqlite.close(true); - } - }); -}); diff --git a/src/app/dashboardServer.ts b/src/app/dashboardServer.ts deleted file mode 100644 index 2259e9d5d..000000000 --- a/src/app/dashboardServer.ts +++ /dev/null @@ -1,198 +0,0 @@ -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 { - authenticationWorkBudgetMaximumUnits, - authenticationWorkBudgetWindowMs, - totpWorkBudgetMaximumUnits, - totpWorkBudgetWindowMs, - webAuthnWorkBudgetMaximumUnits, - webAuthnWorkBudgetWindowMs, -} from "../server/domains/security/authenticationRateLimit.ts"; -import { createAuthenticationWorkBudget } from "../server/domains/security/authenticationWorkBudget.ts"; -import { createAutomationSecurityLifecycleService } from "../server/domains/security/automation/lifecycle.ts"; -import { createAutomationLifecycleRepository } from "../server/domains/security/automation/lifecycleRepository.ts"; -import { createMfaAccountLifecycleService } from "../server/domains/security/mfa/accountLifecycle.ts"; -import { createMfaLifecycleRepository } from "../server/domains/security/mfa/lifecycleRepository.ts"; -import { createMfaLoginLifecycleService } from "../server/domains/security/mfa/loginLifecycle.ts"; -import type { TotpSecretCipher } from "../server/domains/security/mfa/totpSecretCipher.ts"; -import { createWebAuthnAdapter } from "../server/domains/security/mfa/webauthn/adapter.ts"; -import type { WebAuthnRelyingPartyConfiguration } from "../server/domains/security/mfa/webauthn/relyingPartyConfiguration.ts"; -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 { parseBrowserOrigin } from "../server/rawHttp/requestSecurity.ts"; -import { createServer, type ApplicationServer, type ServerOptions } from "./server.ts"; - -/** Production composition inputs above the generic Bun/tRPC server primitive. */ -export interface DashboardServerOptions extends Omit< - ServerOptions, - | "authenticateCredential" - | "authenticationLifecycle" - | "automationSecurityLifecycle" - | "browserOrigin" - | "hostname" - | "mfaAccountLifecycle" - | "mfaLoginLifecycle" -> { - 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; - /** Shared composition clock for deterministic lifecycle and request-auth behavior. */ - readonly now?: () => Date; - readonly recentAuthenticationWindowMs?: number; - readonly sessionIdleDurationMs?: number; - readonly totpSecretCipher: TotpSecretCipher; - readonly trustedProxyAddresses?: readonly string[]; - /** Explicit WebAuthn trust configuration; request host headers are never used. */ - readonly webAuthnRelyingParty?: WebAuthnRelyingPartyConfiguration; - readonly webAuthnVerificationTimeoutMs?: number; -} - -/** - * Ensures the HTTP and WebAuthn browser trust boundaries cannot diverge. - * @param browserOrigin Explicit public Dashboard browser origin. - * @param relyingParty Optional validated WebAuthn trust configuration. - * @returns The canonical Dashboard browser origin. - */ -export function validateDashboardWebAuthnBrowserOrigin( - browserOrigin: string, - relyingParty?: WebAuthnRelyingPartyConfiguration -): string { - const canonicalOrigin = parseBrowserOrigin(browserOrigin); - if ( - relyingParty !== undefined && - !relyingParty.allowedOrigins.includes(canonicalOrigin) - ) { - throw new TypeError( - "Dashboard browser origin is absent from the WebAuthn origin allowlist" - ); - } - return canonicalOrigin; -} - -/** - * 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. - * @returns A started Bun server using persisted session and automation identities. - */ -export 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, - }); - 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, - }); -} diff --git a/src/server/database/migrations/loadVerifiedMigrations.test.ts b/src/server/database/migrations/loadVerifiedMigrations.test.ts deleted file mode 100644 index ca517c7d9..000000000 --- a/src/server/database/migrations/loadVerifiedMigrations.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { cp, mkdtemp, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { migrationsDirectory } from "../../test/support/freshDatabase.ts"; -import { loadVerifiedMigrations } from "./loadVerifiedMigrations.ts"; -import { migrationManifest } from "./manifest.ts"; - -const temporaryDirectories: string[] = []; - -afterEach(async () => { - await Promise.all( - temporaryDirectories - .splice(0) - .map((directory) => rm(directory, { force: true, recursive: true })) - ); -}); - -async function copyMigrationGraph(): Promise { - const directory = await mkdtemp(path.join(os.tmpdir(), "mira-migrations-")); - temporaryDirectories.push(directory); - await cp(migrationsDirectory, directory, { recursive: true }); - return directory; -} - -function reviewedMigration() { - const migration = migrationManifest[0]; - if (!migration) { - throw new Error("Expected the migration manifest to contain a foundation node"); - } - return migration; -} - -async function expectRejection( - operation: Promise, - expectedMessage: string -): Promise { - let rejection: unknown; - - try { - await operation; - } catch (error) { - rejection = error; - } - - expect(rejection).toBeInstanceOf(Error); - expect((rejection as Error).message).toContain(expectedMessage); -} - -describe("reviewed migration manifest", () => { - test("loads the exact reviewed graph in runtime order", async () => { - const migrations = await loadVerifiedMigrations({ - directory: migrationsDirectory, - }); - - expect(migrations.map((migration) => migration.id)).toEqual( - migrationManifest.map((migration) => migration.id) - ); - expect(migrations[0]?.statements.length).toBeGreaterThan(1); - }); - - test("rejects a tampered snapshot", async () => { - const directory = await copyMigrationGraph(); - const migrationId = reviewedMigration().id; - - await writeFile(`${directory}/${migrationId}/snapshot.json`, "{}\n"); - - await expectRejection( - loadVerifiedMigrations({ directory }), - `Migration snapshot checksum mismatch: ${migrationId}` - ); - }); - - test("rejects tampered migration SQL", async () => { - const directory = await copyMigrationGraph(); - const migrationId = reviewedMigration().id; - - await writeFile(`${directory}/${migrationId}/migration.sql`, "SELECT 1;\n"); - - await expectRejection( - loadVerifiedMigrations({ directory }), - `Migration SQL checksum mismatch: ${migrationId}` - ); - }); - - test("rejects duplicate manifest ids", async () => { - const directory = await copyMigrationGraph(); - const migration = reviewedMigration(); - - await expectRejection( - loadVerifiedMigrations({ - directory, - manifest: [migration, { ...migration }], - }), - "Migration manifest contains an invalid or duplicate folder name" - ); - }); - - test("reports duplicate ids before malformed checksums", async () => { - const directory = await copyMigrationGraph(); - const migration = reviewedMigration(); - - await expectRejection( - loadVerifiedMigrations({ - directory, - manifest: [ - { ...migration, migrationSha256: "not-a-checksum" }, - migration, - ], - }), - "Migration manifest contains an invalid or duplicate folder name" - ); - }); - - test("rejects an unknown manifest shape with the folder-name error", async () => { - const directory = await copyMigrationGraph(); - - await expectRejection( - loadVerifiedMigrations({ - directory, - manifest: { entries: [reviewedMigration()] }, - }), - "Migration manifest contains an invalid or duplicate folder name" - ); - }); - - test("rejects manifest ids outside runtime order", async () => { - const directory = await copyMigrationGraph(); - const migration = reviewedMigration(); - - await expectRejection( - loadVerifiedMigrations({ - directory, - manifest: [ - migration, - { - ...migration, - id: "20200101000000_dashboard-followup", - }, - ], - }), - "Migration manifest is not in runtime application order" - ); - }); - - test("reports runtime order before malformed checksums", async () => { - const directory = await copyMigrationGraph(); - const migration = reviewedMigration(); - - await expectRejection( - loadVerifiedMigrations({ - directory, - manifest: [ - { ...migration, migrationSha256: "not-a-checksum" }, - { - ...migration, - id: "20200101000000_dashboard-followup", - }, - ], - }), - "Migration manifest is not in runtime application order" - ); - }); - - test("rejects malformed SQL checksums", async () => { - const directory = await copyMigrationGraph(); - const migration = reviewedMigration(); - - await expectRejection( - loadVerifiedMigrations({ - directory, - manifest: [{ ...migration, migrationSha256: "not-a-checksum" }], - }), - "Migration manifest contains an invalid SHA-256 checksum" - ); - }); - - test("rejects malformed snapshot checksums", async () => { - const directory = await copyMigrationGraph(); - const migration = reviewedMigration(); - - await expectRejection( - loadVerifiedMigrations({ - directory, - manifest: [{ ...migration, snapshotSha256: "not-a-checksum" }], - }), - "Migration manifest contains an invalid SHA-256 checksum" - ); - }); - - test("rejects a non-string checksum from an unknown manifest", async () => { - const directory = await copyMigrationGraph(); - const migration = reviewedMigration(); - - await expectRejection( - loadVerifiedMigrations({ - directory, - manifest: [{ ...migration, migrationSha256: 1 }], - }), - "Migration manifest contains an invalid SHA-256 checksum" - ); - }); - - test("rejects unreviewed migration folders", async () => { - const directory = await copyMigrationGraph(); - await cp( - `${directory}/${reviewedMigration().id}`, - `${directory}/20260803215711_unreviewed`, - { recursive: true } - ); - - await expectRejection( - loadVerifiedMigrations({ directory }), - "Migration directory does not match the reviewed manifest" - ); - }); - - test("rejects malformed migration folder names from the filesystem", async () => { - const directory = await copyMigrationGraph(); - await cp( - `${directory}/${reviewedMigration().id}`, - `${directory}/not-a-migration`, - { recursive: true } - ); - - await expectRejection( - loadVerifiedMigrations({ directory }), - "Migration directory does not match the reviewed manifest" - ); - }); -}); diff --git a/src/server/database/migrations/migrationApplicationTime.test.ts b/src/server/database/migrations/migrationApplicationTime.test.ts deleted file mode 100644 index 1260d2fb4..000000000 --- a/src/server/database/migrations/migrationApplicationTime.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Database } from "bun:sqlite"; -import { expect, test } from "bun:test"; - -import { toDate } from "date-fns"; - -import { migrationsDirectory } from "../../test/support/freshDatabase.ts"; -import { applyVerifiedMigrations } from "./applyVerifiedMigrations.ts"; -import { loadVerifiedMigrations } from "./loadVerifiedMigrations.ts"; - -test("rejects an invalid migration timestamp without changing the database", async () => { - const migrations = await loadVerifiedMigrations({ directory: migrationsDirectory }); - const database = new Database(":memory:", { strict: true }); - - try { - database.run("PRAGMA foreign_keys = ON"); - expect(() => - applyVerifiedMigrations(database, migrations, { - appliedAt: toDate(Number.NaN), - releaseId: "1".repeat(40), - }) - ).toThrow("Migration appliedAt must be valid Date milliseconds"); - expect( - database - .query<{ name: string }, []>(` - SELECT name - FROM sqlite_schema - WHERE name NOT GLOB 'sqlite_*' - `) - .all() - ).toEqual([]); - } finally { - database.close(true); - } -}); diff --git a/src/server/database/migrations/migrationLedgerValidation.test.ts b/src/server/database/migrations/migrationLedgerValidation.test.ts deleted file mode 100644 index fe5e51e18..000000000 --- a/src/server/database/migrations/migrationLedgerValidation.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { expect, test } from "bun:test"; - -import { - migrationsDirectory, - openFreshMigratedDatabase, -} from "../../test/support/freshDatabase.ts"; -import { applyVerifiedMigrations } from "./applyVerifiedMigrations.ts"; -import { loadVerifiedMigrations } from "./loadVerifiedMigrations.ts"; - -test("validates every raw field in the durable migration ledger", async () => { - const migrations = await loadVerifiedMigrations({ directory: migrationsDirectory }); - const foundationMigration = migrations[0]; - if (foundationMigration === undefined) { - throw new Error("Expected the migration graph to contain a foundation node"); - } - const corruptions = [ - "UPDATE schema_migrations SET applied_at = -1 WHERE id = ?", - "UPDATE schema_migrations SET id = 'invalid' WHERE id = ?", - `UPDATE schema_migrations SET release_id = '${"A".repeat(40)}' WHERE id = ?`, - ] as const; - - for (const corruption of corruptions) { - const database = await openFreshMigratedDatabase(); - try { - database.sqlite.run(corruption, [foundationMigration.id]); - expect(() => - applyVerifiedMigrations(database.sqlite, migrations, { - releaseId: "1".repeat(40), - }) - ).toThrow("Database migration history does not match the reviewed manifest"); - } finally { - database.sqlite.close(true); - } - } -}); diff --git a/src/server/database/schema/schemaMigrations.ts b/src/server/database/schema/schemaMigrations.ts deleted file mode 100644 index 77d080fe3..000000000 --- a/src/server/database/schema/schemaMigrations.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { 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(), -}); diff --git a/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts b/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts deleted file mode 100644 index 4b235a253..000000000 --- a/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { Effect, Layer, Stream } from "effect"; - -import { RealtimeEventPumpService } from "../../platform/realtime/eventPumpService.ts"; -import { createApplicationRuntime } from "../../platform/runtime/applicationRuntime.ts"; -import { captureFailure } from "../../test/support/promise.ts"; -import { createTestStructuredLogger } from "../../test/support/requestContext.ts"; -import { - type AuthenticationVerificationWorkOptions, - AuthenticationUpstreamUnavailableError, - AuthenticationWorkTimeoutError, -} from "./authenticationWorkGate.ts"; - -const inertRealtimeLayer = Layer.succeed( - RealtimeEventPumpService, - RealtimeEventPumpService.of({ - metricsSnapshot: Effect.die("WebAuthn work tests do not use metrics"), - stream: () => Stream.empty, - wake: Effect.void, - }) -); - -const testStructuredLogger = createTestStructuredLogger(); - -async function yieldToWorkService(): Promise { - await Promise.resolve(); - await Promise.resolve(); -} - -function webAuthnRunner(runtime: ReturnType) { - return ( - work: (signal: AbortSignal) => Promise, - options: AuthenticationVerificationWorkOptions - ): Promise => - runtime.services.authentication.runWebAuthnVerification(work, options); -} - -describe("process WebAuthn verification work service", () => { - test("uses an independent default two-active/four-queued gate", async () => { - const runtime = createApplicationRuntime({ - logger: testStructuredLogger, - realtimeEventPumpLayer: inertRealtimeLayer, - }); - const releaseActive = Promise.withResolvers(); - const activeStarted = Promise.withResolvers(); - let starts = 0; - - try { - await runtime.initialize(); - const authentication = runtime.services.authentication; - const runWebAuthn = (value: number): Promise => - authentication.runWebAuthnVerification( - async (signal) => { - expect(signal.aborted).toBeFalse(); - starts += 1; - if (starts === 2) activeStarted.resolve(); - if (starts <= 2) await releaseActive.promise; - return value; - }, - { timeoutMs: 5000 } - ); - const active = [runWebAuthn(1), runWebAuthn(2)]; - await activeStarted.promise; - const queued = [ - runWebAuthn(3), - runWebAuthn(4), - runWebAuthn(5), - runWebAuthn(6), - ]; - await yieldToWorkService(); - - expect(starts).toBe(2); - expect( - await authentication.runGatewayVerification( - () => Promise.resolve("gateway"), - { timeoutMs: 500 } - ) - ).toBe("gateway"); - expect(await captureFailure(() => runWebAuthn(7))).toMatchObject({ - _tag: "AuthenticationWorkCapacityError", - operation: "webauthn", - }); - - releaseActive.resolve(); - expect(await Promise.all([...active, ...queued])).toEqual([1, 2, 3, 4, 5, 6]); - expect(starts).toBe(6); - } finally { - releaseActive.resolve(); - await runtime.dispose(); - } - }); - - test("releases queued admission when the WebAuthn caller aborts", async () => { - const runtime = createApplicationRuntime({ - authenticationWork: { - webAuthnMaximumConcurrent: 1, - webAuthnMaximumQueued: 1, - }, - logger: testStructuredLogger, - realtimeEventPumpLayer: inertRealtimeLayer, - }); - const releaseFirst = Promise.withResolvers(); - const firstStarted = Promise.withResolvers(); - let queuedCancellationSettlements = 0; - - try { - await runtime.initialize(); - const webAuthn = webAuthnRunner(runtime); - const first = webAuthn( - async () => { - firstStarted.resolve(); - await releaseFirst.promise; - return "first"; - }, - { timeoutMs: 5000 } - ); - await firstStarted.promise; - const controller = new AbortController(); - const queued = webAuthn(() => Promise.resolve("cancelled"), { - onCancellationBeforeRelease: () => { - queuedCancellationSettlements += 1; - }, - signal: controller.signal, - timeoutMs: 5000, - }); - await yieldToWorkService(); - - const cancellation = new Error("request cancelled"); - controller.abort(cancellation); - expect(await captureFailure(() => queued)).toBe(cancellation); - expect(queuedCancellationSettlements).toBe(0); - - const replacement = webAuthn(() => Promise.resolve("replacement"), { - timeoutMs: 5000, - }); - await yieldToWorkService(); - expect( - await captureFailure(() => - webAuthn(() => Promise.resolve("overflow"), { - timeoutMs: 5000, - }) - ) - ).toMatchObject({ - _tag: "AuthenticationWorkCapacityError", - operation: "webauthn", - }); - releaseFirst.resolve(); - expect(await first).toBe("first"); - expect(await replacement).toBe("replacement"); - } finally { - releaseFirst.resolve(); - await runtime.dispose(); - } - }); - - test("redacts defects and retains active capacity after timeout and abort", async () => { - const runtime = createApplicationRuntime({ - authenticationWork: { - webAuthnMaximumConcurrent: 1, - webAuthnMaximumQueued: 0, - }, - logger: testStructuredLogger, - realtimeEventPumpLayer: inertRealtimeLayer, - }); - const timedWork = Promise.withResolvers(); - const abortedWork = Promise.withResolvers(); - const abortedWorkStarted = Promise.withResolvers(); - let timedSignal: AbortSignal | undefined; - let abortedSignal: AbortSignal | undefined; - let cancellationSettlements = 0; - let timeoutSettled = false; - - try { - await runtime.initialize(); - const webAuthn = webAuthnRunner(runtime); - const unavailable = await captureFailure(() => - webAuthn(() => Promise.reject(new Error("sensitive verifier detail")), { - timeoutMs: 500, - }) - ); - expect(unavailable).toBeInstanceOf(AuthenticationUpstreamUnavailableError); - expect(unavailable).toMatchObject({ operation: "webauthn" }); - expect(String(unavailable)).not.toContain("sensitive verifier detail"); - - const timedOut = await captureFailure(() => - webAuthn( - (signal) => { - timedSignal = signal; - return timedWork.promise; - }, - { - onFailureBeforeRelease: (failure) => { - expect(failure).toBeInstanceOf( - AuthenticationWorkTimeoutError - ); - timeoutSettled = true; - }, - timeoutMs: 50, - } - ) - ); - expect(timedOut).toBeInstanceOf(AuthenticationWorkTimeoutError); - expect(timedOut).toMatchObject({ operation: "webauthn", timeoutMs: 50 }); - expect(timeoutSettled).toBeTrue(); - expect(timedSignal?.aborted).toBeTrue(); - expect( - await captureFailure(() => - webAuthn(() => Promise.resolve(true), { timeoutMs: 500 }) - ) - ).toMatchObject({ operation: "webauthn" }); - - timedWork.resolve(false); - await yieldToWorkService(); - const controller = new AbortController(); - const aborted = webAuthn( - (signal) => { - abortedSignal = signal; - abortedWorkStarted.resolve(); - return abortedWork.promise; - }, - { - onCancellationBeforeRelease: () => { - cancellationSettlements += 1; - }, - signal: controller.signal, - timeoutMs: 5000, - } - ); - await abortedWorkStarted.promise; - const cancellation = new Error("request cancelled"); - controller.abort(cancellation); - expect(await captureFailure(() => aborted)).toBe(cancellation); - expect(abortedSignal?.aborted).toBeTrue(); - expect(cancellationSettlements).toBe(0); - expect( - await captureFailure(() => - webAuthn(() => Promise.resolve(true), { timeoutMs: 500 }) - ) - ).toMatchObject({ operation: "webauthn" }); - - abortedWork.resolve(false); - await yieldToWorkService(); - expect(cancellationSettlements).toBe(1); - expect( - await webAuthn(() => Promise.resolve(true), { timeoutMs: 500 }) - ).toBeTrue(); - } finally { - timedWork.resolve(false); - abortedWork.resolve(false); - await runtime.dispose(); - } - }); - - test("runs in-gate rechecks and settlements before releasing capacity", async () => { - const runtime = createApplicationRuntime({ - authenticationWork: { - webAuthnMaximumConcurrent: 1, - webAuthnMaximumQueued: 1, - }, - logger: testStructuredLogger, - realtimeEventPumpLayer: inertRealtimeLayer, - }); - const releaseResult = Promise.withResolvers(); - const resultStarted = Promise.withResolvers(); - const releaseFailure = Promise.withResolvers(); - const failureStarted = Promise.withResolvers(); - const order: string[] = []; - let skippedWorkCalls = 0; - - try { - await runtime.initialize(); - const webAuthn = webAuthnRunner(runtime); - const result = webAuthn( - async () => { - order.push("result-work"); - resultStarted.resolve(); - await releaseResult.promise; - return "verified"; - }, - { - onResultBeforeRelease: () => order.push("result-settled"), - timeoutMs: 5000, - } - ); - await resultStarted.promise; - const skipped = webAuthn( - () => { - skippedWorkCalls += 1; - return Promise.resolve("unexpected"); - }, - { - onBeforeStart: () => { - order.push("queued-recheck"); - return { proceed: false, value: "stale" }; - }, - onResultBeforeRelease: () => order.push("skipped-settled"), - timeoutMs: 5000, - } - ); - await yieldToWorkService(); - expect(order).toEqual(["result-work"]); - - releaseResult.resolve(); - expect(await result).toBe("verified"); - expect(await skipped).toBe("stale"); - expect(skippedWorkCalls).toBe(0); - expect(order).toEqual(["result-work", "result-settled", "queued-recheck"]); - - const failed = webAuthn( - async () => { - order.push("failure-work"); - failureStarted.resolve(); - await releaseFailure.promise; - throw new Error("verifier detail"); - }, - { - onFailureBeforeRelease: () => order.push("failure-settled"), - timeoutMs: 5000, - } - ); - await failureStarted.promise; - const afterFailure = webAuthn( - () => { - order.push("after-failure-start"); - return Promise.resolve("after"); - }, - { timeoutMs: 5000 } - ); - releaseFailure.resolve(); - expect(await captureFailure(() => failed)).toBeInstanceOf( - AuthenticationUpstreamUnavailableError - ); - expect(await afterFailure).toBe("after"); - expect(order.indexOf("failure-settled")).toBeLessThan( - order.indexOf("after-failure-start") - ); - } finally { - releaseResult.resolve(); - releaseFailure.resolve(); - await runtime.dispose(); - } - }); - - test("rejects invalid WebAuthn process work limits", () => { - expect(() => - createApplicationRuntime({ - authenticationWork: { webAuthnMaximumConcurrent: 0 }, - logger: testStructuredLogger, - realtimeEventPumpLayer: inertRealtimeLayer, - }) - ).toThrow("WebAuthn verification concurrency limit is invalid"); - expect(() => - createApplicationRuntime({ - authenticationWork: { webAuthnMaximumQueued: -1 }, - logger: testStructuredLogger, - realtimeEventPumpLayer: inertRealtimeLayer, - }) - ).toThrow("WebAuthn verification queue limit is invalid"); - expect(() => - createApplicationRuntime({ - authenticationWork: { webAuthnMaximumQueued: 1.5 }, - logger: testStructuredLogger, - realtimeEventPumpLayer: inertRealtimeLayer, - }) - ).toThrow(RangeError); - }); -}); diff --git a/src/server/domains/security/testSupport/authentication.ts b/src/server/domains/security/testSupport/authentication.ts deleted file mode 100644 index f3beb5f73..000000000 --- a/src/server/domains/security/testSupport/authentication.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { addDays, parseISO } from "date-fns"; -import * as v from "valibot"; - -import { authSessions } from "../../../database/schema/authSessions.ts"; -import { automationCredentials } from "../../../database/schema/automationCredentials.ts"; -import { automationPrincipalCapabilities } from "../../../database/schema/automationPrincipalCapabilities.ts"; -import { automationPrincipals } from "../../../database/schema/automationPrincipals.ts"; -import { users } from "../../../database/schema/users.ts"; -import { authSessionInsertSchema } from "../../../database/validation/authSessions.ts"; -import { automationCredentialInsertSchema } from "../../../database/validation/automationCredentials.ts"; -import { automationPrincipalCapabilityInsertSchema } from "../../../database/validation/automationPrincipalCapabilities.ts"; -import { automationPrincipalInsertSchema } from "../../../database/validation/automationPrincipals.ts"; -import { userInsertSchema } from "../../../database/validation/users.ts"; -import { generateOpaqueToken } from "../../../shared/opaqueToken.ts"; -import { openFreshMigratedDatabase } from "../../../test/support/freshDatabase.ts"; -import { testDashboardPasswordHash } from "../../../test/support/securityPassword.ts"; -import type { TotpSecretCipher } from "../mfa/totpSecretCipher.ts"; -import { createRequestAuthenticationRepository } from "../requestAuthenticationRepository.ts"; - -export const authenticationTestNow = parseISO("2026-08-05T01:00:00.000Z"); -export const authenticationTestUserId = "019fc968-1a9b-7770-8f1b-d5b863b0e7b4"; -export const authenticationTestCredentialId = "019fc968-1a9b-7771-9f1b-d5b863b0e7b4"; -export const authenticationTestPrincipalId = "openclaw-task-tracking"; - -/** Fail-closed cipher used by composition tests that do not exercise TOTP. */ -export const testTotpSecretCipher: TotpSecretCipher = Object.freeze({ - activeKeyId: "test-primary", - decrypt: () => Promise.reject(new Error("Test TOTP secret is unavailable")), - encrypt: () => Promise.reject(new Error("Test TOTP encryption is unavailable")), - hasKey: () => false, -}); - -/** - * Opens a fresh database containing one session and one automation credential. - * @param now Timestamp used for the persisted authentication records. - * @returns Fresh authentication fixture with its repository and generated tokens. - */ -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(); - - return { - automation, - database, - expiresAt, - repository: createRequestAuthenticationRepository(database.orm), - session, - }; - } catch (error) { - database.sqlite.close(true); - throw error; - } -} 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" - ] -}