diff --git a/.github/renovate.json5 b/.github/renovate.json5 index b9d57dd0dcb..f51ed2fda4e 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -210,7 +210,9 @@ }, // Allow internal Docker digest pins to automerge once the relevant - // CI checks have gone green. + // CI checks have gone green. Scoped to base images we control or + // pin purely for build reproducibility, so digest rotations don't + // change runtime behavior. { "description": "Automerge internal Docker digest updates after CI passes", "matchDatasources": [ @@ -218,7 +220,12 @@ ], "matchPackageNames": [ "ghost/traffic-analytics", - "tinybirdco/tinybird-local" + "tinybirdco/tinybird-local", + "python", + "ghcr.io/astral-sh/uv", + "axllent/mailpit", + "caddy", + "stripe/stripe-cli" ], "matchUpdateTypes": [ "digest" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08a39f3dbe5..661f02c6a23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,7 +154,7 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts - name: Determine Affected Projects id: affected @@ -302,10 +302,10 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --filter @tryghost/i18n... --ignore-scripts - name: Run i18n tests - run: pnpm nx run @tryghost/i18n:test + run: pnpm --filter @tryghost/i18n test job_admin-tests: runs-on: ubuntu-latest @@ -383,19 +383,9 @@ jobs: timezoneLinux: "America/New_York" - name: Run unit tests + # ghost/core's unit tests run on vitest (see ghost/core/vitest.config.ts); + # other packages run their own test:unit target. run: pnpm nx run-many -t test:unit -p "${{ needs.job_setup.outputs.affected_projects_str }}" - env: - FORCE_COLOR: 0 - GHOST_UNIT_TEST_VARIANT: ci - NX_SKIP_LOG_GROUPING: true - logging__level: fatal - - - name: Run vitest unit tests - # ghost/core is mid-migration from mocha to vitest. The vitest - # target covers a scoped subset (see ghost/core/vitest.config.ts) - # and runs additively alongside mocha — both runners run the same - # files during the migration as a divergence safety net. - run: pnpm nx run-many -t test:vitest -p "${{ needs.job_setup.outputs.affected_projects_str }}" env: FORCE_COLOR: 0 NX_SKIP_LOG_GROUPING: true @@ -410,12 +400,6 @@ jobs: exit 1 fi - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - if: matrix.node == env.NODE_VERSION - with: - name: unit-coverage - path: ghost/*/coverage/cobertura-coverage.xml - - uses: tryghost/actions/actions/slack-build@598d6328d89dbd796aa02ae2ea66308f9d942224 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: diff --git a/AGENTS.md b/AGENTS.md index d39c42a6232..6b35fa7646c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,8 @@ This file provides guidance to AI Agents when working with code in this reposito **Always use `pnpm` for all commands.** This repository uses pnpm workspaces, not npm. +Shared dependency versions are pinned in `pnpm-workspace.yaml` under `catalog:` and referenced as `"pkg": "catalog:"` (or `catalog:` for named catalogs). `catalogMode` is `strict`, so `pnpm add` routes new deps into the catalog automatically — don't inline the version. + ## Monorepo Structure Ghost is a pnpm + Nx monorepo with three workspace groups: @@ -57,10 +59,12 @@ pnpm build:clean # Clean build artifacts and rebuild ```bash # Unit tests (from root) pnpm test:unit # Run all unit tests in all packages +pnpm test:watch # Watch mode — unified Vitest watcher (ghost/core + all apps) # Ghost core tests (from ghost/core/) cd ghost/core -pnpm test:unit # Unit tests only +pnpm test:unit # Unit tests only (Vitest, run once) +pnpm test:watch # Watch mode — ghost/core unit tests only pnpm test:integration # Integration tests pnpm test:e2e # E2E API tests (not browser) pnpm test:all # All test types diff --git a/apps/activitypub/package.json b/apps/activitypub/package.json index 9d9bbce10ab..bb354b7eb2b 100644 --- a/apps/activitypub/package.json +++ b/apps/activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/activitypub", - "version": "3.1.26", + "version": "3.1.27", "license": "MIT", "repository": { "type": "git", @@ -48,7 +48,7 @@ "eslint-plugin-tailwindcss": "4.0.0-beta.0", "jest": "29.7.0", "jsdom": "catalog:", - "tailwindcss": "4.2.2", + "tailwindcss": "catalog:", "vite": "catalog:", "vitest": "catalog:" }, diff --git a/apps/admin-x-design-system/package.json b/apps/admin-x-design-system/package.json index 8f9de4eba27..ae7f0d8bfbd 100644 --- a/apps/admin-x-design-system/package.json +++ b/apps/admin-x-design-system/package.json @@ -31,7 +31,7 @@ "@storybook/addon-docs": "catalog:", "@storybook/addon-links": "catalog:", "@storybook/react-vite": "catalog:", - "@tailwindcss/postcss": "4.2.1", + "@tailwindcss/postcss": "catalog:", "@testing-library/react": "14.3.1", "@testing-library/react-hooks": "8.0.1", "@types/lodash-es": "4.17.12", @@ -56,7 +56,7 @@ "react-dom": "18.3.1", "sinon": "catalog:", "storybook": "catalog:", - "tailwindcss": "4.2.1", + "tailwindcss": "catalog:", "typescript": "catalog:", "validator": "catalog:", "vite": "catalog:", diff --git a/apps/admin-x-framework/src/api/security.ts b/apps/admin-x-framework/src/api/security.ts new file mode 100644 index 00000000000..4ac50908c3c --- /dev/null +++ b/apps/admin-x-framework/src/api/security.ts @@ -0,0 +1,14 @@ +import {createMutation} from '../utils/api/hooks'; + +export interface ResetAuthResponse { + security_action: Array<{ + action: 'reset_authentication'; + api_keys_rotated: number; + users_locked: number; + }>; +} + +export const useResetAuth = createMutation({ + method: 'POST', + path: () => '/authentication/reset/' +}); diff --git a/apps/admin-x-framework/src/test/vitest-config.ts b/apps/admin-x-framework/src/test/vitest-config.ts index 84ab02c0ac5..12256243ee5 100644 --- a/apps/admin-x-framework/src/test/vitest-config.ts +++ b/apps/admin-x-framework/src/test/vitest-config.ts @@ -5,6 +5,8 @@ import {defineConfig} from 'vitest/config'; export interface VitestConfigOptions { /** Custom setup files (defaults to ['./test/setup.ts']) */ setupFiles?: string[]; + /** Project root for resolving the @src/@test aliases (defaults to process.cwd()) */ + root?: string; /** Additional path aliases beyond the defaults (@src, @test) */ aliases?: Record; /** Test file patterns to include */ @@ -26,6 +28,9 @@ export interface VitestConfigOptions { export function createVitestConfig(options: VitestConfigOptions = {}) { const { setupFiles = ['./test/setup.ts'], + // process.cwd() is correct when an app runs its own tests; the unified + // root watcher passes an explicit root so aliases stay package-local. + root = process.cwd(), aliases = {}, include = [ './test/unit/**/*.{test,spec}.{js,ts,jsx,tsx}', @@ -68,8 +73,8 @@ export function createVitestConfig(options: VitestConfigOptions = {}) { }, resolve: { alias: { - '@src': path.resolve(process.cwd(), './src'), - '@test': path.resolve(process.cwd(), './test'), + '@src': path.resolve(root, './src'), + '@test': path.resolve(root, './test'), ...aliases } } diff --git a/apps/admin-x-settings/package.json b/apps/admin-x-settings/package.json index efd914cf879..e145f020b73 100644 --- a/apps/admin-x-settings/package.json +++ b/apps/admin-x-settings/package.json @@ -88,7 +88,7 @@ "eslint-plugin-react-refresh": "catalog:", "eslint-plugin-tailwindcss": "4.0.0-beta.0", "jsdom": "catalog:", - "tailwindcss": "4.2.2", + "tailwindcss": "catalog:", "vite": "catalog:", "vitest": "catalog:" }, diff --git a/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx b/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx index d29d0062a52..ca5389fa323 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx @@ -13,7 +13,7 @@ export const searchKeywords = { codeInjection: ['advanced', 'code injection', 'head', 'footer'], labs: ['advanced', 'labs', 'alpha', 'private', 'beta', 'flag', 'routes', 'redirect', 'translation', 'editor', 'portal'], history: ['advanced', 'history', 'log', 'events', 'user events', 'staff', 'audit', 'action'], - dangerzone: ['danger', 'danger zone', 'delete', 'content', 'delete all content', 'delete site'] + dangerzone: ['danger zone', 'delete all content', 'delete site', 'reset all authentication', 'reset api keys', 'reset password', 'compromised credentials', 'lock staff users', 'sign out all staff'] }; const AdvancedSettings: React.FC = () => { diff --git a/apps/admin-x-settings/src/components/settings/advanced/danger-zone.tsx b/apps/admin-x-settings/src/components/settings/advanced/danger-zone.tsx index 57d1f0360c0..4cdbb8d65cb 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/danger-zone.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/danger-zone.tsx @@ -1,15 +1,30 @@ import NiceModal from '@ebay/nice-modal-react'; import React from 'react'; import TopLevelGroup from '../../top-level-group'; -import {Button, ConfirmationModal, SettingGroupHeader, showToast, withErrorBoundary} from '@tryghost/admin-x-design-system'; +import useStaffUsers from '../../../hooks/use-staff-users'; +import {Button, ConfirmationModal, ListItem, SettingGroupHeader, showToast, withErrorBoundary} from '@tryghost/admin-x-design-system'; +import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useDeleteAllContent} from '@tryghost/admin-x-framework/api/db'; +import {useGlobalData} from '../../providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tryghost/admin-x-framework'; +import {useResetAuth} from '@tryghost/admin-x-framework/api/security'; const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { const {mutateAsync: deleteAllContent} = useDeleteAllContent(); + const {mutateAsync: resetAuth} = useResetAuth(); const client = useQueryClient(); const handleError = useHandleError(); + const {config} = useGlobalData(); + const {totalUsers} = useStaffUsers(); + + const resetAuthEnabled = Boolean(config?.labs?.dangerZoneResetAuth); + + const resetAuthStaffSentence = totalUsers === 1 + ? 'You will be signed out and must reset your password before signing back in.' + : totalUsers > 1 + ? `All ${totalUsers} staff users, including you, will be signed out and must reset their password before signing back in.` + : 'All staff users, including you, will be signed out and must reset their password before signing back in.'; const handleDeleteAllContent = () => { NiceModal.show(ConfirmationModal, { @@ -33,17 +48,67 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { }); }; + const handleResetAuth = () => { + NiceModal.show(ConfirmationModal, { + title: 'Reset all authentication?', + prompt: ( + <> +

+ This rotates every API key on your site. Any integration using one will stop working until you reconfigure it with the new key from Settings → Advanced → Integrations. +

+

+ {resetAuthStaffSentence} Your members aren't affected. +

+ + ), + okLabel: 'Reset all authentication', + okRunningLabel: 'Resetting...', + okColor: 'red', + onOk: async (modal) => { + try { + const response = await resetAuth(null); + const result = response?.security_action?.[0]; + const keys = result?.api_keys_rotated ?? 0; + const users = result?.users_locked ?? 0; + showToast({ + title: `Rotated ${keys} API ${keys === 1 ? 'key' : 'keys'} and locked ${users} ${users === 1 ? 'user' : 'users'}. You will be signed out shortly.`, + type: 'success' + }); + modal?.remove(); + window.location.href = getGhostPaths().adminRoot; + } catch (e) { + handleError(e); + } + } + }); + }; + return ( + } keywords={keywords} navid='dangerzone' testId='dangerzone' > -
-
); }; diff --git a/apps/admin-x-settings/src/components/sidebar.tsx b/apps/admin-x-settings/src/components/sidebar.tsx index 162d112e21e..6dc3665cea7 100644 --- a/apps/admin-x-settings/src/components/sidebar.tsx +++ b/apps/admin-x-settings/src/components/sidebar.tsx @@ -241,6 +241,7 @@ const Sidebar: React.FC = () => { + {!filter && diff --git a/apps/admin-x-settings/test/acceptance/advanced/dangerzone.test.ts b/apps/admin-x-settings/test/acceptance/advanced/dangerzone.test.ts index d80b20f02aa..218147da6f8 100644 --- a/apps/admin-x-settings/test/acceptance/advanced/dangerzone.test.ts +++ b/apps/admin-x-settings/test/acceptance/advanced/dangerzone.test.ts @@ -1,5 +1,5 @@ import {expect, test} from '@playwright/test'; -import {globalDataRequests, mockApi} from '@tryghost/admin-x-framework/test/acceptance'; +import {globalDataRequests, mockApi, toggleLabsFlag} from '@tryghost/admin-x-framework/test/acceptance'; test.describe('DangerZone', async () => { test('Delete all content', async ({page}) => { @@ -10,14 +10,39 @@ test.describe('DangerZone', async () => { await page.goto('/'); - const dangeZoneSection = page.getByTestId('dangerzone'); + const dangerZone = page.getByTestId('dangerzone'); + await dangerZone.getByRole('button', {name: 'Delete all content'}).click(); - await dangeZoneSection.getByRole('button', {name: 'Delete all content'}).click(); - - await page.getByTestId('confirmation-modal').getByRole('button', {name: 'Delete'}).click(); + const modal = page.getByTestId('confirmation-modal'); + await modal.getByRole('button', {name: 'Delete', exact: true}).click(); await expect(page.getByTestId('toast-success')).toContainText('All content deleted from database'); expect(lastApiRequests.deleteAllContent).toBeTruthy(); }); + + test('Reset all authentication', async ({page}) => { + toggleLabsFlag('dangerZoneResetAuth', true); + + const {lastApiRequests} = await mockApi({page, requests: { + ...globalDataRequests, + resetAuth: { + method: 'POST', + path: '/authentication/reset/', + response: { + security_action: [{action: 'reset_authentication', api_keys_rotated: 4, users_locked: 3}] + } + } + }}); + + await page.goto('/'); + + const dangerZone = page.getByTestId('dangerzone'); + await dangerZone.getByRole('button', {name: 'Reset all authentication'}).click(); + + const modal = page.getByTestId('confirmation-modal'); + await modal.getByRole('button', {name: 'Reset all authentication'}).click(); + + expect(lastApiRequests.resetAuth).toBeTruthy(); + }); }); diff --git a/apps/admin-x-settings/test/acceptance/membership/analytics.test.ts b/apps/admin-x-settings/test/acceptance/membership/analytics.test.ts index 621bf289fcd..980e9648fde 100644 --- a/apps/admin-x-settings/test/acceptance/membership/analytics.test.ts +++ b/apps/admin-x-settings/test/acceptance/membership/analytics.test.ts @@ -135,6 +135,45 @@ test.describe('Analytics settings', async () => { expect(hasDownloadUrl).toBe(true); }); + test('Disables post analytics export button and shows loading state while downloading', async ({page}) => { + await mockApi({page, requests: createMockApiConfig({})}); + + let exportRequestCount = 0; + let resolveExportRequest: (() => void) | undefined; + + await page.route(/\/ghost\/api\/admin\/posts\/export\/\?limit=1000$/, async (route) => { + exportRequestCount += 1; + await new Promise((resolve) => { + resolveExportRequest = resolve; + }); + await route.fulfill({ + status: 200, + body: 'csv data', + headers: { + 'content-type': 'text/csv' + } + }); + }); + + await page.goto('/'); + + const section = page.getByTestId('migrationtools'); + + await section.getByRole('tab', {name: 'Export'}).click(); + + const postAnalyticsButton = section.getByTestId('post-analytics-export-button'); + await postAnalyticsButton.click(); + + await expect.poll(() => exportRequestCount).toBe(1); + await expect(postAnalyticsButton).toBeDisabled(); + await expect(postAnalyticsButton).toContainText('Loading...'); + + resolveExportRequest?.(); + + await expect(postAnalyticsButton).toBeEnabled(); + expect(exportRequestCount).toBe(1); + }); + test('Supports read only settings', async ({page}) => { await mockApi({page, requests: { ...globalDataRequests, diff --git a/apps/admin-x-settings/vitest.config.ts b/apps/admin-x-settings/vitest.config.ts index 045e75ed592..2ea2b74782b 100644 --- a/apps/admin-x-settings/vitest.config.ts +++ b/apps/admin-x-settings/vitest.config.ts @@ -1,3 +1,3 @@ import {createVitestConfig} from '@tryghost/admin-x-framework/test/vitest-config'; -export default createVitestConfig(); \ No newline at end of file +export default createVitestConfig({root: __dirname}); \ No newline at end of file diff --git a/apps/admin/package.json b/apps/admin/package.json index a388bec60bf..f8d3aafe0fc 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -28,7 +28,7 @@ }, "devDependencies": { "@eslint/js": "catalog:eslint9", - "@tailwindcss/vite": "4.2.1", + "@tailwindcss/vite": "catalog:", "@tanstack/react-query": "catalog:", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "14.3.1", @@ -46,7 +46,7 @@ "jsdom": "catalog:", "msw": "catalog:", "sirv": "3.0.2", - "tailwindcss": "4.2.2", + "tailwindcss": "catalog:", "typescript": "catalog:", "typescript-eslint": "8.58.0", "vite": "catalog:", diff --git a/apps/comments-ui/package.json b/apps/comments-ui/package.json index d6404e16577..dd1fd78b062 100644 --- a/apps/comments-ui/package.json +++ b/apps/comments-ui/package.json @@ -79,7 +79,7 @@ "postcss": "catalog:", "postcss-import": "16.1.1", "sinon": "catalog:", - "tailwindcss": "3.4.18", + "tailwindcss": "catalog:tailwind3", "vite": "catalog:", "vite-plugin-svgr": "catalog:", "vitest": "catalog:" diff --git a/apps/posts/package.json b/apps/posts/package.json index e50b48f15ef..a3133f6a7fa 100644 --- a/apps/posts/package.json +++ b/apps/posts/package.json @@ -52,7 +52,7 @@ "eslint-plugin-tailwindcss": "4.0.0-beta.0", "jsdom": "catalog:", "msw": "catalog:", - "tailwindcss": "4.2.2", + "tailwindcss": "catalog:", "vite": "catalog:", "vitest": "catalog:" }, diff --git a/apps/posts/vitest.config.ts b/apps/posts/vitest.config.ts index bb9fe1bcf64..101e374458d 100644 --- a/apps/posts/vitest.config.ts +++ b/apps/posts/vitest.config.ts @@ -1,3 +1,3 @@ import {createVitestConfig} from '@tryghost/admin-x-framework/test/vitest-config'; -export default createVitestConfig(); +export default createVitestConfig({root: __dirname}); diff --git a/apps/shade/package.json b/apps/shade/package.json index 7ee2c0ba067..86c6de2b55c 100644 --- a/apps/shade/package.json +++ b/apps/shade/package.json @@ -85,7 +85,7 @@ "@storybook/addon-docs": "catalog:", "@storybook/addon-links": "catalog:", "@storybook/react-vite": "catalog:", - "@tailwindcss/postcss": "4.2.1", + "@tailwindcss/postcss": "catalog:", "@testing-library/react": "14.3.1", "@types/node": "catalog:", "@types/react-world-flags": "1.6.0", @@ -103,10 +103,10 @@ "postcss": "catalog:", "remark-gfm": "4.0.1", "storybook": "catalog:", - "tailwindcss": "4.2.1", + "tailwindcss": "catalog:", "tsc-alias": "1.8.17", "tsx": "4.21.0", - "tw-animate-css": "1.4.0", + "tw-animate-css": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-svgr": "catalog:", diff --git a/apps/signup-form/package.json b/apps/signup-form/package.json index 657b6e314c3..f1f04863f95 100644 --- a/apps/signup-form/package.json +++ b/apps/signup-form/package.json @@ -53,7 +53,7 @@ "postcss": "catalog:", "postcss-import": "16.1.1", "storybook": "catalog:", - "tailwindcss": "3.4.18", + "tailwindcss": "catalog:tailwind3", "vite": "catalog:", "vite-plugin-svgr": "catalog:", "vitest": "catalog:" diff --git a/apps/sodo-search/package.json b/apps/sodo-search/package.json index ac76efdc447..329189ee6a7 100644 --- a/apps/sodo-search/package.json +++ b/apps/sodo-search/package.json @@ -104,7 +104,7 @@ "eslint": "catalog:", "jsdom": "catalog:", "nock": "13.5.6", - "tailwindcss": "3.4.18", + "tailwindcss": "catalog:tailwind3", "vite": "catalog:", "vite-plugin-svgr": "catalog:", "vitest": "catalog:" diff --git a/apps/stats/package.json b/apps/stats/package.json index 42c24705978..71900501992 100644 --- a/apps/stats/package.json +++ b/apps/stats/package.json @@ -51,7 +51,7 @@ "eslint-plugin-react-refresh": "catalog:", "eslint-plugin-tailwindcss": "4.0.0-beta.0", "jsdom": "catalog:", - "tailwindcss": "4.2.2", + "tailwindcss": "catalog:", "vite": "catalog:", "vite-plugin-svgr": "catalog:", "vitest": "catalog:" diff --git a/apps/stats/vitest.config.ts b/apps/stats/vitest.config.ts index 5e00474fa6f..d7f5920075a 100644 --- a/apps/stats/vitest.config.ts +++ b/apps/stats/vitest.config.ts @@ -2,6 +2,7 @@ import {createVitestConfig} from '@tryghost/admin-x-framework/test/vitest-config import {resolve} from 'path'; export default createVitestConfig({ + root: __dirname, aliases: { '@src': resolve(__dirname, './src'), '@assets': resolve(__dirname, './src/assets'), diff --git a/e2e/helpers/pages/admin/settings/sections/danger-zone-section.ts b/e2e/helpers/pages/admin/settings/sections/danger-zone-section.ts new file mode 100644 index 00000000000..1fb2ead56d8 --- /dev/null +++ b/e2e/helpers/pages/admin/settings/sections/danger-zone-section.ts @@ -0,0 +1,38 @@ +import {BasePage} from '@/helpers/pages'; +import {Locator, Page} from '@playwright/test'; + +export class DangerZoneSection extends BasePage { + readonly section: Locator; + readonly heading: Locator; + + readonly deleteAllContentButton: Locator; + readonly resetAuthButton: Locator; + + readonly confirmationModal: Locator; + readonly deleteAllContentOkButton: Locator; + readonly resetAuthOkButton: Locator; + + constructor(page: Page) { + super(page, '/ghost/#/settings/advanced'); + + this.section = page.getByTestId('dangerzone'); + this.heading = page.getByRole('heading', {level: 5, name: 'Danger zone'}); + + this.deleteAllContentButton = this.section.getByRole('button', {name: 'Delete all content'}); + this.resetAuthButton = this.section.getByRole('button', {name: 'Reset all authentication'}); + + this.confirmationModal = page.getByTestId('confirmation-modal'); + this.deleteAllContentOkButton = this.confirmationModal.getByRole('button', {name: 'Delete', exact: true}); + this.resetAuthOkButton = this.confirmationModal.getByRole('button', {name: 'Reset all authentication'}); + } + + async openDeleteAllContentModal() { + await this.deleteAllContentButton.click(); + await this.confirmationModal.waitFor({state: 'visible'}); + } + + async openResetAuthModal() { + await this.resetAuthButton.click(); + await this.confirmationModal.waitFor({state: 'visible'}); + } +} diff --git a/e2e/helpers/pages/admin/settings/sections/index.ts b/e2e/helpers/pages/admin/settings/sections/index.ts index e62028107f9..adea5e46cdc 100644 --- a/e2e/helpers/pages/admin/settings/sections/index.ts +++ b/e2e/helpers/pages/admin/settings/sections/index.ts @@ -1,4 +1,5 @@ export {AnnouncementBarSection} from './announcement-bar-section'; +export {DangerZoneSection} from './danger-zone-section'; export {AccessSection} from './access-section'; export {PublicationSection} from './publications-section'; export {LabsSection} from './labs-section'; diff --git a/e2e/helpers/pages/admin/settings/settings-page.ts b/e2e/helpers/pages/admin/settings/settings-page.ts index 2f57cbc9580..bb2dd9cad91 100644 --- a/e2e/helpers/pages/admin/settings/settings-page.ts +++ b/e2e/helpers/pages/admin/settings/settings-page.ts @@ -1,5 +1,5 @@ import {BasePage} from '@/helpers/pages'; -import {IntegrationsSection, LabsSection, PortalSection, PublicationSection, TiersSection} from './sections'; +import {DangerZoneSection, IntegrationsSection, LabsSection, PortalSection, PublicationSection, TiersSection} from './sections'; import {Locator, Page} from '@playwright/test'; import {StaffSection} from './sections/staff-section'; @@ -13,6 +13,7 @@ export class SettingsPage extends BasePage { readonly labsSection: LabsSection; readonly staffSection: StaffSection; readonly tiersSection: TiersSection; + readonly dangerZoneSection: DangerZoneSection; readonly sidebar: Locator; readonly labsSidebarLink: Locator; @@ -34,6 +35,7 @@ export class SettingsPage extends BasePage { this.integrationsSection = new IntegrationsSection(page); this.staffSection = new StaffSection(page); this.tiersSection = new TiersSection(page); + this.dangerZoneSection = new DangerZoneSection(page); } async searchByInput(text: string) { diff --git a/e2e/tests/admin/settings/danger-zone.test.ts b/e2e/tests/admin/settings/danger-zone.test.ts new file mode 100644 index 00000000000..648b6c84aef --- /dev/null +++ b/e2e/tests/admin/settings/danger-zone.test.ts @@ -0,0 +1,62 @@ +import {SettingsPage} from '@/admin-pages'; +import {expect, test} from '@/helpers/playwright'; +import {usePerTestIsolation} from '@/helpers/playwright/isolation'; + +usePerTestIsolation(); + +interface ApiKey { + type: string; + secret: string; +} + +interface Integration { + slug: string; + api_keys: ApiKey[]; +} + +async function snapshotApiKeys(page: SettingsPage['page'], baseURL: string): Promise> { + const response = await page.request.get(`${baseURL}/ghost/api/admin/integrations/?include=api_keys`); + expect(response.ok(), 'integrations list returns 2xx').toBe(true); + const body = await response.json() as {integrations: Integration[]}; + return body.integrations + .flatMap(i => i.api_keys.map(k => ({slug: i.slug, type: k.type, secret: k.secret}))) + .sort((a, b) => `${a.slug}|${a.type}`.localeCompare(`${b.slug}|${b.type}`)); +} + +test.describe('Ghost Admin - Danger Zone security actions', () => { + test.use({labs: {dangerZoneResetAuth: true}}); + + test('reset all authentication - rotates every visible key, locks owner, kills session', async ({page, ghostAccountOwner, baseURL}) => { + const url = baseURL ?? ''; + const settingsPage = new SettingsPage(page); + await settingsPage.dangerZoneSection.goto(); + + const preKeys = await snapshotApiKeys(page, url); + expect(preKeys.length, 'integrations expose at least one key pre-rotation').toBeGreaterThan(0); + + const preContentKey = preKeys.find(k => k.type === 'content')?.secret; + expect(preContentKey, 'a content key exists pre-rotation').toBeTruthy(); + const preContentResponse = await page.request.get(`${url}/ghost/api/content/posts/?key=${preContentKey}&limit=1`); + expect(preContentResponse.status(), 'pre-rotation content key works').toBe(200); + + await settingsPage.dangerZoneSection.openResetAuthModal(); + await settingsPage.dangerZoneSection.resetAuthOkButton.click(); + + await page.waitForURL(/\/ghost\/(#\/)?signin\/?/, {timeout: 15000}); + + // Old credentials must now be rejected — the owner is locked. + const loginResponse = await page.request.post(`${url}/ghost/api/admin/session/`, { + // secretlint-disable-next-line @secretlint/secretlint-rule-pattern + data: {username: ghostAccountOwner.email, password: ghostAccountOwner.password}, + headers: {Origin: url} + }); + expect( + [401, 403, 422], + 'locked user login attempt is rejected' + ).toContain(loginResponse.status()); + + // Old content key must now be rejected — every api_keys.secret rotated. + const oldKeyResponse = await page.request.get(`${url}/ghost/api/content/posts/?key=${preContentKey}&limit=1`); + expect(oldKeyResponse.status(), 'pre-rotation content key is rejected').toBe(401); + }); +}); diff --git a/ghost/admin/app/components/gh-billing-iframe.js b/ghost/admin/app/components/gh-billing-iframe.js index 26228d5abd5..8e44549b149 100644 --- a/ghost/admin/app/components/gh-billing-iframe.js +++ b/ghost/admin/app/components/gh-billing-iframe.js @@ -37,34 +37,62 @@ export default class GhBillingIframe extends Component { return; } - // only process messages coming from the billing iframe - if (event?.data && this.billing.getIframeURL().includes(event?.origin)) { - this.billing.markBillingAppLoaded(); + if (!this.billing.isValidBillingIframeMessage(event)) { + return; + } - if (event.data?.request === 'token') { - this._handleTokenRequest(); - } + const data = event.data; - if (event.data?.request === 'forceUpgradeInfo') { - this._handleForceUpgradeRequest(); - } + if (data?.request === 'billingAppReady') { + this.billing.markBillingAppLoaded(data); - if (event.data?.subscription) { - await this._handleSubscriptionUpdate(event.data); + if (data?.route) { + this.billing.handleRouteChangeInIframe(data.route); } + + return; + } + + this.billing.recordBillingAppPreReadyMessage(data); + + if (data?.route) { + this.billing.handleRouteChangeInIframe(data.route); + } + + if (data?.request === 'token') { + this._handleTokenRequest(); + } + + if (data?.request === 'forceUpgradeInfo') { + this._handleForceUpgradeRequest(); + } + + if (data?.subscription) { + await this._handleSubscriptionUpdate(data); } } + _postMessageToBillingIframe(message) { + const billingIframeWindow = this.billing.getBillingIframe()?.contentWindow; + const billingAppOrigin = this.billing.getBillingAppOrigin(); + + if (!billingIframeWindow || !billingAppOrigin) { + return; + } + + billingIframeWindow.postMessage(message, billingAppOrigin); + } + _handleTokenRequest() { const handleNoPermission = () => { // no permission means the current user requesting the token is not the owner of the site. this.isOwner = false; // Avoid letting the BMA waiting for a message and send an empty token response instead - this.billing.getBillingIframe().contentWindow.postMessage({ + this._postMessageToBillingIframe({ request: 'token', response: null - }, '*'); + }); }; if (!this.session.user?.isOwnerOnly) { @@ -75,10 +103,10 @@ export default class GhBillingIframe extends Component { const ghostIdentityUrl = this.ghostPaths.url.api('identities'); this.ajax.request(ghostIdentityUrl).then((response) => { const token = response?.identities?.[0]?.token; - this.billing.getBillingIframe().contentWindow.postMessage({ + this._postMessageToBillingIframe({ request: 'token', response: token - }, '*'); + }); this.isOwner = true; }).catch((error) => { @@ -101,14 +129,14 @@ export default class GhBillingIframe extends Component { email: owner?.email }; } - this.billing.getBillingIframe().contentWindow.postMessage({ + this._postMessageToBillingIframe({ request: 'forceUpgradeInfo', response: { forceUpgrade: this.config.hostSettings?.forceUpgrade, isOwner: this.isOwner, ownerUser } - }, '*'); + }); } async _handleSubscriptionUpdate(data) { diff --git a/ghost/admin/app/services/billing.js b/ghost/admin/app/services/billing.js index 42c7d29e652..372dd800250 100644 --- a/ghost/admin/app/services/billing.js +++ b/ghost/admin/app/services/billing.js @@ -24,6 +24,11 @@ export default class BillingService extends Service { @tracked billingAppLoaded = false; @tracked billingAppLoadFailureReported = false; + @tracked billingAppPreReadyMessageCount = 0; + @tracked billingAppPreReadyMessageTypes = []; + @tracked billingAppLastPreReadyMessageType = null; + @tracked billingAppReadyReceivedAt = null; + @tracked billingAppReadyPayload = null; billingAppLoadTimeout = null; billingAppRetryTimeout = null; @@ -35,18 +40,6 @@ export default class BillingService extends Service { _loadListenerAttachedTo = null; - constructor() { - super(...arguments); - - if (this.config.hostSettings?.billing?.url) { - window.addEventListener('message', (event) => { - if (event && event.data && event.data.route) { - this.handleRouteChangeInIframe(event.data.route); - } - }); - } - } - willDestroy() { super.willDestroy(...arguments); this.clearBillingAppLoadMonitor(); @@ -79,6 +72,7 @@ export default class BillingService extends Service { this.billingAppLoaded = false; this.billingAppLoadAttempts = 0; this.billingAppLoadFailureReported = false; + this.resetBillingAppLoadDiagnostics(); } this.billingAppLoadAttempts += 1; @@ -116,6 +110,7 @@ export default class BillingService extends Service { } this.billingAppIframeLoadFired = false; this.billingAppIframeSrcSetAt = Date.now(); + this.resetBillingAppLoadDiagnostics(); iframe.src = this.getIframeURL(); } @@ -129,11 +124,102 @@ export default class BillingService extends Service { this.setBillingIframeSrc(); } - markBillingAppLoaded() { + markBillingAppLoaded(payload = null) { this.billingAppLoaded = true; + this.billingAppLoadFailureReported = false; + this.billingAppReadyReceivedAt = Date.now(); + this.billingAppReadyPayload = payload; this.clearBillingAppLoadMonitor(); } + resetBillingAppLoadDiagnostics() { + this.billingAppPreReadyMessageCount = 0; + this.billingAppPreReadyMessageTypes = []; + this.billingAppLastPreReadyMessageType = null; + this.billingAppReadyReceivedAt = null; + this.billingAppReadyPayload = null; + } + + recordBillingAppPreReadyMessage(data) { + if (this.billingAppLoaded || this.billingAppReadyReceivedAt || this.billingAppLoadFailureReported) { + return; + } + + if (!this.billingAppLoadTimeout && !this.billingAppRetryTimeout) { + return; + } + + const messageType = this.getBillingAppMessageType(data); + + this.billingAppPreReadyMessageCount += 1; + this.billingAppLastPreReadyMessageType = messageType; + + if (!this.billingAppPreReadyMessageTypes.includes(messageType)) { + this.billingAppPreReadyMessageTypes = [ + ...this.billingAppPreReadyMessageTypes, + messageType + ]; + } + } + + getBillingAppMessageType(data) { + if (data?.request) { + return data.request; + } + + if (data?.route) { + return 'route'; + } + + if (data?.subscription) { + return 'subscription'; + } + + if (data?.query) { + return data.query; + } + + return 'unknown'; + } + + getBillingAppOrigin() { + const iframeURL = this.getIframeURL({fetchOwner: false}); + + if (!iframeURL) { + return null; + } + + try { + return new URL(iframeURL).origin; + } catch (e) { + return null; + } + } + + isValidBillingIframeMessage(event) { + if (!event?.data) { + return false; + } + + const billingAppOrigin = this.getBillingAppOrigin(); + + if (!billingAppOrigin || event.origin !== billingAppOrigin) { + return false; + } + + const billingIframeWindow = this.getBillingIframe()?.contentWindow; + + if (!billingIframeWindow) { + return false; + } + + if (event.source !== billingIframeWindow) { + return false; + } + + return true; + } + clearBillingAppLoadMonitor() { if (this.billingAppLoadTimeout) { clearTimeout(this.billingAppLoadTimeout); @@ -159,6 +245,8 @@ export default class BillingService extends Service { const iframe = this.getBillingIframe(); const visibilityState = document.visibilityState; + const iframeSrc = iframe?.src || null; + const configuredBillingOrigin = this.getBillingAppOrigin(); // Fields are kept flat on `billing_monitor` because Sentry's default // `normalizeDepth` of 3 stringifies anything deeper to '[Object]', @@ -203,6 +291,8 @@ export default class BillingService extends Service { is_force_upgrade: !!this.config.hostSettings?.forceUpgrade, location_hash: window.location.hash, retry_delays_ms: this.billingAppLoadRetryDelaysMs, + iframe_src: iframeSrc, + configured_billing_origin: configuredBillingOrigin, document_visibility_state: visibilityState, iframe_offset_parent_visible: iframe ? iframe.offsetParent !== null : null, iframe_computed_display: computedDisplay, @@ -211,6 +301,10 @@ export default class BillingService extends Service { iframe_rect_height: rectHeight, iframe_load_fired: this.billingAppIframeLoadFired, ms_since_src_set: this.billingAppIframeSrcSetAt ? Date.now() - this.billingAppIframeSrcSetAt : null, + non_ready_message_count: this.billingAppPreReadyMessageCount, + non_ready_message_types: this.billingAppPreReadyMessageTypes.join(','), + last_non_ready_message_type: this.billingAppLastPreReadyMessageType, + ready_received: false, navigator_online: navigator.onLine, connection_effective_type: navigator.connection?.effectiveType ?? null, bma_boot_accessible: bmaBootAccessible, @@ -228,9 +322,13 @@ export default class BillingService extends Service { }); } - getIframeURL() { + getIframeURL(options = {}) { + const {fetchOwner = true} = options; + // initiate getting owner user in the background - this.getOwnerUser(); + if (fetchOwner) { + this.getOwnerUser(); + } let url = this.config.hostSettings?.billing?.url; diff --git a/ghost/admin/package.json b/ghost/admin/package.json index b5f105c13ec..d709d81dc05 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "6.40.1-rc.0", + "version": "6.41.1-rc.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", diff --git a/ghost/admin/tests/integration/components/gh-billing-iframe-test.js b/ghost/admin/tests/integration/components/gh-billing-iframe-test.js new file mode 100644 index 00000000000..055c3004215 --- /dev/null +++ b/ghost/admin/tests/integration/components/gh-billing-iframe-test.js @@ -0,0 +1,130 @@ +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {find, render, settled} from '@ember/test-helpers'; +import {setupRenderingTest} from 'ember-mocha'; + +describe('Integration: Component: gh-billing-iframe', function () { + setupRenderingTest(); + + let billing; + + async function postBillingMessage(data, options = {}) { + const iframe = find('#billing-frame'); + + window.dispatchEvent(new MessageEvent('message', { + data, + origin: options.origin ?? 'https://billing.example.test', + source: options.source ?? iframe.contentWindow + })); + + await settled(); + } + + beforeEach(function () { + billing = this.owner.lookup('service:billing'); + + sinon.stub(billing, 'getIframeURL').returns('https://billing.example.test/pro'); + sinon.stub(billing, 'startBillingAppLoadMonitor'); + }); + + afterEach(function () { + billing.clearBillingAppLoadMonitor(); + sinon.restore(); + }); + + it('marks the billing app loaded after billingAppReady from the validated iframe', async function () { + const markBillingAppLoaded = sinon.spy(billing, 'markBillingAppLoaded'); + + await render(hbs``); + + await postBillingMessage({ + request: 'billingAppReady', + route: '/plans', + state: 'content', + release: 'test', + timestamp: Date.now() + }); + + expect(markBillingAppLoaded.calledOnce).to.be.true; + expect(markBillingAppLoaded.firstCall.args[0]).to.include({ + request: 'billingAppReady', + route: '/plans', + state: 'content', + release: 'test' + }); + expect(billing.billingAppLoaded).to.be.true; + }); + + it('handles valid non-ready token messages without marking the billing app loaded', async function () { + const markBillingAppLoaded = sinon.spy(billing, 'markBillingAppLoaded'); + + await render(hbs``); + + const iframe = find('#billing-frame'); + const postMessage = sinon.stub(iframe.contentWindow, 'postMessage'); + + await postBillingMessage({request: 'token'}); + + expect(markBillingAppLoaded.called).to.be.false; + expect(billing.billingAppLoaded).to.be.false; + expect(postMessage.calledOnceWithExactly({ + request: 'token', + response: null + }, 'https://billing.example.test')).to.be.true; + }); + + it('handles valid route messages without marking the billing app loaded', async function () { + const markBillingAppLoaded = sinon.spy(billing, 'markBillingAppLoaded'); + const handleRouteChangeInIframe = sinon.spy(billing, 'handleRouteChangeInIframe'); + + await render(hbs``); + + await postBillingMessage({route: '/plans'}); + + expect(markBillingAppLoaded.called).to.be.false; + expect(handleRouteChangeInIframe.calledOnceWithExactly('/plans')).to.be.true; + expect(billing.billingAppLoaded).to.be.false; + }); + + it('ignores messages from an invalid origin', async function () { + const markBillingAppLoaded = sinon.spy(billing, 'markBillingAppLoaded'); + + await render(hbs``); + + const iframe = find('#billing-frame'); + const postMessage = sinon.stub(iframe.contentWindow, 'postMessage'); + + await postBillingMessage({request: 'token'}, {origin: 'https://evil.example.test'}); + await postBillingMessage({request: 'billingAppReady'}, {origin: 'https://evil.example.test'}); + + expect(markBillingAppLoaded.called).to.be.false; + expect(postMessage.called).to.be.false; + expect(billing.billingAppLoaded).to.be.false; + }); + + it('ignores messages from the wrong source window', async function () { + const markBillingAppLoaded = sinon.spy(billing, 'markBillingAppLoaded'); + + await render(hbs``); + + await postBillingMessage({request: 'billingAppReady'}, {source: window}); + + expect(markBillingAppLoaded.called).to.be.false; + expect(billing.billingAppLoaded).to.be.false; + }); + + it('ignores messages when the billing iframe window is unavailable', async function () { + const markBillingAppLoaded = sinon.spy(billing, 'markBillingAppLoaded'); + + await render(hbs``); + + sinon.stub(billing, 'getBillingIframe').returns(null); + + await postBillingMessage({request: 'billingAppReady'}, {source: window}); + + expect(markBillingAppLoaded.called).to.be.false; + expect(billing.billingAppLoaded).to.be.false; + }); +}); diff --git a/ghost/admin/tests/integration/components/gh-billing-modal-test.js b/ghost/admin/tests/integration/components/gh-billing-modal-test.js index 28bac3830e4..4528900044d 100644 --- a/ghost/admin/tests/integration/components/gh-billing-modal-test.js +++ b/ghost/admin/tests/integration/components/gh-billing-modal-test.js @@ -9,14 +9,36 @@ describe('Integration: Component: gh-billing-modal', function () { setupRenderingTest(); let billing; + let configManager; + let limit; + let stateBridge; + + async function postBillingMessage(data) { + const iframe = find('#billing-frame'); + + window.dispatchEvent(new MessageEvent('message', { + data, + origin: 'https://billing.example.test', + source: iframe.contentWindow + })); + + await settled(); + } beforeEach(function () { billing = this.owner.lookup('service:billing'); + configManager = this.owner.lookup('service:config-manager'); + limit = this.owner.lookup('service:limit'); + stateBridge = this.owner.lookup('service:state-bridge'); + billing.billingAppLoaded = false; billing.billingAppLoadFailureReported = false; sinon.stub(billing, 'getIframeURL').returns('https://billing.example.test'); sinon.stub(billing, 'startBillingAppLoadMonitor'); + sinon.stub(configManager, 'fetch').resolves(); + sinon.stub(limit, 'reload'); + sinon.stub(stateBridge, 'triggerSubscriptionChange'); }); afterEach(function () { @@ -24,19 +46,50 @@ describe('Integration: Component: gh-billing-modal', function () { sinon.restore(); }); - it('shows a loading state until the billing app sends its first message', async function () { + it('shows a loading state until the billing app sends billingAppReady', async function () { await render(hbs``); expect(find('[data-test-billing-loading]')).to.exist; expect(find('[data-test-billing-load-error]')).to.not.exist; - billing.markBillingAppLoaded(); - await settled(); + await postBillingMessage({ + request: 'billingAppReady', + route: '/plans', + state: 'loading', + release: 'test', + timestamp: Date.now() + }); expect(find('[data-test-billing-loading]')).to.not.exist; expect(find('[data-test-billing-load-error]')).to.not.exist; }); + it('keeps the loading state for valid non-ready billing app messages', async function () { + await render(hbs``); + + await postBillingMessage({request: 'token'}); + expect(find('[data-test-billing-loading]')).to.exist; + + await postBillingMessage({request: 'forceUpgradeInfo'}); + expect(find('[data-test-billing-loading]')).to.exist; + + await postBillingMessage({route: '/plans'}); + expect(find('[data-test-billing-loading]')).to.exist; + + await postBillingMessage({ + subscription: { + status: 'active' + } + }); + expect(find('[data-test-billing-loading]')).to.exist; + + await postBillingMessage({ + request: 'billingAppReady', + state: 'content' + }); + expect(find('[data-test-billing-loading]')).to.not.exist; + }); + it('shows a customer-facing error when the billing app does not become ready', async function () { billing.billingAppLoadFailureReported = true; diff --git a/ghost/admin/tests/unit/services/billing-test.js b/ghost/admin/tests/unit/services/billing-test.js index d7aba5025e5..e9ddd445111 100644 --- a/ghost/admin/tests/unit/services/billing-test.js +++ b/ghost/admin/tests/unit/services/billing-test.js @@ -129,8 +129,14 @@ describe('Unit: Service: billing', function () { attempts: 2, has_billing_url: true, is_force_upgrade: false, + iframe_src: null, + configured_billing_origin: 'https://billing.example.test', iframe_load_fired: true, billing_window_open: true, + non_ready_message_count: 0, + non_ready_message_types: '', + last_non_ready_message_type: null, + ready_received: false, navigator_online: navigator.onLine, document_visibility_state: document.visibilityState, bma_boot_accessible: false, @@ -140,6 +146,70 @@ describe('Unit: Service: billing', function () { expect(billingMonitor.ms_since_src_set).to.be.a('number').and.to.be.at.least(1234); }); + it('reports pre-ready message diagnostics to Sentry', async function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + sinon.stub(service, 'getBillingIframe').returns(null); + service.billingAppLoadAttempts = 2; + service.billingAppLoadTimeout = setTimeout(() => {}, 10000); + service.billingAppIframeSrcSetAt = Date.now() - 100; + + service.recordBillingAppPreReadyMessage({request: 'token'}); + service.recordBillingAppPreReadyMessage({route: '/plans'}); + service.recordBillingAppPreReadyMessage({ + subscription: { + status: 'active' + } + }); + service.reportBillingAppLoadFailure(); + + await waitUntil(() => testkit.reports().length > 0); + + const billingMonitor = testkit.reports()[0].originalReport.contexts.ghost.billing_monitor; + expect(billingMonitor).to.deep.include({ + non_ready_message_count: 3, + non_ready_message_types: 'token,route,subscription', + last_non_ready_message_type: 'subscription', + ready_received: false + }); + }); + + it('resets billing app load diagnostics when a fresh iframe load starts', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const iframe = {src: '', addEventListener: sinon.stub()}; + sinon.stub(service, 'getBillingIframe').returns(iframe); + + service.billingAppLoadTimeout = setTimeout(() => {}, 10000); + service.recordBillingAppPreReadyMessage({request: 'token'}); + service.markBillingAppLoaded({request: 'billingAppReady'}); + + service.setBillingIframeSrc(); + + expect(service.billingAppPreReadyMessageCount).to.equal(0); + expect(service.billingAppPreReadyMessageTypes).to.deep.equal([]); + expect(service.billingAppLastPreReadyMessageType).to.be.null; + expect(service.billingAppReadyReceivedAt).to.be.null; + expect(service.billingAppReadyPayload).to.be.null; + }); + + it('treats late billingAppReady as recovery after a reported load failure', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const readyPayload = { + request: 'billingAppReady', + state: 'content' + }; + service.billingAppLoadFailureReported = true; + + service.markBillingAppLoaded(readyPayload); + + expect(service.billingAppLoaded).to.be.true; + expect(service.billingAppLoadFailureReported).to.be.false; + expect(service.billingAppReadyReceivedAt).to.be.a('number'); + expect(service.billingAppReadyPayload).to.equal(readyPayload); + }); + it('does not report when the billing app becomes ready before the timeout', function () { const service = this.owner.lookup('service:billing'); billingService = service; diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index 1df4b7130a5..7b1d752a60e 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -322,7 +322,7 @@ async function initServices() { const indexnow = require('./server/services/indexnow'); const slack = require('./server/services/slack'); const webhooks = require('./server/services/webhooks'); - const postScheduling = require('./server/services/post-scheduling'); + const postScheduling = require('./server/services/post-scheduling').default; const comments = require('./server/services/comments'); const staffService = require('./server/services/staff'); const memberAttribution = require('./server/services/member-attribution'); @@ -344,7 +344,7 @@ async function initServices() { const statsService = require('./server/services/stats'); const explorePingService = require('./server/services/explore-ping'); const domainEvents = require('@tryghost/domain-events'); - const AutomationsService = require('./server/services/automations'); + const automations = require('./server/services/automations'); const {createAdapter: createSchedulerAdapter} = require('./server/adapters/scheduling/utils'); const urlUtils = require('./shared/url-utils'); @@ -354,6 +354,7 @@ async function initServices() { emailAddressService.init(); const apiUrl = urlUtils.urlFor('api', {type: 'admin'}, true); const schedulerAdapter = createSchedulerAdapter(); + schedulerAdapter.run(); await stripe.init(); await Promise.all([ @@ -373,11 +374,6 @@ async function initServices() { emailService.init(), emailAnalytics.init(), webhooks.listen(), - postScheduling.init({ - apiUrl, - adapter: schedulerAdapter, - internalKeys - }), comments.init(), linkTracking.init(), emailSuppressionList.init(), @@ -392,7 +388,7 @@ async function initServices() { schedulerAdapter, internalKeys }), - new AutomationsService().init({ + automations.init({ domainEvents, apiUrl, schedulerAdapter, @@ -400,6 +396,10 @@ async function initServices() { }) ]); + if (schedulerAdapter.rescheduleOnBoot) { + await postScheduling.rescheduleAll(); + } + debug('End: Services'); debug('End: initServices'); diff --git a/ghost/core/core/server/adapters/scheduling/scheduling-base.js b/ghost/core/core/server/adapters/scheduling/scheduling-base.js index 15d401a7d87..45409cba642 100644 --- a/ghost/core/core/server/adapters/scheduling/scheduling-base.js +++ b/ghost/core/core/server/adapters/scheduling/scheduling-base.js @@ -1,8 +1,49 @@ +const logging = require('@tryghost/logging'); + function SchedulingBase() { Object.defineProperty(this, 'requiredFns', { value: ['schedule', 'unschedule', 'run'], writable: false }); + this._reschedulers = new Set(); } +/** + * Register a subsystem that can rebuild its scheduler queue on demand. + * + * Scheduler-users (post-scheduling, automations, gifts) call this at + * construction so the adapter knows who to ask when queued URLs need + * regenerating (e.g. after internal API key rotation). The convention is + * that a registered rescheduler exposes a `rescheduleAll(opts)` method. + * + * @param {{rescheduleAll: (opts: {previousKey?: {id: string; secret: string}}) => Promise}} rescheduler + */ +SchedulingBase.prototype.register = function (rescheduler) { + this._reschedulers.add(rescheduler); +}; + +/** + * Ask every registered rescheduler to rebuild its queue under the current + * key. Best-effort: a failure in one doesn't block the others. + * + * @param {{previousKey?: {id: string; secret: string}}} [opts] + * @returns {Promise[]>} + */ +SchedulingBase.prototype.rescheduleAll = async function (opts = {}) { + const reschedulers = Array.from(this._reschedulers); + const results = await Promise.allSettled( + reschedulers.map(r => r.rescheduleAll(opts)) + ); + results.forEach((result, i) => { + if (result.status === 'rejected') { + logging.error({ + event: {name: 'scheduler.reschedule_all.failed'}, + err: result.reason, + rescheduler: reschedulers[i].constructor?.name ?? 'unknown' + }, 'Rescheduler failed'); + } + }); + return results; +}; + module.exports = SchedulingBase; diff --git a/ghost/core/core/server/adapters/scheduling/types.ts b/ghost/core/core/server/adapters/scheduling/types.ts new file mode 100644 index 00000000000..e3f46057996 --- /dev/null +++ b/ghost/core/core/server/adapters/scheduling/types.ts @@ -0,0 +1,36 @@ +/** + * Shape of a job the scheduler adapter queues. Time is a unix timestamp; + * url carries a JWT-signed admin token; extra forwards through to the + * HTTP callback the adapter fires. + */ +export interface SchedulerJob { + time: number; + url: string; + extra: { + httpMethod: string; + oldTime?: number | null; + }; +} + +/** + * Implemented by scheduler-using subsystems (post-scheduling, automations, + * gift reminders). The adapter calls `rescheduleAll` on every registered + * rescheduler when something requires the queue to be rebuilt — currently + * after an internal API key rotation. + */ +export interface Rescheduler { + rescheduleAll(opts?: {previousKey?: {id: string; secret: string}}): Promise; +} + +/** + * The contract Ghost expects of any concrete scheduling adapter. Adapters + * that extend `SchedulingBase` inherit `register` and `rescheduleAll` for + * free; the three runtime methods (`schedule`, `unschedule`, `run`) are + * the ones each adapter implementation must provide. + */ +export interface SchedulerAdapter { + run(): void; + schedule(job: SchedulerJob): void; + unschedule(job: SchedulerJob, opts?: {bootstrap?: boolean}): void; + register(rescheduler: Rescheduler): void; +} diff --git a/ghost/core/core/server/api/endpoints/authentication.js b/ghost/core/core/server/api/endpoints/authentication.js index 003a958ebde..43582cd1094 100644 --- a/ghost/core/core/server/api/endpoints/authentication.js +++ b/ghost/core/core/server/api/endpoints/authentication.js @@ -12,7 +12,17 @@ const apiMail = require('./index').mail; const apiSettings = require('./index').settings; const UsersService = require('../../services/users'); const userService = new UsersService({dbBackup, models, auth, apiMail, apiSettings}); -const {deleteAllSessions} = require('../../services/auth/session'); +const adapterManager = require('../../services/adapter-manager'); +const schedulerAdapter = adapterManager.getAdapter('scheduling'); + +async function destroyRequestSession(req) { + if (!req || !req.session) { + return; + } + await new Promise((resolve, reject) => { + req.session.destroy(err => (err ? reject(err) : resolve())); + }); +} const messages = { notTheBlogOwner: 'You are not the site owner.' @@ -224,15 +234,26 @@ const controller = { } }, - resetAllPasswords: { - statusCode: 204, + reset: { + statusCode: 200, headers: { cacheInvalidate: false }, permissions: true, async query(frame) { - await userService.resetAllPasswords(frame.options); - await deleteAllSessions(); + const result = await auth.resetAuthentication({ + schedulerAdapter, + userService, + options: frame.options + }); + + // Express-session would otherwise re-save the current request's + // session on the response, resurrecting the row deleteAllSessions + // just wiped. Destroying the request session prevents the re-save + // and emits a Set-Cookie that expires the cookie on the client. + await destroyRequestSession(frame.original.session && frame.original.session.req); + + return result; } } }; diff --git a/ghost/core/core/server/api/endpoints/schedules.js b/ghost/core/core/server/api/endpoints/schedules.js index 8591f772e3d..03b4991b03f 100644 --- a/ghost/core/core/server/api/endpoints/schedules.js +++ b/ghost/core/core/server/api/endpoints/schedules.js @@ -1,4 +1,3 @@ -const models = require('../../models'); const postScheduling = require('../../services/posts/post-scheduling'); /** @type {import('@tryghost/api-framework').Controller} */ @@ -52,35 +51,6 @@ const controller = { response[resourceType] = [scheduledResource]; return response; } - }, - - getScheduled: { - // NOTE: this method is for internal use only by DefaultScheduler - // it is not exposed anywhere! - headers: { - cacheInvalidate: false - }, - permissions: false, - validation: { - options: { - resource: { - required: true, - values: ['posts', 'pages'] - } - } - }, - async query(frame) { - const resourceModel = 'Post'; - const resourceType = (frame.options.resource === 'post') ? 'post' : 'page'; - const cleanOptions = {}; - cleanOptions.filter = `status:scheduled+type:${resourceType}`; - cleanOptions.columns = ['id', 'published_at', 'created_at', 'type']; - - const result = await models[resourceModel].findAll(cleanOptions); - let response = {}; - response[resourceType] = result; - return response; - } } }; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/authentication.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/authentication.js index 1b819e6238b..db71ef3a6d7 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/authentication.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/authentication.js @@ -71,5 +71,15 @@ module.exports = { valid: !!data }] }; + }, + + reset(data, apiConfig, frame) { + frame.response = { + security_action: [{ + action: 'reset_authentication', + api_keys_rotated: data?.apiKeysRotated ?? 0, + users_locked: data?.usersLocked ?? 0 + }] + }; } }; diff --git a/ghost/core/core/server/data/migrations/versions/6.41/2026-05-13-12-00-00-rename-reset-all-passwords-permission.js b/ghost/core/core/server/data/migrations/versions/6.41/2026-05-13-12-00-00-rename-reset-all-passwords-permission.js new file mode 100644 index 00000000000..5aeadb7824b --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.41/2026-05-13-12-00-00-rename-reset-all-passwords-permission.js @@ -0,0 +1,21 @@ +const {createTransactionalMigration} = require('../../utils'); + +// The `resetAllPasswords` action on the `authentication` object backed the +// orphaned `/authentication/global_password_reset` endpoint. That endpoint is +// replaced by `/authentication/reset`, which rotates every credential +// (api keys, passwords, sessions) in one shot — so we rename the existing +// permission row to match its new contract rather than introduce a fresh row +// alongside a stale one. + +module.exports = createTransactionalMigration( + async function up(knex) { + await knex('permissions') + .where({action_type: 'resetAllPasswords', object_type: 'authentication'}) + .update({name: 'Reset authentication', action_type: 'reset'}); + }, + async function down(knex) { + await knex('permissions') + .where({action_type: 'reset', object_type: 'authentication'}) + .update({name: 'Reset all passwords', action_type: 'resetAllPasswords'}); + } +); diff --git a/ghost/core/core/server/data/schema/fixtures/fixtures.json b/ghost/core/core/server/data/schema/fixtures/fixtures.json index 1b119b99f34..95f2c79b7bc 100644 --- a/ghost/core/core/server/data/schema/fixtures/fixtures.json +++ b/ghost/core/core/server/data/schema/fixtures/fixtures.json @@ -630,8 +630,8 @@ "object_type": "offer" }, { - "name": "Reset all passwords", - "action_type": "resetAllPasswords", + "name": "Reset authentication", + "action_type": "reset", "object_type": "authentication" }, { @@ -957,7 +957,7 @@ "snippet": "all", "custom_theme_setting": "all", "offer": "all", - "authentication": "resetAllPasswords", + "authentication": "reset", "members_stripe_connect": "auth", "newsletter": "all", "explore": "read", diff --git a/ghost/core/core/server/models/api-key.js b/ghost/core/core/server/models/api-key.js index 90f1b2725f0..e16c11674b9 100644 --- a/ghost/core/core/server/models/api-key.js +++ b/ghost/core/core/server/models/api-key.js @@ -3,6 +3,7 @@ const security = require('@tryghost/security'); const ghostBookshelf = require('./base'); const {Role} = require('./role'); +// secretlint-disable-next-line @secretlint/secretlint-rule-pattern const ApiKey = ghostBookshelf.Model.extend({ tableName: 'api_keys', @@ -61,6 +62,22 @@ const ApiKey = ghostBookshelf.Model.extend({ refreshSecret(data, options) { const secret = security.secret.create(data.type); return this.edit(Object.assign({}, data, {secret}), options); + }, + + /** + * Refresh the secret on every API key row, returning the count rotated. + * Used by the danger-zone reset flow; callers are expected to wrap this + * in a transaction. + * + * @param {Object} options + * @returns {Promise<{count: number}>} + */ + async refreshAllSecrets(options) { + const apiKeys = await this.findAll(options); + for (const apiKey of apiKeys.models) { + await this.refreshSecret(apiKey.toJSON(), Object.assign({}, options, {id: apiKey.id})); + } + return {count: apiKeys.length}; } }); @@ -69,6 +86,7 @@ const ApiKeys = ghostBookshelf.Collection.extend({ }); module.exports = { + // secretlint-disable-next-line @secretlint/secretlint-rule-pattern ApiKey: ghostBookshelf.model('ApiKey', ApiKey), ApiKeys: ghostBookshelf.collection('ApiKeys', ApiKeys) }; diff --git a/ghost/core/core/server/models/user.js b/ghost/core/core/server/models/user.js index 848ac2b512e..e6491c40980 100644 --- a/ghost/core/core/server/models/user.js +++ b/ghost/core/core/server/models/user.js @@ -174,6 +174,24 @@ User = ghostBookshelf.Model.extend({ return this.get('status') === 'locked'; }, + /** + * Replace this user's password with an opaque random value, and mark + * them as locked unless they are already inactive (suspended). Suspended + * users must not be transitioned out of `inactive` — they retain the + * suspended-signin path — but their password is still rotated so a + * compromised credential cannot survive a future unsuspend. + */ + lock: function lock(options) { + const update = { + // secretlint-disable-next-line @secretlint/secretlint-rule-pattern + password: security.identifier.uid(50) + }; + if (this.get('status') !== 'inactive') { + update.status = 'locked'; + } + return this.save(update, {...options, patch: true}); + }, + isInactive: function isInactive() { return this.get('status') === 'inactive'; }, diff --git a/ghost/core/core/server/services/auth/index.js b/ghost/core/core/server/services/auth/index.js index aa6e21747c5..4a3132fdf8a 100644 --- a/ghost/core/core/server/services/auth/index.js +++ b/ghost/core/core/server/services/auth/index.js @@ -17,5 +17,9 @@ module.exports = { get passwordreset() { return require('./passwordreset'); + }, + + get resetAuthentication() { + return require('./reset-authentication').default; } }; diff --git a/ghost/core/core/server/services/auth/reset-authentication.ts b/ghost/core/core/server/services/auth/reset-authentication.ts new file mode 100644 index 00000000000..57e6e7f5e84 --- /dev/null +++ b/ghost/core/core/server/services/auth/reset-authentication.ts @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type {Knex} from 'knex'; +import type {InternalApiKey, InternalKeys} from '../internal-keys'; +import internalKeysDefault from '../internal-keys'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const modelsDefault = require('../../models'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const {deleteAllSessions: deleteAllSessionsDefault} = require('./session'); + +interface ResetAuthenticationArgs { + schedulerAdapter: {rescheduleAll(opts: {previousKey?: InternalApiKey}): Promise}; + userService: {lockAll(options: any): Promise<{count: number}>}; + options: {context?: {user?: string}; [key: string]: unknown}; + models?: any; + internalKeys?: InternalKeys; + deleteAllSessions?: () => Promise; +} + +interface ResetAuthenticationResult { + apiKeysRotated: number; + usersLocked: number; +} + +/** + * Rotation, user lock and the audit row commit in a single transaction so + * app crashes mid-flight can't leave the system half-rotated or lose the + * audit trail. Session deletion runs immediately after the commit, before + * the rescheduleAll, so a failure inside the adapter can't leave stale + * session rows live for an attacker. + * + * The schedulerAdapter and userService come from boot's lifecycle, so they + * are explicit parameters. The auth-domain primitives (models, internalKeys, + * sessions) default to their module singletons; tests pass overrides. + */ +export default async function resetAuthentication({ + schedulerAdapter, + userService, + options, + models = modelsDefault, + internalKeys = internalKeysDefault, + deleteAllSessions = deleteAllSessionsDefault +}: ResetAuthenticationArgs): Promise { + const previousSchedulerKey = await internalKeys.get('ghost-scheduler'); + const actorId = options?.context?.user ?? null; + + const {apiKeysRotated, usersLocked} = await models.Base.transaction(async (tx: Knex.Transaction) => { + const txOptions = Object.assign({}, options, {transacting: tx}); + + const {count: rotated} = await models.ApiKey.refreshAllSecrets(txOptions); + const {count: locked} = await userService.lockAll(txOptions); + + if (actorId) { + await models.Action.add({ + event: 'edited', + resource_type: 'security_action', + resource_id: null, + actor_type: 'user', + actor_id: actorId, + context: { + action_name: 'reset_authentication', + api_keys_rotated: rotated, + users_locked: locked + } + }, {transacting: tx, autoRefresh: false}); + } + + return {apiKeysRotated: rotated, usersLocked: locked}; + }); + + internalKeys.clear(); + await deleteAllSessions(); + await schedulerAdapter.rescheduleAll({previousKey: previousSchedulerKey}); + + return {apiKeysRotated, usersLocked}; +} diff --git a/ghost/core/core/server/services/automations/index.js b/ghost/core/core/server/services/automations/index.js index e797b5f5b3c..e5e8460a6f3 100644 --- a/ghost/core/core/server/services/automations/index.js +++ b/ghost/core/core/server/services/automations/index.js @@ -18,10 +18,12 @@ const memberWelcomeEmailService = require('../member-welcome-emails/service'); * httpMethod: string; * }; * }) => void} schedule + * @prop {(rescheduler: {rescheduleAll: () => unknown}) => void} register */ class AutomationsService { #initialized = false; + #enqueuePollNow; /** * @param {object} options @@ -36,13 +38,9 @@ class AutomationsService { return; } - const enqueuePollNow = () => { - domainEvents.dispatch(StartAutomationsPollEvent.create()); - }; + this.#enqueuePollNow = () => domainEvents.dispatch(StartAutomationsPollEvent.create()); - /** - * @param {Readonly} date - */ + /** @param {Readonly} date */ const enqueuePollAt = async (date) => { try { const key = await internalKeys.get('ghost-scheduler'); @@ -55,18 +53,26 @@ class AutomationsService { } }; - domainEvents.subscribe(StartAutomationsPollEvent, oneAtATime(async () => { - await poll({ - memberWelcomeEmailService, - enqueueAnotherPollNow: enqueuePollNow, - enqueueAnotherPollAt: enqueuePollAt - }); - })); + domainEvents.subscribe(StartAutomationsPollEvent, oneAtATime(async () => poll({ + memberWelcomeEmailService, + enqueueAnotherPollNow: this.#enqueuePollNow, + enqueueAnotherPollAt: enqueuePollAt + }))); - enqueuePollNow(); + schedulerAdapter.register(this); + this.#enqueuePollNow(); this.#initialized = true; } + + /** + * Re-arm the poll chain. A queued poll signed under the previous scheduler + * key fails JWT verification when fired; this dispatches a fresh in-process + * poll that re-schedules the next callback under the current key. + */ + rescheduleAll() { + this.#enqueuePollNow?.(); + } } -module.exports = AutomationsService; +module.exports = new AutomationsService(); diff --git a/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts b/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts index 3f72044d6b1..c02a8da13e6 100644 --- a/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts +++ b/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts @@ -148,6 +148,16 @@ export class GiftBookshelfRepository implements GiftRepository { return collection.models.map(model => this.toGift(model)); } + async findUnsentReminders(): Promise { + const now = new Date().toISOString(); + + const collection = await this.model.findAll({ + filter: `status:redeemed+consumes_at:>'${now}'+consumes_soon_reminder_sent_at:null` + }); + + return collection.models.map(model => this.toGift(model)); + } + async create(gift: Gift, options: RepositoryTransactionOptions = {}) { await this.model.add(this.toRow(gift), options); } diff --git a/ghost/core/core/server/services/gifts/gift-reminder-scheduler.ts b/ghost/core/core/server/services/gifts/gift-reminder-scheduler.ts new file mode 100644 index 00000000000..a65fdde1bee --- /dev/null +++ b/ghost/core/core/server/services/gifts/gift-reminder-scheduler.ts @@ -0,0 +1,109 @@ +import logging from '@tryghost/logging'; +import {Gift} from './gift'; +import type {InternalApiKey, InternalKeys} from '../internal-keys'; +import type {SchedulerAdapter, SchedulerJob} from '../../adapters/scheduling/types'; +import {GIFT_REMINDER_LEAD_DAYS} from './constants'; +// Same-domain (scheduling) primitives, used unconditionally. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const urlUtils = require('../../../shared/url-utils'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const {getSignedAdminToken} = require('../../adapters/scheduling/utils'); + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const GIFT_REMINDER_LEAD_MS = GIFT_REMINDER_LEAD_DAYS * MS_PER_DAY; + +interface GiftReminderSchedulerDeps { + apiUrl: string; + // Optional in deps so the JS wrapper can pass options.schedulerAdapter + // through without TS complaining at the JS/TS boundary. The class field + // below is non-optional; the constructor's adapter.register(this) call + // throws if undefined is passed through in practice. + adapter?: SchedulerAdapter; + internalKeys: InternalKeys; + findUnsentReminders(): Promise; +} + +export class GiftReminderScheduler { + readonly #apiUrl: string; + readonly #adapter: SchedulerAdapter; + readonly #internalKeys: InternalKeys; + readonly #findUnsentReminders: () => Promise; + + constructor({apiUrl, adapter, internalKeys, findUnsentReminders}: GiftReminderSchedulerDeps) { + this.#apiUrl = apiUrl; + this.#adapter = adapter!; + this.#internalKeys = internalKeys; + this.#findUnsentReminders = findUnsentReminders; + this.#adapter.register(this); + } + + /** + * Queue a single reminder callback for a freshly-redeemed gift. The + * callback fires at consumesAt - GIFT_REMINDER_LEAD_DAYS. Already-due + * reminders are skipped — the daily cron picks them up. + */ + async scheduleFor(gift: Gift): Promise { + if (!gift.consumesAt) { + return; + } + const time = gift.consumesAt.getTime() - GIFT_REMINDER_LEAD_MS; + if (time <= Date.now()) { + return; + } + + try { + const key = await this.#internalKeys.get('ghost-scheduler'); + this.#adapter.schedule(this.#buildJob(time, key)); + } catch (err) { + logging.error({ + event: {name: 'gift_reminder_scheduler.schedule.failed'}, + err, + giftToken: gift.token + }, 'Failed to schedule gift reminder'); + } + } + + /** + * Re-issue every queued reminder under the current scheduler key. Pass + * the pre-rotation secret as `previousKey` so each adapter-queued URL + * can be reconstructed for unschedule before resigning with the new + * key. Reminders whose fire time has already passed are skipped — the + * daily cron picks them up. + */ + async rescheduleAll({previousKey}: {previousKey?: InternalApiKey} = {}): Promise { + const currentKey = await this.#internalKeys.get('ghost-scheduler'); + const unscheduleKey = previousKey ?? currentKey; + const pending = await this.#findUnsentReminders(); + + // Same-key rebuild (no previousKey, boot path) → URL signature is + // identical to the about-to-be-scheduled job. The default adapter + // implements unschedule via tombstones keyed by URL+time, so a same-URL + // unschedule poisons the scheduled job. Bootstrap mode skips the + // tombstone write. Rotation (previousKey provided) → URLs differ, so + // the tombstone correctly targets the old queued entry. + const bootstrap = !previousKey; + + for (const gift of pending) { + if (!gift.consumesAt) { + continue; + } + const time = gift.consumesAt.getTime() - GIFT_REMINDER_LEAD_MS; + if (time <= Date.now()) { + continue; + } + this.#adapter.unschedule(this.#buildJob(time, unscheduleKey), {bootstrap}); + this.#adapter.schedule(this.#buildJob(time, currentKey)); + } + } + + #buildJob(time: number, key: InternalApiKey): SchedulerJob { + const signedAdminToken = getSignedAdminToken({ + publishedAt: new Date(time).toISOString(), + apiUrl: this.#apiUrl, + key + }); + const url = new URL(urlUtils.urlJoin(this.#apiUrl, 'gifts', 'flush_reminders')); + url.searchParams.set('token', signedAdminToken); + return {time, url: url.toString(), extra: {httpMethod: 'PUT'}}; + } +} diff --git a/ghost/core/core/server/services/gifts/gift-repository.ts b/ghost/core/core/server/services/gifts/gift-repository.ts index d8c43436654..c57fd01b840 100644 --- a/ghost/core/core/server/services/gifts/gift-repository.ts +++ b/ghost/core/core/server/services/gifts/gift-repository.ts @@ -20,6 +20,7 @@ export interface GiftRepository { findPendingConsumption(): Promise; findPendingExpiration(): Promise; findPendingReminder(options: FindPendingReminderOptions): Promise; + findUnsentReminders(): Promise; getActiveByMember(memberId: string, options?: RepositoryTransactionOptions): Promise; getActiveByMembers(memberIds: string[], options?: RepositoryTransactionOptions): Promise>; create(gift: Gift, options?: RepositoryTransactionOptions): Promise; diff --git a/ghost/core/core/server/services/gifts/gift-service-wrapper.js b/ghost/core/core/server/services/gifts/gift-service-wrapper.js index b3734e64307..7ee52b1ba98 100644 --- a/ghost/core/core/server/services/gifts/gift-service-wrapper.js +++ b/ghost/core/core/server/services/gifts/gift-service-wrapper.js @@ -7,7 +7,7 @@ * @typedef {object} InitOptions * @prop {string} [apiUrl] * @prop {SchedulerAdapter} [schedulerAdapter] - * @prop {ReadonlyMap>} [internalKeys] + * @prop {import('../internal-keys').InternalKeys} [internalKeys] */ class GiftServiceWrapper { @@ -26,6 +26,7 @@ class GiftServiceWrapper { const {Gift: GiftModel} = require('../../models'); const {GiftBookshelfRepository} = require('./gift-bookshelf-repository'); const {GiftService} = require('./gift-service'); + const {GiftReminderScheduler} = require('./gift-reminder-scheduler'); const {GiftEmailService} = require('./gift-email-service'); const {GiftController} = require('./gift-controller'); const membersService = require('../members'); @@ -45,7 +46,6 @@ class GiftServiceWrapper { const settingsHelpers = require('../settings-helpers'); const EmailAddressParser = require('../email-address/email-address-parser'); const {blogIcon} = require('../../../server/lib/image'); - const {getSignedAdminToken} = require('../../adapters/scheduling/utils'); const {t} = require('../i18n'); const repository = new GiftBookshelfRepository({ @@ -61,6 +61,13 @@ class GiftServiceWrapper { t }); + const giftReminderScheduler = new GiftReminderScheduler({ + apiUrl: options.apiUrl, + adapter: options.schedulerAdapter, + internalKeys: options.internalKeys, + findUnsentReminders: () => repository.findUnsentReminders() + }); + this.service = new GiftService({ giftRepository: repository, get memberRepository() { @@ -71,11 +78,7 @@ class GiftServiceWrapper { get staffServiceEmails() { return staffService.api.emails; }, - schedulerAdapter: options.schedulerAdapter ?? null, - getSchedulerKey: options.internalKeys ? () => options.internalKeys.get('ghost-scheduler') : null, - getSignedAdminToken, - urlJoin: urlUtils.urlJoin.bind(urlUtils), - apiUrl: options.apiUrl ?? null + giftReminderScheduler }); this.controller = new GiftController({ diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts index 1f84938b88d..5b616ddd3b4 100644 --- a/ghost/core/core/server/services/gifts/gift-service.ts +++ b/ghost/core/core/server/services/gifts/gift-service.ts @@ -3,7 +3,7 @@ import errors from '@tryghost/errors'; import logging from '@tryghost/logging'; import {Gift} from './gift'; import type {GiftRepository} from './gift-repository'; -import type {InternalApiKey} from '../internal-keys'; +import type {GiftReminderScheduler} from './gift-reminder-scheduler'; import tpl from '@tryghost/tpl'; import {GIFT_REMINDER_FLOOR_DAYS, GIFT_REMINDER_LEAD_DAYS} from './constants'; import {MEMBER_WELCOME_EMAIL_SLUGS} from '../member-welcome-emails/constants'; @@ -113,31 +113,13 @@ export interface GiftPurchaseData { stripePaymentIntentId: string; } -interface SchedulerAdapter { - schedule(job: {time: number; url: string; extra: {httpMethod: string}}): void; -} - -type GetSchedulerKey = () => Promise; - -type GetSignedAdminToken = (options: { - publishedAt: string; - apiUrl: string; - key: InternalApiKey; -}) => string; - -type UrlJoin = (...parts: string[]) => string; - interface GiftServiceDeps { giftRepository: GiftRepository; memberRepository: MemberRepository; tiersService: TiersService; giftEmailService: GiftEmailService; staffServiceEmails: StaffServiceEmails; - schedulerAdapter: SchedulerAdapter | null; - getSchedulerKey: GetSchedulerKey | null; - getSignedAdminToken: GetSignedAdminToken | null; - urlJoin: UrlJoin | null; - apiUrl: string | null; + giftReminderScheduler: Pick; } interface ReminderSend { @@ -355,7 +337,7 @@ export class GiftService { logging.error('Failed to notify staff of gift redemption', err); } - await this.scheduleReminder(redeemed); + await this.deps.giftReminderScheduler.scheduleFor(redeemed); }; if (options.transacting) { @@ -595,44 +577,6 @@ export class GiftService { return {expiredCount}; } - private async scheduleReminder(gift: Gift): Promise { - const {schedulerAdapter, getSchedulerKey, getSignedAdminToken, urlJoin, apiUrl} = this.deps; - - if (!schedulerAdapter || !getSchedulerKey || !getSignedAdminToken || !urlJoin || !apiUrl) { - return; - } - - if (!gift.consumesAt) { - return; - } - - const time = gift.consumesAt.getTime() - GIFT_REMINDER_LEAD_MS; - - if (time <= Date.now()) { - return; - } - - try { - const key = await getSchedulerKey(); - const signedAdminToken = getSignedAdminToken({ - publishedAt: new Date(time).toISOString(), - apiUrl, - key - }); - - const url = new URL(urlJoin(apiUrl, 'gifts', 'flush_reminders')); - url.searchParams.set('token', signedAdminToken); - - schedulerAdapter.schedule({ - time, - url: url.toString(), - extra: {httpMethod: 'PUT'} - }); - } catch (err) { - logging.error('Failed to schedule gift reminder', err); - } - } - async processReminders(): Promise<{remindedCount: number; skippedCount: number; failedCount: number}> { const now = new Date(); const toRemind = await this.deps.giftRepository.findPendingReminder({ diff --git a/ghost/core/core/server/services/internal-keys/index.ts b/ghost/core/core/server/services/internal-keys/index.ts index 44fd051161f..6c7c1946139 100644 --- a/ghost/core/core/server/services/internal-keys/index.ts +++ b/ghost/core/core/server/services/internal-keys/index.ts @@ -20,6 +20,16 @@ const SLUG_KEY_TYPE = { export type ApiKeyType = typeof SLUG_KEY_TYPE[InternalIntegrationSlug]; +/** + * The shape consumers receive when they import the singleton: an + * `AutoFillingMap` whose `get` returns `Promise` directly + * (the override drops the `| undefined` from the structural `Map.get` + * signature). Rotation orchestration uses the inherited `Map` surface + * (`.clear()`, `.delete()`) to invalidate after rotating the underlying + * api_keys row. + */ +export type InternalKeys = AutoFillingMap>; + // models/index.js is the Bookshelf model registry — a JS module without // TypeScript declarations. Use a typed require so we can call the model // method without polluting the file with `any`. The generic constrains @@ -35,9 +45,7 @@ const models = require('../../models') as { /** * Process-lifetime cache of internal-integration API keys, keyed by slug. - * Exposed to consumers as a `ReadonlyMap>` - * so they only see `.get(slug)`; rotation orchestration uses the full Map - * surface (`.delete(slug)`, `.clear()`) to invalidate after rotating the + * Rotation orchestration calls `.clear()` to invalidate after rotating the * underlying api_keys row. */ const internalKeys = new AutoFillingMap>( diff --git a/ghost/core/core/server/services/post-scheduling/index.js b/ghost/core/core/server/services/post-scheduling/index.js deleted file mode 100644 index 9afef42ea55..00000000000 --- a/ghost/core/core/server/services/post-scheduling/index.js +++ /dev/null @@ -1,29 +0,0 @@ -const events = require('../../lib/common/events'); -const PostSchedulerService = require('./post-scheduler-service'); -const {sequence} = require('@tryghost/promise'); - -const SCHEDULED_RESOURCES = ['post', 'page']; - -/** - * @description Load all scheduled posts/pages from database. - * @return {Promise} - */ -const loadScheduledResources = async function () { - const api = require('../../api').endpoints; - const results = await sequence(SCHEDULED_RESOURCES.map(resourceType => async () => { - const result = await api.schedules.getScheduled.query({options: {resource: resourceType}}); - return result[resourceType] || []; - })); - return SCHEDULED_RESOURCES.reduce((obj, entry, index) => Object.assign(obj, {[entry]: results[index]}), {}); -}; - -const init = async ({adapter, apiUrl, internalKeys}) => { - const service = new PostSchedulerService({apiUrl, internalKeys, adapter, events}); - if (adapter.rescheduleOnBoot) { - const scheduledResources = await loadScheduledResources(); - await service.reschedule(scheduledResources); - } - return service; -}; - -exports.init = init; diff --git a/ghost/core/core/server/services/post-scheduling/index.ts b/ghost/core/core/server/services/post-scheduling/index.ts new file mode 100644 index 00000000000..b5421a879d0 --- /dev/null +++ b/ghost/core/core/server/services/post-scheduling/index.ts @@ -0,0 +1,14 @@ +import PostScheduling from './post-scheduling'; +import internalKeys from '../internal-keys'; + +// CJS modules without TS declarations — typed loosely at the boundary. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const adapterManager = require('../adapter-manager'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const urlUtils = require('../../../shared/url-utils'); + +export default new PostScheduling({ + apiUrl: urlUtils.urlFor('api', {type: 'admin'}, true), + adapter: adapterManager.getAdapter('scheduling'), + internalKeys +}); diff --git a/ghost/core/core/server/services/post-scheduling/post-scheduler-service.js b/ghost/core/core/server/services/post-scheduling/post-scheduler-service.js deleted file mode 100644 index ba9aba25973..00000000000 --- a/ghost/core/core/server/services/post-scheduling/post-scheduler-service.js +++ /dev/null @@ -1,126 +0,0 @@ -const moment = require('moment'); -const errors = require('@tryghost/errors'); -const logging = require('@tryghost/logging'); - -const urlUtils = require('../../../shared/url-utils'); -const {getSignedAdminToken} = require('../../adapters/scheduling/utils'); - -const SCHEDULED_RESOURCES = ['post', 'page']; - -class PostSchedulerService { - constructor({apiUrl, internalKeys, adapter, events} = {}) { - if (!apiUrl) { - throw new errors.IncorrectUsageError({message: 'post-scheduling: no apiUrl was provided'}); - } - if (!internalKeys) { - throw new errors.IncorrectUsageError({message: 'post-scheduling: no internalKeys was provided'}); - } - - this.apiUrl = apiUrl; - this.adapter = adapter; - this.internalKeys = internalKeys; - - adapter.run(); - - SCHEDULED_RESOURCES.forEach((resource) => { - events.on(`${resource}.scheduled`, async (model) => { - try { - const key = await internalKeys.get('ghost-scheduler'); - adapter.schedule(this.normalize({model, apiUrl, key, resourceType: resource})); - } catch (err) { - logging.error({ - event: {name: 'post-scheduling.schedule.error'}, - err, - resource, - id: model.get('id') - }, 'Failed to schedule resource'); - } - }); - - /** We want to do reschedule as (unschedule + schedule) due to how token(+url) is generated - * We want to first remove existing schedule by generating a matching token(+url) - * followed by generating a new token(+url) for the new schedule - */ - events.on(`${resource}.rescheduled`, async (model) => { - try { - const key = await internalKeys.get('ghost-scheduler'); - adapter.unschedule(this.normalize({model, apiUrl, key, resourceType: resource}, 'unscheduled')); - adapter.schedule(this.normalize({model, apiUrl, key, resourceType: resource})); - } catch (err) { - logging.error({ - event: {name: 'post-scheduling.reschedule.error'}, - err, - resource, - id: model.get('id') - }, 'Failed to reschedule resource'); - } - }); - - events.on(`${resource}.unscheduled`, async (model) => { - try { - const key = await internalKeys.get('ghost-scheduler'); - adapter.unschedule(this.normalize({model, apiUrl, key, resourceType: resource}, 'unscheduled')); - } catch (err) { - logging.error({ - event: {name: 'post-scheduling.unschedule.error'}, - err, - resource, - id: model.get('id') - }, 'Failed to unschedule resource'); - } - }); - }); - } - - /** - * Re-issue every queued schedule. On boot the caller passes the resources - * loaded from the DB. For key rotation, the caller additionally passes - * `previousKey` so unschedule URLs reproduce the entries the adapter - * already has (signed under the previous secret); schedule URLs are - * reissued under the current secret. - * - * @param {Object} scheduledResources - {post: [...], page: [...]} - * @param {Object} [opts] - * @param {{id: string, secret: string}} [opts.previousKey] - */ - async reschedule(scheduledResources, {previousKey} = {}) { - const currentKey = await this.internalKeys.get('ghost-scheduler'); - const unscheduleKey = previousKey ?? currentKey; - - for (const resourceType of Object.keys(scheduledResources)) { - for (const model of scheduledResources[resourceType]) { - this.adapter.unschedule( - this.normalize({model, apiUrl: this.apiUrl, key: unscheduleKey, resourceType}), - {bootstrap: true} - ); - this.adapter.schedule( - this.normalize({model, apiUrl: this.apiUrl, key: currentKey, resourceType}) - ); - } - } - } - - /** - * @description Normalize model data into scheduler notation. - * @param {Object} options - * @return {Object} - */ - normalize({model, apiUrl, resourceType, key}, event = '') { - const resource = `${resourceType}s`; - const publishedAt = (event === 'unscheduled') ? model.previous('published_at') : model.get('published_at'); - const signedAdminToken = getSignedAdminToken({publishedAt, apiUrl, key}); - let url = `${urlUtils.urlJoin(apiUrl, 'schedules', resource, model.get('id'))}/?token=${signedAdminToken}`; - - return { - // NOTE: The scheduler expects a unix timestamp. - time: moment(publishedAt).valueOf(), - url: url, - extra: { - httpMethod: 'PUT', - oldTime: model.previous('published_at') ? moment(model.previous('published_at')).valueOf() : null - } - }; - } -} - -module.exports = PostSchedulerService; diff --git a/ghost/core/core/server/services/post-scheduling/post-scheduling.ts b/ghost/core/core/server/services/post-scheduling/post-scheduling.ts new file mode 100644 index 00000000000..5fc091876d0 --- /dev/null +++ b/ghost/core/core/server/services/post-scheduling/post-scheduling.ts @@ -0,0 +1,138 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import moment from 'moment'; +import logging from '@tryghost/logging'; +import type {InternalApiKey, InternalKeys} from '../internal-keys'; +import type {SchedulerAdapter, SchedulerJob} from '../../adapters/scheduling/types'; + +// CJS-only modules — typed loosely below. models is the Bookshelf registry +// without TS declarations; the rest are JS modules without types. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const models = require('../../models'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const urlUtils = require('../../../shared/url-utils'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const {getSignedAdminToken} = require('../../adapters/scheduling/utils'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const events = require('../../lib/common/events'); + +interface PostSchedulingDeps { + apiUrl: string; + // Optional in deps so the JS wrapper can pass options.schedulerAdapter + // through without TS complaining at the JS/TS boundary. The class field + // below is non-optional; the constructor's adapter.register(this) call + // throws if undefined is passed through in practice. + adapter?: SchedulerAdapter; + internalKeys: InternalKeys; +} + +// Pages live in the posts table with type:'page', so both types are +// queried through models.Post and discriminated via the type filter. +const SCHEDULED_RESOURCES = ['post', 'page'] as const; +type ScheduledResource = typeof SCHEDULED_RESOURCES[number]; + +export default class PostScheduling { + readonly #apiUrl: string; + readonly #adapter: SchedulerAdapter; + readonly #internalKeys: InternalKeys; + + constructor({apiUrl, adapter, internalKeys}: PostSchedulingDeps) { + this.#apiUrl = apiUrl; + this.#adapter = adapter!; + this.#internalKeys = internalKeys; + + SCHEDULED_RESOURCES.forEach((resource) => { + events.on(`${resource}.scheduled`, async (model: any) => { + try { + const key = await internalKeys.get('ghost-scheduler'); + this.#adapter.schedule(this.#normalize({model, key, resourceType: resource})); + } catch (err) { + logging.error({event: {name: 'post-scheduling.schedule.error'}, err, resource, id: model.get('id')}, 'Failed to schedule resource'); + } + }); + + // Reschedule = matched unschedule + fresh schedule, because tokens + // are signed against the published_at timestamp. + events.on(`${resource}.rescheduled`, async (model: any) => { + try { + const key = await internalKeys.get('ghost-scheduler'); + this.#adapter.unschedule(this.#normalize({model, key, resourceType: resource}, 'unscheduled')); + this.#adapter.schedule(this.#normalize({model, key, resourceType: resource})); + } catch (err) { + logging.error({event: {name: 'post-scheduling.reschedule.error'}, err, resource, id: model.get('id')}, 'Failed to reschedule resource'); + } + }); + + events.on(`${resource}.unscheduled`, async (model: any) => { + try { + const key = await internalKeys.get('ghost-scheduler'); + this.#adapter.unschedule(this.#normalize({model, key, resourceType: resource}, 'unscheduled')); + } catch (err) { + logging.error({event: {name: 'post-scheduling.unschedule.error'}, err, resource, id: model.get('id')}, 'Failed to unschedule resource'); + } + }); + }); + + this.#adapter.register(this); + } + + /** + * Re-issue every queued schedule under the current internal-keys cache. + * On boot the previous key is the same as the current key. For key + * rotation, the caller passes `previousKey` so unschedule URLs match + * the entries the adapter already holds (signed under the previous + * secret); schedule URLs are reissued under the current secret. + */ + async rescheduleAll({previousKey}: {previousKey?: InternalApiKey} = {}): Promise { + const scheduledResources = await this.#loadScheduledResources(); + const currentKey = await this.#internalKeys.get('ghost-scheduler'); + const unscheduleKey = previousKey ?? currentKey; + + // Same-key rebuild (no previousKey, boot path) → URL signature is + // identical to the about-to-be-scheduled job. The default adapter + // implements unschedule via tombstones keyed by URL+time, so a same-URL + // unschedule poisons the scheduled job. Bootstrap mode skips the + // tombstone write. Rotation (previousKey provided) → URLs differ, so + // the tombstone correctly targets the old queued entry. + const bootstrap = !previousKey; + + for (const resourceType of Object.keys(scheduledResources) as ScheduledResource[]) { + for (const model of scheduledResources[resourceType]) { + this.#adapter.unschedule( + this.#normalize({model, key: unscheduleKey, resourceType}), + {bootstrap} + ); + this.#adapter.schedule( + this.#normalize({model, key: currentKey, resourceType}) + ); + } + } + } + + async #loadScheduledResources(): Promise> { + const entries = await Promise.all(SCHEDULED_RESOURCES.map(async (resourceType) => { + const found = await models.Post.findAll({ + filter: `status:scheduled+type:${resourceType}`, + columns: ['id', 'published_at', 'created_at', 'type'] + }); + return [resourceType, found]; + })); + return Object.fromEntries(entries); + } + + #normalize({model, resourceType, key}: {model: any; resourceType: ScheduledResource; key: InternalApiKey}, event: '' | 'unscheduled' = ''): SchedulerJob { + const resource = `${resourceType}s`; + const publishedAt = (event === 'unscheduled') ? model.previous('published_at') : model.get('published_at'); + const signedAdminToken = getSignedAdminToken({publishedAt, apiUrl: this.#apiUrl, key}); + const url = `${urlUtils.urlJoin(this.#apiUrl, 'schedules', resource, model.get('id'))}/?token=${signedAdminToken}`; + + return { + // NOTE: The scheduler expects a unix timestamp. + time: moment(publishedAt).valueOf(), + url, + extra: { + httpMethod: 'PUT', + oldTime: model.previous('published_at') ? moment(model.previous('published_at')).valueOf() : null + } + }; + } +} diff --git a/ghost/core/core/server/services/settings/private-site-access-code.js b/ghost/core/core/server/services/settings/private-site-access-code.js index eb203cb6b5a..6908cd04c29 100644 --- a/ghost/core/core/server/services/settings/private-site-access-code.js +++ b/ghost/core/core/server/services/settings/private-site-access-code.js @@ -1,14 +1,22 @@ const crypto = require('crypto'); +const ACCESS_CODE_WORDS = [ + 'anchor', 'aurora', 'beacon', 'birch', 'bright', 'cedar', 'cloud', 'comet', 'copper', 'coral', + 'ember', 'fern', 'field', 'forest', 'golden', 'green', 'harbor', 'hidden', 'horizon', 'juniper', + 'lagoon', 'lunar', 'maple', 'meadow', 'midnight', 'north', 'ocean', 'olive', 'paper', 'pine', + 'quiet', 'river', 'sage', 'signal', 'silver', 'solstice', 'sparrow', 'stone', 'studio', 'summit', + 'sunrise', 'thistle', 'valley', 'violet', 'willow', 'window', 'winter', 'wild' +]; + /** - * Placeholder access-code generator — returns `fake-###`. - * The real format will be implemented in a follow-up PR. + * Generates a short trial private-site access code in the `word###` format. * * @returns {string} */ function generatePrivateSiteAccessCode() { + const word = ACCESS_CODE_WORDS[crypto.randomInt(ACCESS_CODE_WORDS.length)]; const number = crypto.randomInt(1000).toString().padStart(3, '0'); - return `fake-${number}`; + return `${word}${number}`; } -module.exports = {generatePrivateSiteAccessCode}; +module.exports = {ACCESS_CODE_WORDS, generatePrivateSiteAccessCode}; diff --git a/ghost/core/core/server/services/users.js b/ghost/core/core/server/services/users.js index 0d96fc08921..7601bf7305a 100644 --- a/ghost/core/core/server/services/users.js +++ b/ghost/core/core/server/services/users.js @@ -56,24 +56,37 @@ class Users { this.assignTagToUserPosts = this.assignTagToUserPosts.bind(this); } - async resetAllPasswords(frameOptions) { - return this.models.Base.transaction(async (t) => { - frameOptions.transacting = t; - - // Reset all passwords - const users = await this.models.User.findAll(frameOptions); - for (const user of users) { - await user.save({ - status: 'locked' // Prevent signins before password reset - }, frameOptions); + /** + * Lock every user and invalidate their password. + * + * Locked users hit `PasswordResetRequiredError` on their next signin + * attempt; the session endpoint catches that, generates a reset token, + * and emails the user *in context of their own signin*. That avoids + * blasting unsolicited "your password has been reset" emails to people + * who are not actively signing in, while still funnelling everyone + * through a fresh password before they regain access. + * + * @param {Object} frameOptions + * @returns {Promise<{count: number}>} + */ + async lockAll(frameOptions) { + const lockUsers = async (txOptions) => { + // Every staff user has their password rotated. user.lock() + // preserves `inactive` status for suspended users so they remain + // on the suspended-signin path; everyone else transitions to + // `locked` and hits the reset-on-signin flow. + const users = await this.models.User.findAll(txOptions); + for (const user of users.models) { + await user.lock(txOptions); } + return users.models; + }; - //Send all password resets - for (const user of users) { - const token = await this.auth.passwordreset.generateToken(user.get('email'), this.apiSettings, t); - await this.auth.passwordreset.sendResetNotification(token, this.apiMail); - } - }); + const users = frameOptions.transacting + ? await lockUsers(frameOptions) + : await this.models.Base.transaction(t => lockUsers({...frameOptions, transacting: t})); + + return {count: users.length}; } async assignTagToUserPosts({id, context, transacting}) { diff --git a/ghost/core/core/server/web/api/endpoints/admin/middleware.js b/ghost/core/core/server/web/api/endpoints/admin/middleware.js index a2d4cda4458..c6c45a15f7e 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/middleware.js +++ b/ghost/core/core/server/web/api/endpoints/admin/middleware.js @@ -23,12 +23,16 @@ const tokenPermissionCheck = function tokenPermissionCheck(req, res, next) { // CASE: user is requesting with staff token, check blocklist, else skip to permission system // Staff tokens have a user_id associated with them, integration tokens don't if (req.api_key?.get('user_id')) { - // Check if staff token is trying to access blocked endpoints + // Express matches routes case-insensitively but req.path preserves the + // original case. Normalise before comparing so a mixed-case URL can't + // slip past the blocklist while still routing to the lowercase handler. // Match both with and without trailing slash since Express routes accept both - const isDeleteAllContent = req.method === 'DELETE' && (req.path === '/db/' || req.path === '/db'); - const isTransferOwnership = req.method === 'PUT' && (req.path === '/users/owner/' || req.path === '/users/owner'); + const path = req.path.toLowerCase(); + const isDeleteAllContent = req.method === 'DELETE' && (path === '/db/' || path === '/db'); + const isTransferOwnership = req.method === 'PUT' && (path === '/users/owner/' || path === '/users/owner'); + const isResetAuthentication = req.method === 'POST' && (path === '/authentication/reset/' || path === '/authentication/reset'); - if (isDeleteAllContent || isTransferOwnership) { + if (isDeleteAllContent || isTransferOwnership || isResetAuthentication) { return next(new errors.NoPermissionError({ message: tpl(messages.staffTokenBlocked) })); diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 44dc001ed46..f702d8d7a36 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -306,7 +306,7 @@ module.exports = function apiRoutes() { router.post('/authentication/setup', http(api.authentication.setup)); router.put('/authentication/setup', mw.authAdminApi, http(api.authentication.updateSetup)); router.get('/authentication/setup', http(api.authentication.isSetup)); - router.post('/authentication/global_password_reset', mw.authAdminApi, http(api.authentication.resetAllPasswords)); + router.post('/authentication/reset', mw.authAdminApi, http(api.authentication.reset)); // ## Images router.post('/images/upload', diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index c010b80f22b..fe3c4b8569c 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -25,7 +25,8 @@ const GA_FEATURES = [ 'explore', 'commentModeration', 'featurebaseFeedback', - 'giftSubscriptions' + 'giftSubscriptions', + 'dangerZoneResetAuth' ]; // These features are considered publicly available and can be enabled/disabled by users diff --git a/ghost/core/package.json b/ghost/core/package.json index 147ca260de9..992270be889 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.40.1-rc.0", + "version": "6.41.1-rc.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", @@ -54,20 +54,18 @@ "pretest": "pnpm build:assets", "test": "pnpm test:unit", "test:base": "mocha --reporter dot --node-option import=tsx --require=./test/utils/overrides.js --exit --trace-warnings --recursive --extension=test.js,test.ts", - "test:vitest": "NODE_OPTIONS='--import tsx' vitest run", + "test:vitest": "vitest run", + "test:watch": "vitest --reporter=default", "test:single": "f() { q=$(echo \"$1\" | sed 's/\\.test\\.[jt]s$//'); case \"$q\" in */*) pnpm test:base --timeout=60000 \"$1\" ;; *) pnpm test:base --timeout=60000 \"test/**/*$q*.test.{js,ts}\" ;; esac; }; f", "test:all": "pnpm test:unit && pnpm test:integration && pnpm test:e2e && pnpm lint", "test:debug": "DEBUG=ghost:test* pnpm test", - "test:unit": "c8 pnpm test:unit:${GHOST_UNIT_TEST_VARIANT:-base}", - "test:unit:base": "pnpm test:base ./test/unit/server/notify.test.js ./test/unit/server/overrides.test.js ./test/unit/server/adapters/scheduling/scheduling-default.test.js --timeout=2000", - "test:unit:ci": "pnpm test:unit:base --reporter=min", + "test:unit": "pnpm test:vitest", "test:integration": "pnpm test:base './test/integration' --timeout=10000", "test:e2e": "pnpm test:base ./test/e2e-* --timeout=15000", "test:legacy": "pnpm test:base './test/legacy' --timeout=60000", "test:ci:e2e": "c8 -c ./.c8rc.e2e.json -o coverage-e2e pnpm test:e2e -b", "test:ci:legacy": "pnpm test:legacy -b", "test:ci:integration": "c8 -c ./.c8rc.e2e.json -o coverage-integration --lines 52 --functions 47 --branches 73 --statements 52 pnpm test:integration -b", - "test:unit:slow": "pnpm test:unit --reporter=mocha-slow-test-reporter", "test:int:slow": "pnpm test:integration --reporter=mocha-slow-test-reporter", "test:e2e:slow": "pnpm test:e2e --reporter=mocha-slow-test-reporter", "test:leg:slow": "mocha --reporter dot --require=./test/utils/overrides.js --exit --trace-warnings --recursive --extension=test.js './test/legacy' --timeout=60000 --reporter=mocha-slow-test-reporter", diff --git a/ghost/core/test/.eslintrc.js b/ghost/core/test/.eslintrc.js index 26eaaf45729..d89a14a0d4f 100644 --- a/ghost/core/test/.eslintrc.js +++ b/ghost/core/test/.eslintrc.js @@ -39,7 +39,8 @@ module.exports = { 'no-unused-vars': [ 'error', { - varsIgnorePattern: '^should$' + varsIgnorePattern: '^should$', + argsIgnorePattern: '^_' } ], 'no-useless-escape': 'off', diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap index 96849874c8f..8005dc80823 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap @@ -16,6 +16,7 @@ Object { "commentsPinning": true, "commentsThreads": true, "customFonts": true, + "dangerZoneResetAuth": true, "editorExcerpt": true, "emailCustomization": true, "emailUniqueid": true, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap index c23382a0281..fc78880c480 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap @@ -1803,7 +1803,7 @@ exports[`Settings API Edit can edit Stripe settings when Stripe Connect limit is Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "5406", + "content-length": "5435", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/api-tokens.test.js b/ghost/core/test/e2e-api/admin/api-tokens.test.js index 885360e6ac3..45c15b17478 100644 --- a/ghost/core/test/e2e-api/admin/api-tokens.test.js +++ b/ghost/core/test/e2e-api/admin/api-tokens.test.js @@ -144,127 +144,64 @@ describe('Admin API', function () { }); }); - // Make sure that staff tokens are blocked from certain endpoints + // The blocklist contract: each (method, path) tuple must be rejected + // for any staff token, across lowercase, trailing-slash and mixed-case + // variants. The middleware's role isn't to evaluate permissions here — + // it short-circuits before that — so role doesn't change the outcome. describe('Staff Token Blocklist', function () { - describe('DELETE /db endpoint (delete all content)', function () { - it('Owner staff token should be blocked', async function () { + // Build path variants once so the parameter table reads as a contract: + // lowercase, trailing slash, and mixed-case in the endpoint segment all + // resolve to the same handler (Express matches case-insensitively) and + // so must be rejected the same. The `/ghost/api/admin` prefix stays + // lowercase because the API-key auth layer rejects mixed-case prefixes + // at 401 (JWT audience validation) before the blocklist runs. + const API_PREFIX = '/ghost/api/admin'; + const pathVariants = (endpoint) => { + const base = `${API_PREFIX}${endpoint}`; + const mixedCase = `${API_PREFIX}${endpoint.replace(/(?<=\/)[a-z]/g, c => c.toUpperCase())}`; + return [base, `${base}/`, mixedCase]; + }; + + const blockedRequests = [ + ...pathVariants('/db').map(path => ({ + label: `DELETE ${path}`, + method: 'DELETE', + path + })), + ...pathVariants('/users/owner').map(path => ({ + label: `PUT ${path}`, + method: 'PUT', + path, + body: () => ({owner: [{id: fixtureManager.get('users', 1).id, email: fixtureManager.get('users', 1).email}]}) + })), + ...pathVariants('/authentication/reset').map(path => ({ + label: `POST ${path}`, + method: 'POST', + path + })) + ]; + + blockedRequests.forEach(({label, method, path, body}) => { + it(`blocks staff token: ${label}`, async function () { await agent.useStaffTokenForOwner(); - await agent - .delete('db') - .expectStatus(403); - }); - - it('Admin staff token should be blocked', async function () { - await agent.useStaffTokenForAdmin(); - await agent - .delete('db') - .expectStatus(403); - }); - - it('Editor staff token should be blocked', async function () { - await agent.useStaffTokenForEditor(); - await agent - .delete('db') - .expectStatus(403); - }); - - it('Regular user authentication should work (if user has permission)', async function () { - await agent.loginAsOwner(); - // Owner actually has permission to delete all content, so this should succeed - // The important thing is that it's not blocked by the staff token check - await agent - .delete('db') - .expectStatus(204); // Success - owner can delete all content - }); - }); - - describe('DELETE /db endpoint - trailing slash handling', function () { - it('Staff token should be blocked WITH trailing slash', async function () { - await agent.useStaffTokenForOwner(); - const res = await rawRequest('DELETE', '/ghost/api/admin/db/'); - assert.equal(res.status, 403, 'Request with trailing slash should be blocked'); - }); - - it('Staff token should be blocked WITHOUT trailing slash', async function () { - await agent.useStaffTokenForOwner(); - const res = await rawRequest('DELETE', '/ghost/api/admin/db'); - assert.equal(res.status, 403, 'Request without trailing slash should also be blocked'); + const req = rawRequest(method, path); + if (body) { + req.send(body()); + } + const res = await req; + assert.equal(res.status, 403, `Expected 403 for ${method} ${path}`); + assert.equal(res.body.errors[0].type, 'NoPermissionError'); + assert.equal(res.body.errors[0].message, 'Staff tokens are not allowed to access this endpoint'); }); }); - describe('PUT /users/owner endpoint (transfer ownership)', function () { - it('Owner staff token should be blocked', async function () { - await agent.useStaffTokenForOwner(); - await agent - .put('users/owner') - .body({ - owner: [{ - id: fixtureManager.get('users', 1).id, - email: fixtureManager.get('users', 1).email - }] - }) - .expectStatus(403) - .expect(({body}) => { - assert.equal(body.errors[0].type, 'NoPermissionError'); - assert.equal(body.errors[0].message, 'Staff tokens are not allowed to access this endpoint'); - }); - }); - - it('Admin staff token should be blocked', async function () { - await agent.useStaffTokenForAdmin(); - await agent - .put('users/owner') - .body({ - owner: [{ - id: fixtureManager.get('users', 1).id, - email: fixtureManager.get('users', 1).email - }] - }) - .expectStatus(403) - .expect(({body}) => { - assert.equal(body.errors[0].type, 'NoPermissionError'); - assert.equal(body.errors[0].message, 'Staff tokens are not allowed to access this endpoint'); - }); - }); - - it('Regular user authentication should work (if user has permission)', async function () { - await agent.loginAsOwner(); - await agent - .put('users/owner') - .body({ - owner: [{ - id: fixtureManager.get('users', 1).id, - email: fixtureManager.get('users', 1).email - }] - }) - .expectStatus(200); - }); - }); - - describe('PUT /users/owner endpoint - trailing slash handling', function () { - it('Staff token should be blocked WITH trailing slash', async function () { - await agent.useStaffTokenForOwner(); - const res = await rawRequest('PUT', '/ghost/api/admin/users/owner/') - .send({ - owner: [{ - id: fixtureManager.get('users', 1).id, - email: fixtureManager.get('users', 1).email - }] - }); - assert.equal(res.status, 403, 'Request with trailing slash should be blocked'); - }); - - it('Staff token should be blocked WITHOUT trailing slash', async function () { - await agent.useStaffTokenForOwner(); - const res = await rawRequest('PUT', '/ghost/api/admin/users/owner') - .send({ - owner: [{ - id: fixtureManager.get('users', 1).id, - email: fixtureManager.get('users', 1).email - }] - }); - assert.equal(res.status, 403, 'Request without trailing slash should also be blocked'); - }); + // Control: staff tokens on a non-blocked endpoint reach the permission + // system normally. Proves the blocklist is scoped, not global. + it('allows staff token on a non-blocked endpoint', async function () { + await agent.useStaffTokenForOwner(); + await agent + .get('db') + .expectStatus(200); }); describe('Everything else should get access according to their permissions', function () { diff --git a/ghost/core/test/e2e-api/admin/settings.test.js b/ghost/core/test/e2e-api/admin/settings.test.js index d1423986d61..ed26886fa5a 100644 --- a/ghost/core/test/e2e-api/admin/settings.test.js +++ b/ghost/core/test/e2e-api/admin/settings.test.js @@ -801,7 +801,7 @@ describe('Settings API', function () { const byKey = Object.fromEntries(response.body.settings.map(s => [s.key, s])); assert.equal(typeof byKey.password.value, 'string'); - assert.match(byKey.password.value, /^fake-\d{3}$/); + assert.match(byKey.password.value, /^[a-z]+\d{3}$/); assert.notEqual(byKey.password.value, 'caller-chosen-code'); assert.equal(byKey.password.is_read_only, true); diff --git a/ghost/core/test/integration/migrations/migration.test.js b/ghost/core/test/integration/migrations/migration.test.js index fba4c87b1cc..89671078dc8 100644 --- a/ghost/core/test/integration/migrations/migration.test.js +++ b/ghost/core/test/integration/migrations/migration.test.js @@ -206,7 +206,7 @@ describe('Migrations', function () { assertHavePermission(permissions, 'Add Products', ['Administrator', 'Admin Integration']); assertHavePermission(permissions, 'Delete Products', ['Administrator']); - assertHavePermission(permissions, 'Reset all passwords', ['Administrator']); + assertHavePermission(permissions, 'Reset authentication', ['Administrator']); assertHavePermission(permissions, 'Browse custom theme settings', ['Administrator']); assertHavePermission(permissions, 'Edit custom theme settings', ['Administrator']); diff --git a/ghost/core/test/legacy/api/admin/__snapshots__/authentication.test.js.snap b/ghost/core/test/legacy/api/admin/__snapshots__/authentication.test.js.snap index c7a1c6ce0f1..ae25408e197 100644 --- a/ghost/core/test/legacy/api/admin/__snapshots__/authentication.test.js.snap +++ b/ghost/core/test/legacy/api/admin/__snapshots__/authentication.test.js.snap @@ -932,3 +932,16 @@ Object { "x-powered-by": "Express", } `; + +exports[`Authentication API Reset authentication rotates every api key, locks every user, kills every session, and sends no proactive emails 1: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "94", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/legacy/api/admin/authentication.test.js b/ghost/core/test/legacy/api/admin/authentication.test.js index ce460217543..db6cb00face 100644 --- a/ghost/core/test/legacy/api/admin/authentication.test.js +++ b/ghost/core/test/legacy/api/admin/authentication.test.js @@ -538,10 +538,10 @@ describe('Authentication API', function () { }); }); - describe('Reset all passwords', function () { + describe('Reset authentication', function () { before(async function () { agent = await agentProvider.getAdminAPIAgent(); - await fixtureManager.init('invites'); + await fixtureManager.init('invites', 'integrations', 'api_keys'); await agent.loginAsOwner(); }); @@ -553,38 +553,63 @@ describe('Authentication API', function () { mockManager.restore(); }); - it('reset all passwords returns 204', async function () { - await agent.post('authentication/global_password_reset') + it('rotates every api key, locks every user, kills every session, and sends no proactive emails', async function () { + const apiKeysBefore = (await models.ApiKey.findAll({})).toJSON().reduce((acc, key) => { + acc[key.id] = key.secret; + return acc; + }, {}); + const totalKeys = Object.keys(apiKeysBefore).length; + assert.ok(totalKeys > 0, 'fixture should contain at least one API key'); + + const {body} = await agent.post('authentication/reset') .header('Accept', 'application/json') .body({}) - .expectStatus(204) - .expectEmptyBody() + .expectStatus(200) .matchHeaderSnapshot({ 'content-version': anyContentVersion, etag: anyEtag }); - // Check side effects - // All users locked + assert.equal(body.security_action[0].action, 'reset_authentication'); + assert.equal(body.security_action[0].api_keys_rotated, totalKeys); + assert.ok(body.security_action[0].users_locked >= 1); + + // Every API key secret was refreshed + const apiKeysAfter = (await models.ApiKey.findAll({})).toJSON(); + for (const key of apiKeysAfter) { + assert.notEqual(key.secret, apiKeysBefore[key.id], `Secret for key ${key.id} should have changed`); + } + + // Every user is locked const users = await models.User.fetchAll(); for (const user of users) { assert.equal(user.get('status'), 'locked', `Status should be locked for user ${user.get('email')}`); } - // No session left + // Every session is gone const sessions = await models.Session.fetchAll(); assert.equal(sessions.length, 0, 'There should be no sessions left in the DB'); - emailMockReceiver.assertSentEmailCount(2); - - mockManager.assert.sentEmail({ - subject: 'Reset Password', - to: 'jbloggs@example.com' - }); - mockManager.assert.sentEmail({ - subject: 'Reset Password', - to: 'ghost-author@example.com' + // Audit entry recorded. The `context` column is stored as a JSON + // string; the Action model doesn't auto-parse it on read. + const auditRows = await models.Action.findAll({ + filter: 'resource_type:security_action+event:edited' }); + assert.ok( + auditRows.some((row) => { + const raw = row.get('context'); + if (!raw) { + return false; + } + const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; + return parsed.action_name === 'reset_authentication'; + }), + 'expected an audit entry with action_name=reset_authentication' + ); + + // No proactive emails — the reset email is sent by the session + // endpoint when a locked user next attempts to sign in. + emailMockReceiver.assertSentEmailCount(0); }); }); }); diff --git a/ghost/core/test/unit/frontend/services/assets-minification/minifier.test.js b/ghost/core/test/unit/frontend/services/assets-minification/minifier.test.js index d3e63ec8beb..4d95adc5dfb 100644 --- a/ghost/core/test/unit/frontend/services/assets-minification/minifier.test.js +++ b/ghost/core/test/unit/frontend/services/assets-minification/minifier.test.js @@ -6,6 +6,15 @@ const fs = require('fs').promises; const os = require('os'); const Minifier = require('../../../../../core/frontend/services/assets-minification/minifier'); +// minifier.getMatchingFiles() returns paths relative to process.cwd(); build +// the expected paths the same way so assertions do not assume the test runs +// with process.cwd() === ghost/core (the unified `pnpm test:watch` runs from +// the repo root). +const expectedFixturePath = file => path.relative( + process.cwd(), + path.join(__dirname, 'fixtures', 'basic-cards', 'css', file) +); + describe('Minifier', function () { let minifier; let testDir; @@ -29,9 +38,9 @@ describe('Minifier', function () { assert(Array.isArray(result)); assert.equal(result.length, 3); - assert.equal(result[0], path.join('test','unit','frontend','services','assets-minification','fixtures','basic-cards','css','bookmark.css')); - assert.equal(result[1], path.join('test','unit','frontend','services','assets-minification','fixtures','basic-cards','css','empty.css')); - assert.equal(result[2], path.join('test','unit','frontend','services','assets-minification','fixtures','basic-cards','css','gallery.css')); + assert.equal(result[0], expectedFixturePath('bookmark.css')); + assert.equal(result[1], expectedFixturePath('empty.css')); + assert.equal(result[2], expectedFixturePath('gallery.css')); }); it('match glob range e.g. css/bookmark.css and css/empty.css (css/@(bookmark|empty).css)', async function () { @@ -39,8 +48,8 @@ describe('Minifier', function () { assert(Array.isArray(result)); assert.equal(result.length, 2); - assert.equal(result[0], path.join('test','unit','frontend','services','assets-minification','fixtures','basic-cards','css','bookmark.css')); - assert.equal(result[1], path.join('test','unit','frontend','services','assets-minification','fixtures','basic-cards','css','empty.css')); + assert.equal(result[0], expectedFixturePath('bookmark.css')); + assert.equal(result[1], expectedFixturePath('empty.css')); }); it('reverse match glob e.g. css/!(bookmark).css', async function () { @@ -48,15 +57,15 @@ describe('Minifier', function () { assert(Array.isArray(result)); assert.equal(result.length, 2); - assert.equal(result[0], path.join('test','unit','frontend','services','assets-minification','fixtures','basic-cards','css','empty.css')); - assert.equal(result[1], path.join('test','unit','frontend','services','assets-minification','fixtures','basic-cards','css','gallery.css')); + assert.equal(result[0], expectedFixturePath('empty.css')); + assert.equal(result[1], expectedFixturePath('gallery.css')); }); it('reverse match glob e.g. css/!(bookmark|gallery).css', async function () { let result = await minifier.getMatchingFiles('css/!(bookmark|gallery).css'); assert(Array.isArray(result)); assert.equal(result.length, 1); - assert.equal(result[0], path.join('test','unit','frontend','services','assets-minification','fixtures','basic-cards','css','empty.css')); + assert.equal(result[0], expectedFixturePath('empty.css')); }); }); diff --git a/ghost/core/test/unit/server/adapters/scheduling/scheduling-base.test.js b/ghost/core/test/unit/server/adapters/scheduling/scheduling-base.test.js new file mode 100644 index 00000000000..9320507aec0 --- /dev/null +++ b/ghost/core/test/unit/server/adapters/scheduling/scheduling-base.test.js @@ -0,0 +1,106 @@ +const assert = require('node:assert/strict'); +const sinon = require('sinon'); +const logging = require('@tryghost/logging'); +const SchedulingBase = require('../../../../../core/server/adapters/scheduling/scheduling-base'); + +describe('SchedulingBase', function () { + it('declares schedule, unschedule and run as required functions', function () { + const base = new SchedulingBase(); + assert.deepEqual(base.requiredFns, ['schedule', 'unschedule', 'run']); + }); + + describe('register + rescheduleAll', function () { + it('calls rescheduleAll on every registered rescheduler', async function () { + const base = new SchedulingBase(); + const calls = []; + + base.register({rescheduleAll: async opts => calls.push(['a', opts])}); + base.register({rescheduleAll: async opts => calls.push(['b', opts])}); + base.register({rescheduleAll: async opts => calls.push(['c', opts])}); + + await base.rescheduleAll({previousKey: {id: 'k', secret: 's'}}); + + assert.equal(calls.length, 3); + assert.deepEqual(calls.map(c => c[0]), ['a', 'b', 'c']); + for (const [, opts] of calls) { + assert.deepEqual(opts, {previousKey: {id: 'k', secret: 's'}}); + } + }); + + it('isolates failures: one rejecting does not prevent the others', async function () { + const base = new SchedulingBase(); + const calls = []; + + base.register({rescheduleAll: async () => { + throw new Error('a failed'); + }}); + base.register({rescheduleAll: async () => calls.push('b')}); + base.register({rescheduleAll: async () => calls.push('c')}); + + const errorStub = sinon.stub(logging, 'error'); + try { + const results = await base.rescheduleAll({}); + + assert.equal(results.length, 3); + assert.equal(results[0].status, 'rejected'); + assert.equal(results[1].status, 'fulfilled'); + assert.equal(results[2].status, 'fulfilled'); + assert.deepEqual(calls, ['b', 'c']); + } finally { + errorStub.restore(); + } + }); + + it('logs each rejection with the rescheduler class name', async function () { + const base = new SchedulingBase(); + + // Named classes so constructor.name reflects them in the log payload. + class PostScheduling { + async rescheduleAll() { + throw new Error('post failed'); + } + } + class AutomationsService { + async rescheduleAll() {} + } + + base.register(new PostScheduling()); + base.register(new AutomationsService()); + + const errorStub = sinon.stub(logging, 'error'); + try { + await base.rescheduleAll({}); + + assert.equal(errorStub.callCount, 1, 'only the failing rescheduler is logged'); + const [meta, message] = errorStub.firstCall.args; + assert.equal(meta.event.name, 'scheduler.reschedule_all.failed'); + assert.equal(meta.rescheduler, 'PostScheduling'); + assert.equal(meta.err.message, 'post failed'); + assert.equal(message, 'Rescheduler failed'); + } finally { + errorStub.restore(); + } + }); + + it('is a no-op when nothing has registered', async function () { + const base = new SchedulingBase(); + const results = await base.rescheduleAll(); + assert.deepEqual(results, []); + }); + + it('dedupes when the same rescheduler registers twice', async function () { + const base = new SchedulingBase(); + let invocations = 0; + const rescheduler = {rescheduleAll: async () => { + invocations += 1; + }}; + + base.register(rescheduler); + base.register(rescheduler); + + await base.rescheduleAll(); + + assert.equal(invocations, 1); + }); + }); +}); diff --git a/ghost/core/test/unit/server/adapters/scheduling/scheduling-default.test.js b/ghost/core/test/unit/server/adapters/scheduling/scheduling-default.test.js index 59535ce4df8..eeedf23a856 100644 --- a/ghost/core/test/unit/server/adapters/scheduling/scheduling-default.test.js +++ b/ghost/core/test/unit/server/adapters/scheduling/scheduling-default.test.js @@ -63,7 +63,7 @@ describe('Scheduling Default Adapter', function () { ]); }); - it('reschedule: default', function (done) { + it('reschedule: default', function () { sinon.stub(scope.adapter, '_pingUrl'); const time = moment().add(20, 'milliseconds').valueOf(); @@ -98,10 +98,9 @@ describe('Scheduling Default Adapter', function () { clock.tick(50); sinon.assert.calledOnce(scope.adapter._pingUrl); - done(); }); - it('reschedule: simulate restart', function (done) { + it('reschedule: simulate restart', function () { sinon.stub(scope.adapter, '_pingUrl'); const time = moment().add(20, 'milliseconds').valueOf(); @@ -126,10 +125,9 @@ describe('Scheduling Default Adapter', function () { clock.tick(50); sinon.assert.calledOnce(scope.adapter._pingUrl); - done(); }); - it('run', function (done) { + it('run', function () { // 1000 jobs, but only the number x are under 1 minute const timestamps = _.map(_.range(1000), function (i) { return moment().add(i, 'seconds').valueOf(); @@ -137,25 +135,28 @@ describe('Scheduling Default Adapter', function () { const allJobs = {}; - sinon.stub(scope.adapter, '_execute').callsFake(function (nextJobs) { - assert.equal(Object.keys(nextJobs).length, 121); - assert.equal(Object.keys(scope.adapter.allJobs).length, 1000 - 121); - done(); - }); - timestamps.forEach(function (timestamp) { allJobs[timestamp] = [{url: 'xxx'}]; }); + const executeStub = sinon.stub(scope.adapter, '_execute'); + scope.adapter.allJobs = allJobs; scope.adapter.runTimeoutInMs = 100; scope.adapter.offsetInMinutes = 1; scope.adapter.run(); - clock.runAll(); + // run() reschedules itself every runTimeoutInMs; advancing the + // clock by a single interval fires exactly one _execute. + clock.tick(100); + + sinon.assert.calledOnce(executeStub); + const nextJobs = executeStub.firstCall.args[0]; + assert.equal(Object.keys(nextJobs).length, 121); + assert.equal(Object.keys(scope.adapter.allJobs).length, 1000 - 121); }); - it('ensure recursive run works', function (done) { + it('ensure recursive run works', function () { sinon.spy(scope.adapter, '_execute'); scope.adapter.allJobs = {}; @@ -166,10 +167,9 @@ describe('Scheduling Default Adapter', function () { clock.tick(200); assert(scope.adapter._execute.callCount > 1); - done(); }); - it('execute', function (done) { + it('execute', function () { let pinged = 0; const jobs = 3; @@ -190,17 +190,16 @@ describe('Scheduling Default Adapter', function () { scope.adapter._execute(nextJobs); - (function retry() { - if (pinged !== jobs) { - clock.tick(50); - return retry(); - } + // _execute arms a setTimeout (+ setImmediate) per job; step the + // fake clock until every job has pinged. + for (let i = 0; i < 200 && pinged !== jobs; i = i + 1) { + clock.tick(50); + } - done(); - })(); + assert.equal(pinged, jobs); }); - it('delete job (unschedule)', function (done) { + it('delete job (unschedule)', function () { let pinged = 0; const jobsToDelete = {}; const jobsToExecute = {}; @@ -229,16 +228,13 @@ describe('Scheduling Default Adapter', function () { // simulate execute is called scope.adapter._execute(jobsToExecute); - (function retry() { - if (pinged !== 2) { - clock.tick(50); - return retry(); - } + // 2 of the 4 jobs were unscheduled, so only 2 should ping. + for (let i = 0; i < 200 && pinged !== 2; i = i + 1) { + clock.tick(50); + } - assert.equal(Object.keys(scope.adapter.deletedJobs).length, 2); - assert.equal(pinged, 2); - done(); - })(); + assert.equal(Object.keys(scope.adapter.deletedJobs).length, 2); + assert.equal(pinged, 2); }); it('delete job (unschedule): time is null', function () { @@ -247,19 +243,20 @@ describe('Scheduling Default Adapter', function () { }); describe('pingUrl', function () { - it('pingUrl (PUT)', function (done) { - // TODO: remove this once we figure out how to make this work with fake timers - sinon.restore(); - sinon.useFakeTimers({ - shouldAdvanceTime: true - }); + // These tests exercise real HTTP via nock. The suite-level fake + // clock interferes with got's internal timers, so restore real + // timers for the pingUrl tests. + beforeEach(function () { + clock.restore(); + }); + it('pingUrl (PUT)', async function () { const ping = nock('http://localhost:1111') .put('/ping') .query({}) .reply(200); - scope.adapter._pingUrl({ + await scope.adapter._pingUrl({ url: 'http://localhost:1111/ping', time: moment().add(1, 'second').valueOf(), extra: { @@ -267,13 +264,7 @@ describe('Scheduling Default Adapter', function () { } }); - (function retry() { - if (ping.isDone()) { - done(); - } else { - setTimeout(retry, 100); - } - })(); + assert.equal(ping.isDone(), true); }); it('pingUrl (GET)', async function () { @@ -290,7 +281,6 @@ describe('Scheduling Default Adapter', function () { } }); - clock.runToLast(); assert.equal(ping.isDone(), true); }); @@ -308,7 +298,6 @@ describe('Scheduling Default Adapter', function () { } }); - clock.runToLast(); assert.equal(ping.isDone(), true); }); @@ -326,26 +315,33 @@ describe('Scheduling Default Adapter', function () { } }); - clock.runToLast(); assert.equal(ping.isDone(), true); }); - it('pingUrl, but blog returns 503', function (done) { - // TODO: remove this once we figure out how to make this work with fake timers - sinon.restore(); - sinon.useFakeTimers({ - shouldAdvanceTime: true - }); - - scope.adapter.retryTimeoutInMs = 50; + it('pingUrl, but blog returns 503', async function () { + scope.adapter.retryTimeoutInMs = 20; const loggingStub = sinon.stub(logging, 'error'); + const pingSpy = sinon.spy(scope.adapter, '_pingUrl'); const ping = nock('http://localhost:1111') .put('/ping').reply(503) .put('/ping').reply(503) .put('/ping', {force: true}).reply(200); + // Wait for the nth _pingUrl attempt to be made, then for the + // promise it returned to settle. + const settle = async (callIndex) => { + for (let i = 0; i < 200 && pingSpy.callCount <= callIndex; i = i + 1) { + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + } + await pingSpy.returnValues[callIndex]; + }; + + // Initial attempt + two retries: each 503 schedules a retry + // retryTimeoutInMs later, the third attempt succeeds. scope.adapter._pingUrl({ url: 'http://localhost:1111/ping', time: moment().valueOf(), @@ -354,14 +350,13 @@ describe('Scheduling Default Adapter', function () { } }); - (function retry() { - if (ping.isDone()) { - sinon.assert.calledTwice(loggingStub); - return done(); - } + await settle(0); + await settle(1); + await settle(2); - setTimeout(retry, 50); - }()); + assert.equal(ping.isDone(), true); + sinon.assert.calledThrice(pingSpy); + sinon.assert.calledTwice(loggingStub); }); }); }); diff --git a/ghost/core/test/unit/server/adapters/storage/local-images-storage.test.js b/ghost/core/test/unit/server/adapters/storage/local-images-storage.test.js index 7755b425af0..0ef0cfd8bd6 100644 --- a/ghost/core/test/unit/server/adapters/storage/local-images-storage.test.js +++ b/ghost/core/test/unit/server/adapters/storage/local-images-storage.test.js @@ -8,6 +8,11 @@ const path = require('path'); const LocalImagesStorage = require('../../../../../core/server/adapters/storage/LocalImagesStorage'); const configUtils = require('../../../../utils/config-utils'); +// Resolve content paths from the ghost/core package root so assertions do not +// assume process.cwd() === ghost/core (the unified `pnpm test:watch` runs from +// the repo root). +const ghostCoreRoot = path.join(__dirname, '../../../../..'); + describe('Local Images Storage', function () { let image; let momentStub; @@ -78,42 +83,42 @@ describe('Local Images Storage', function () { it('should create month and year directory', async function () { await localFileStore.save(image); sinon.assert.calledOnce(fsMkdirsStub); - assert.equal(fsMkdirsStub.args[0][0], path.resolve('./content/images/2013/09')); + assert.equal(fsMkdirsStub.args[0][0], path.resolve(ghostCoreRoot, './content/images/2013/09')); }); it('should copy temp file to new location', async function () { await localFileStore.save(image); sinon.assert.calledOnce(fsCopyStub); assert.equal(fsCopyStub.args[0][0], 'tmp/123456.jpg'); - assert.equal(fsCopyStub.args[0][1], path.resolve('./content/images/2013/09/IMAGE.jpg')); + assert.equal(fsCopyStub.args[0][1], path.resolve(ghostCoreRoot, './content/images/2013/09/IMAGE.jpg')); }); it('can upload two different images with the same name without overwriting the first', async function () { - fsStatStub.withArgs(path.resolve('./content/images/2013/09/IMAGE.jpg')).resolves(); - fsStatStub.withArgs(path.resolve('./content/images/2013/09/IMAGE-1.jpg')).rejects(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, './content/images/2013/09/IMAGE.jpg')).resolves(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, './content/images/2013/09/IMAGE-1.jpg')).rejects(); // if on windows need to setup with back slashes // doesn't hurt for the test to cope with both - fsStatStub.withArgs(path.resolve('.\\content\\images\\2013\\Sep\\IMAGE.jpg')).resolves(); - fsStatStub.withArgs(path.resolve('.\\content\\images\\2013\\Sep\\IMAGE-1.jpg')).rejects(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, '.\\content\\images\\2013\\Sep\\IMAGE.jpg')).resolves(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, '.\\content\\images\\2013\\Sep\\IMAGE-1.jpg')).rejects(); const url = await localFileStore.save(image); assert.equal(url, '/content/images/2013/09/IMAGE-1.jpg'); }); it('can upload five different images with the same name without overwriting the first', async function () { - fsStatStub.withArgs(path.resolve('./content/images/2013/09/IMAGE.jpg')).resolves(); - fsStatStub.withArgs(path.resolve('./content/images/2013/09/IMAGE-1.jpg')).resolves(); - fsStatStub.withArgs(path.resolve('./content/images/2013/09/IMAGE-2.jpg')).resolves(); - fsStatStub.withArgs(path.resolve('./content/images/2013/09/IMAGE-3.jpg')).resolves(); - fsStatStub.withArgs(path.resolve('./content/images/2013/09/IMAGE-4.jpg')).rejects(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, './content/images/2013/09/IMAGE.jpg')).resolves(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, './content/images/2013/09/IMAGE-1.jpg')).resolves(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, './content/images/2013/09/IMAGE-2.jpg')).resolves(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, './content/images/2013/09/IMAGE-3.jpg')).resolves(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, './content/images/2013/09/IMAGE-4.jpg')).rejects(); // windows setup - fsStatStub.withArgs(path.resolve('.\\content\\images\\2013\\Sep\\IMAGE.jpg')).resolves(); - fsStatStub.withArgs(path.resolve('.\\content\\images\\2013\\Sep\\IMAGE-1.jpg')).resolves(); - fsStatStub.withArgs(path.resolve('.\\content\\images\\2013\\Sep\\IMAGE-2.jpg')).resolves(); - fsStatStub.withArgs(path.resolve('.\\content\\images\\2013\\Sep\\IMAGE-3.jpg')).resolves(); - fsStatStub.withArgs(path.resolve('.\\content\\images\\2013\\Sep\\IMAGE-4.jpg')).rejects(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, '.\\content\\images\\2013\\Sep\\IMAGE.jpg')).resolves(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, '.\\content\\images\\2013\\Sep\\IMAGE-1.jpg')).resolves(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, '.\\content\\images\\2013\\Sep\\IMAGE-2.jpg')).resolves(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, '.\\content\\images\\2013\\Sep\\IMAGE-3.jpg')).resolves(); + fsStatStub.withArgs(path.resolve(ghostCoreRoot, '.\\content\\images\\2013\\Sep\\IMAGE-4.jpg')).rejects(); const url = await localFileStore.save(image); assert.equal(url, '/content/images/2013/09/IMAGE-4.jpg'); diff --git a/ghost/core/test/unit/server/data/importer/index.test.js b/ghost/core/test/unit/server/data/importer/index.test.js index b317e068414..ef0af2a28f3 100644 --- a/ghost/core/test/unit/server/data/importer/index.test.js +++ b/ghost/core/test/unit/server/data/importer/index.test.js @@ -125,7 +125,7 @@ describe('Importer', function () { }); it('cleans up', async function () { - const file = path.resolve('test/utils/fixtures/import/zips/zip-with-base-dir'); + const file = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-with-base-dir'); ImportManager.fileToDelete = file; const removeStub = sinon.stub(fs, 'remove').withArgs(file).returns(Promise.resolve()); @@ -144,7 +144,7 @@ describe('Importer', function () { it('silently ignores clean up errors', async function () { const loggingStub = sinon.stub(logging, 'error'); - const file = path.resolve('test/utils/fixtures/import/zips/zip-with-base-dir'); + const file = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-with-base-dir'); ImportManager.fileToDelete = file; const removeStub = sinon.stub(fs, 'remove').withArgs(file).returns(Promise.reject(new Error('Unknown file'))); @@ -218,61 +218,61 @@ describe('Importer', function () { describe('Validate Zip', function () { it('accepts a zip with a base directory', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-with-base-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-with-base-dir'); assert(ImportManager.isValidZip(testDir)); }); it('accepts a zip without a base directory', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-without-base-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-without-base-dir'); assert(ImportManager.isValidZip(testDir)); }); it('accepts a zip with an image directory', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-image-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-image-dir'); assert(ImportManager.isValidZip(testDir)); }); it('accepts a zip with a content directory', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-content-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-content-dir'); assert(ImportManager.isValidZip(testDir)); }); it('accepts a zip with a content/images directory', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-content-images-subdir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-content-images-subdir'); assert(ImportManager.isValidZip(testDir)); }); it('accepts a zip with a media directory', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-media-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-media-dir'); assert(ImportManager.isValidZip(testDir)); }); it('accepts a zip with a files directory', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-files-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-files-dir'); assert(ImportManager.isValidZip(testDir)); }); it('accepts a zip with uppercase image extensions', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-uppercase-extensions'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-uppercase-extensions'); assert(ImportManager.isValidZip(testDir)); }); it('fails a zip with two base directories', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-with-double-base-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-with-double-base-dir'); assert.throws(ImportManager.isValidZip.bind(ImportManager, testDir), errors.UnsupportedMediaTypeError); }); it('fails a zip with no content', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-invalid'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-invalid'); assert.throws(ImportManager.isValidZip.bind(ImportManager, testDir), errors.UnsupportedMediaTypeError); }); @@ -289,7 +289,7 @@ describe('Importer', function () { }); it('accepts a zip with a base directory', async function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-with-base-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-with-base-dir'); const extractSpy = sinon.stub(ImportManager, 'extractZip').returns(Promise.resolve(testDir)); const zipResult = await ImportManager.processZip(testZip); @@ -299,7 +299,7 @@ describe('Importer', function () { }); it('accepts a zip without a base directory', async function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-without-base-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-without-base-dir'); const extractSpy = sinon.stub(ImportManager, 'extractZip').returns(Promise.resolve(testDir)); const zipResult = await ImportManager.processZip(testZip); @@ -309,7 +309,7 @@ describe('Importer', function () { }); it('accepts a zip with an image directory', async function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-image-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-image-dir'); const extractSpy = sinon.stub(ImportManager, 'extractZip').returns(Promise.resolve(testDir)); const zipResult = await ImportManager.processZip(testZip); @@ -319,7 +319,7 @@ describe('Importer', function () { }); it('accepts a zip with uppercase image extensions', async function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-uppercase-extensions'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-uppercase-extensions'); const extractSpy = sinon.stub(ImportManager, 'extractZip').returns(Promise.resolve(testDir)); const zipResult = await ImportManager.processZip(testZip); @@ -329,7 +329,7 @@ describe('Importer', function () { }); it('throws zipContainsMultipleDataFormats', async function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-multiple-data-formats'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-multiple-data-formats'); const extractSpy = sinon.stub(ImportManager, 'extractZip').returns(Promise.resolve(testDir)); await assert.rejects(ImportManager.processZip(testZip), /multiple data formats/); @@ -337,7 +337,7 @@ describe('Importer', function () { }); it('throws noContentToImport', async function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-empty'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-empty'); const extractSpy = sinon.stub(ImportManager, 'extractZip').returns(Promise.resolve(testDir)); await assert.rejects(ImportManager.processZip(testZip), /not include any content/); @@ -347,31 +347,31 @@ describe('Importer', function () { describe('Get Base Dir', function () { it('returns string for base directory', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-with-base-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-with-base-dir'); assert.equal(ImportManager.getBaseDirectory(testDir), 'basedir'); }); it('returns string for double base directory', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-with-double-base-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-with-double-base-dir'); assert.equal(ImportManager.getBaseDirectory(testDir), 'basedir'); }); it('returns empty for no base directory', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-without-base-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-without-base-dir'); assert.equal(ImportManager.getBaseDirectory(testDir), undefined); }); it('returns empty for content handler directories', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-image-dir'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-image-dir'); assert.equal(ImportManager.getBaseDirectory(testDir), undefined); }); it('throws invalidZipFileBaseDirectory', function () { - const testDir = path.resolve('test/utils/fixtures/import/zips/zip-empty'); + const testDir = path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-empty'); assert.throws(() => ImportManager.getBaseDirectory(testDir), /invalid zip file/i); }); @@ -381,7 +381,7 @@ describe('Importer', function () { it('can call extract and error correctly', async function () { // Deliberately pass something that can't be extracted just to check this method signature is working await assert.rejects( - ImportManager.extractZip('test/utils/fixtures/import/zips/zip-with-base-dir'), + ImportManager.extractZip(path.resolve(__dirname, '../../../../utils/fixtures/import/zips/zip-with-base-dir')), (err) => { assert.match(err.message, /EISDIR/); assert.match(err.code, /EISDIR/); diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index b9724e01d4c..852a2a14f0a 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -36,7 +36,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route describe('DB version integrity', function () { // Only these variables should need updating const currentSchemaHash = '022588f95456319debb9a270b24b4564'; - const currentFixturesHash = 'b76d01321e02fb99b11e7a29f91859f7'; + const currentFixturesHash = '823aa0e8a8f083e80e271c47836a7e5d'; const currentSettingsHash = '397be8628c753b1959b8954d5610f83f'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; diff --git a/ghost/core/test/unit/server/models/user.test.js b/ghost/core/test/unit/server/models/user.test.js index ef3ea192842..4b1a140349d 100644 --- a/ghost/core/test/unit/server/models/user.test.js +++ b/ghost/core/test/unit/server/models/user.test.js @@ -12,6 +12,49 @@ describe('Unit: models/user', function () { sinon.restore(); }); + describe('lock method', function () { + function lockUser(status) { + const save = sinon.stub().resolves(); + const instance = { + get(key) { + if (key === 'status') { + return status; + } + return undefined; + }, + save + }; + models.User.prototype.lock.call(instance, {transacting: 'tx'}); + return save; + } + + it('rotates the password and transitions an active user to locked', function () { + const save = lockUser('active'); + const update = save.firstCall.args[0]; + assert.equal(update.status, 'locked'); + assert.equal(typeof update.password, 'string'); + assert.ok(update.password.length > 0); + }); + + it('rotates the password on a suspended user but preserves inactive status', function () { + // The compromised credential is invalidated for the inactive + // account too, but the account stays on the suspended-signin + // path rather than gaining a password-reset path it shouldn't have. + const save = lockUser('inactive'); + const update = save.firstCall.args[0]; + assert.equal(update.status, undefined, 'status is left unchanged for inactive users'); + assert.equal(typeof update.password, 'string'); + assert.ok(update.password.length > 0); + }); + + it('rotates the password and transitions an already-locked user (still locked)', function () { + const save = lockUser('locked'); + const update = save.firstCall.args[0]; + assert.equal(update.status, 'locked'); + assert.equal(typeof update.password, 'string'); + }); + }); + describe('updateLastSeen method', function () { it('exists', function () { assert.equal(typeof models.User.prototype.updateLastSeen, 'function'); diff --git a/ghost/core/test/unit/server/services/auth/reset-authentication.test.ts b/ghost/core/test/unit/server/services/auth/reset-authentication.test.ts new file mode 100644 index 00000000000..f1d6b8c2d76 --- /dev/null +++ b/ghost/core/test/unit/server/services/auth/reset-authentication.test.ts @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import resetAuthentication from '../../../../../core/server/services/auth/reset-authentication'; +import {AutoFillingMap} from '../../../../../core/server/lib/auto-filling-map'; +import type {InternalApiKey, InternalIntegrationSlug} from '../../../../../core/server/services/internal-keys'; + +interface ActionRow { + event: string; + resource_type: string; + actor_id: string; + context: {action_name: string; api_keys_rotated: number; users_locked: number}; +} + +type TxCallback = (_tx: object) => Promise; + +/** + * In-memory pretend of the auth-domain modules. We pass it as overrides so + * the test exercises the real orchestration body but observes outcomes + * through state we control. + */ +function buildAuthDomain({apiKeysToRotate, usersToLock, currentKey}: {apiKeysToRotate: number; usersToLock: number; currentKey: {id: string; secret: string}}) { + const recorded = { + actions: [] as ActionRow[], + sessionsDeleted: false, + cacheCleared: false, + committed: false + }; + + const models = { + Base: { + transaction: async (cb: TxCallback) => { + const result = await cb({label: 'tx'}); + recorded.committed = true; + return result; + } + }, + ApiKey: { + refreshAllSecrets: async () => ({count: apiKeysToRotate}) + }, + Action: { + add: async (payload: ActionRow) => { + recorded.actions.push(payload); + return {}; + } + } + }; + + const internalKeys = new AutoFillingMap>( + (slug) => { + throw new Error(`Test internalKeys not seeded for slug ${slug}`); + } + ); + internalKeys.set('ghost-scheduler', Promise.resolve(currentKey)); + const originalClear = internalKeys.clear.bind(internalKeys); + internalKeys.clear = () => { + recorded.cacheCleared = true; + originalClear(); + }; + + const deleteAllSessions = async () => { + recorded.sessionsDeleted = true; + }; + + const userService = {lockAll: async () => ({count: usersToLock})}; + + return {models, internalKeys, deleteAllSessions, userService, recorded}; +} + +describe('resetAuthentication', function () { + it('rotates keys, locks users, writes audit row with counts, returns counts', async function () { + const env = buildAuthDomain({apiKeysToRotate: 4, usersToLock: 3, currentKey: {id: 'k', secret: 'old'}}); + const adapter = {rescheduleAll: async () => {}}; + + const result = await resetAuthentication({ + schedulerAdapter: adapter, + userService: env.userService, + options: {context: {user: 'user-1'}}, + models: env.models, + internalKeys: env.internalKeys, + deleteAllSessions: env.deleteAllSessions + }); + + assert.deepEqual(result, {apiKeysRotated: 4, usersLocked: 3}); + assert.equal(env.recorded.committed, true); + assert.equal(env.recorded.actions.length, 1); + assert.equal(env.recorded.actions[0].event, 'edited'); + assert.equal(env.recorded.actions[0].resource_type, 'security_action'); + assert.equal(env.recorded.actions[0].actor_id, 'user-1'); + assert.equal(env.recorded.actions[0].context.action_name, 'reset_authentication'); + assert.equal(env.recorded.actions[0].context.api_keys_rotated, 4); + assert.equal(env.recorded.actions[0].context.users_locked, 3); + }); + + it('asks the scheduler adapter to reschedule with the pre-rotation key', async function () { + const env = buildAuthDomain({apiKeysToRotate: 1, usersToLock: 1, currentKey: {id: 'k', secret: 'pre-rotation'}}); + let observed: {id: string; secret: string} | undefined; + + await resetAuthentication({ + schedulerAdapter: {rescheduleAll: async (opts) => { + observed = opts.previousKey; + }}, + userService: env.userService, + options: {context: {user: 'user-1'}}, + models: env.models, + internalKeys: env.internalKeys, + deleteAllSessions: env.deleteAllSessions + }); + + assert.deepEqual(observed, {id: 'k', secret: 'pre-rotation'}); + }); + + it('skips the audit row when no actor is in context', async function () { + const env = buildAuthDomain({apiKeysToRotate: 1, usersToLock: 0, currentKey: {id: 'k', secret: 's'}}); + + await resetAuthentication({ + schedulerAdapter: {rescheduleAll: async () => {}}, + userService: env.userService, + options: {}, + models: env.models, + internalKeys: env.internalKeys, + deleteAllSessions: env.deleteAllSessions + }); + + assert.equal(env.recorded.actions.length, 0); + }); + + it('rolls back rotation and skips sessions + reschedule when lock fails', async function () { + const env = buildAuthDomain({apiKeysToRotate: 2, usersToLock: 0, currentKey: {id: 'k', secret: 's'}}); + let rescheduleCalled = false; + + await assert.rejects( + resetAuthentication({ + schedulerAdapter: {rescheduleAll: async () => { + rescheduleCalled = true; + }}, + userService: {lockAll: async () => { + throw new Error('lock failed'); + }}, + options: {context: {user: 'user-1'}}, + models: env.models, + internalKeys: env.internalKeys, + deleteAllSessions: env.deleteAllSessions + }), + /lock failed/ + ); + + assert.equal(env.recorded.actions.length, 0, 'audit row not written on rollback'); + assert.equal(env.recorded.sessionsDeleted, false, 'sessions are not wiped on rollback'); + assert.equal(env.recorded.cacheCleared, false, 'internal-keys cache not cleared on rollback'); + assert.equal(rescheduleCalled, false, 'adapter is not asked to reschedule on rollback'); + }); + + it('wipes sessions before asking the adapter to reschedule', async function () { + const env = buildAuthDomain({apiKeysToRotate: 1, usersToLock: 1, currentKey: {id: 'k', secret: 's'}}); + let sessionsWipedBeforeReschedule = false; + + await resetAuthentication({ + schedulerAdapter: {rescheduleAll: async () => { + sessionsWipedBeforeReschedule = env.recorded.sessionsDeleted; + }}, + userService: env.userService, + options: {context: {user: 'user-1'}}, + models: env.models, + internalKeys: env.internalKeys, + deleteAllSessions: env.deleteAllSessions + }); + + assert.equal(sessionsWipedBeforeReschedule, true); + }); +}); diff --git a/ghost/core/test/unit/server/services/automations/index.test.js b/ghost/core/test/unit/server/services/automations/index.test.js index 042085cc759..e70f01bcda5 100644 --- a/ghost/core/test/unit/server/services/automations/index.test.js +++ b/ghost/core/test/unit/server/services/automations/index.test.js @@ -1,22 +1,26 @@ const sinon = require('sinon'); -const AutomationsService = require('../../../../../core/server/services/automations'); const StartAutomationsPollEvent = require('../../../../../core/server/services/automations/events/start-automations-poll-event'); -describe('AutomationsService', function () { - let service; +const automationsModulePath = require.resolve('../../../../../core/server/services/automations'); + +describe('automations service', function () { + let automations; let domainEvents; let schedulerAdapter; let initOptions; beforeEach(function () { - service = new AutomationsService(); + // Reset the module-level singleton between tests. + delete require.cache[automationsModulePath]; + automations = require(automationsModulePath); domainEvents = { dispatch: sinon.stub(), subscribe: sinon.stub() }; schedulerAdapter = { - schedule: sinon.stub() + schedule: sinon.stub(), + register: sinon.stub() }; initOptions = { domainEvents, @@ -34,22 +38,38 @@ describe('AutomationsService', function () { describe('init', function () { it('dispatches a StartAutomationsPollEvent', function () { - service.init(initOptions); - + automations.init(initOptions); sinon.assert.calledWith(domainEvents.dispatch, sinon.match.instanceOf(StartAutomationsPollEvent)); }); it('subscribes to StartAutomationsPollEvent', function () { - service.init(initOptions); - + automations.init(initOptions); sinon.assert.calledOnceWithExactly(domainEvents.subscribe, StartAutomationsPollEvent, sinon.match.func); }); it('subscribes only once when init is called multiple times', function () { - service.init(initOptions); - service.init(initOptions); - + automations.init(initOptions); + automations.init(initOptions); sinon.assert.calledOnce(domainEvents.subscribe); }); }); + + describe('rescheduleAll', function () { + it('dispatches a fresh StartAutomationsPollEvent', function () { + automations.init(initOptions); + domainEvents.dispatch.resetHistory(); + + automations.rescheduleAll(); + + sinon.assert.calledOnceWithExactly( + domainEvents.dispatch, + sinon.match.instanceOf(StartAutomationsPollEvent) + ); + }); + + it('is a no-op before init', function () { + automations.rescheduleAll(); + sinon.assert.notCalled(domainEvents.dispatch); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/gifts/gift-reminder-scheduler.test.ts b/ghost/core/test/unit/server/services/gifts/gift-reminder-scheduler.test.ts new file mode 100644 index 00000000000..4f67b8b6b7c --- /dev/null +++ b/ghost/core/test/unit/server/services/gifts/gift-reminder-scheduler.test.ts @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +import {GiftReminderScheduler} from '../../../../../core/server/services/gifts/gift-reminder-scheduler'; +import {Gift} from '../../../../../core/server/services/gifts/gift'; +import {AutoFillingMap} from '../../../../../core/server/lib/auto-filling-map'; +import type {InternalApiKey, InternalIntegrationSlug} from '../../../../../core/server/services/internal-keys'; +import {buildGift} from './utils'; + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * Build an in-memory pretend of the cross-domain deps the scheduler takes. + * Tests assert on the queued jobs (the observable outcome) and on which + * repository rows were consulted; same-domain primitives (getSignedAdminToken, + * urlUtils) are real imports inside the class. + */ +// Test secrets are 64-char hex so getSignedAdminToken (which decodes via +// Buffer.from(secret, 'hex')) treats them as distinct signing keys. +const HEX_CURRENT = 'aa'.repeat(32); +const HEX_OLD = '55'.repeat(32); + +function buildDeps(overrides: { + apiUrl?: string; + pending?: Gift[]; + currentKey?: InternalApiKey; + register?: sinon.SinonStub; +} = {}) { + const apiUrl = overrides.apiUrl ?? 'https://example.com/ghost/api/admin'; + const currentKey: InternalApiKey = overrides.currentKey ?? {id: 'kid', secret: HEX_CURRENT}; + const internalKeys = new AutoFillingMap>( + (slug) => { + throw new Error(`Test internalKeys not seeded for slug ${slug}`); + } + ); + internalKeys.set('ghost-scheduler', Promise.resolve(currentKey)); + + const schedule = sinon.stub(); + const unschedule = sinon.stub(); + const register = overrides.register ?? sinon.stub(); + const run = sinon.stub(); + + const findUnsentReminders = sinon.stub<[], Promise>().resolves(overrides.pending ?? []); + + return { + apiUrl, + adapter: {schedule, unschedule, register, run}, + internalKeys, + findUnsentReminders, + currentKey + }; +} + +function futureGift(daysAhead: number) { + return buildGift({ + token: `tok-${daysAhead}`, + status: 'redeemed', + redeemerMemberId: 'm_1', + redeemedAt: new Date(), + consumesAt: new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000) + }); +} + +describe('GiftReminderScheduler', function () { + afterEach(function () { + sinon.restore(); + }); + + it('registers itself with the adapter on construction', function () { + const deps = buildDeps(); + const scheduler = new GiftReminderScheduler(deps); + + sinon.assert.calledOnceWithExactly(deps.adapter.register, scheduler); + }); + + describe('scheduleFor', function () { + it('queues a reminder 7 days before consumesAt, signed with the current key', async function () { + const deps = buildDeps(); + const scheduler = new GiftReminderScheduler(deps); + const gift = futureGift(30); + + await scheduler.scheduleFor(gift); + + sinon.assert.calledOnce(deps.adapter.schedule); + const [job] = deps.adapter.schedule.getCall(0).args; + assert.equal(job.time, gift.consumesAt!.getTime() - SEVEN_DAYS_MS); + assert.equal(job.extra.httpMethod, 'PUT'); + assert.ok(job.url.startsWith(`${deps.apiUrl}/gifts/flush_reminders?token=`), + 'the URL targets the flush_reminders endpoint and carries a JWT'); + }); + + it('does not queue when the gift has no consumesAt', async function () { + const deps = buildDeps(); + const scheduler = new GiftReminderScheduler(deps); + + await scheduler.scheduleFor(buildGift({consumesAt: null})); + + sinon.assert.notCalled(deps.adapter.schedule); + }); + + it('does not queue when the reminder time has already passed', async function () { + const deps = buildDeps(); + const scheduler = new GiftReminderScheduler(deps); + // consumesAt 1 day ahead → reminder fires at consumesAt - 7d → in the past + await scheduler.scheduleFor(futureGift(1)); + + sinon.assert.notCalled(deps.adapter.schedule); + }); + }); + + describe('rescheduleAll', function () { + it('re-signs every pending reminder under the current key', async function () { + const pending = [futureGift(30), futureGift(60)]; + const deps = buildDeps({pending, currentKey: {id: 'k', secret: HEX_CURRENT}}); + const scheduler = new GiftReminderScheduler(deps); + + await scheduler.rescheduleAll({previousKey: {id: 'k', secret: HEX_OLD}}); + + sinon.assert.calledTwice(deps.adapter.unschedule); + sinon.assert.calledTwice(deps.adapter.schedule); + + // The schedule URLs are signed under the current key; the unschedule + // URLs are signed under the previous key. Their tokens must differ + // for the adapter to find the queued entries. + const unscheduleUrls = deps.adapter.unschedule.getCalls().map(c => c.args[0].url); + const scheduleUrls = deps.adapter.schedule.getCalls().map(c => c.args[0].url); + for (let i = 0; i < pending.length; i++) { + assert.notEqual(unscheduleUrls[i], scheduleUrls[i], + `pending[${i}]: unschedule URL (old key) must differ from schedule URL (current key)`); + } + }); + + it('rotation tells the adapter to actually delete the stale queued job', async function () { + // Outcome: rotation requests a real (non-bootstrap) unschedule so + // the adapter writes a tombstone and the stale callback is + // suppressed at execution time. SchedulingDefault's own tests + // cover the tombstone semantics; here we verify GiftReminderScheduler + // honours the contract. + const deps = buildDeps({pending: [futureGift(30)]}); + const scheduler = new GiftReminderScheduler(deps); + + await scheduler.rescheduleAll({previousKey: {id: 'k', secret: HEX_OLD}}); + + sinon.assert.calledOnce(deps.adapter.unschedule); + assert.equal(deps.adapter.unschedule.getCall(0).args[1].bootstrap, false); + }); + + it('uses the current key for unschedule when previousKey is omitted', async function () { + const pending = [futureGift(30)]; + const deps = buildDeps({pending}); + const scheduler = new GiftReminderScheduler(deps); + + await scheduler.rescheduleAll(); + + sinon.assert.calledOnce(deps.adapter.unschedule); + sinon.assert.calledOnce(deps.adapter.schedule); + const unscheduleUrl = deps.adapter.unschedule.getCall(0).args[0].url; + const scheduleUrl = deps.adapter.schedule.getCall(0).args[0].url; + assert.equal(unscheduleUrl, scheduleUrl, + 'with no previousKey, both URLs are signed under the same (current) key'); + }); + + it('same-key rebuild marks unschedule as bootstrap so the new job survives', async function () { + // Outcome: when no previousKey is supplied (boot), unschedule and + // schedule use the same URL. GiftReminderScheduler must mark the + // unschedule as bootstrap so the adapter skips the tombstone and + // the about-to-be-scheduled job stays pingable. + const deps = buildDeps({pending: [futureGift(30)]}); + const scheduler = new GiftReminderScheduler(deps); + + await scheduler.rescheduleAll(); + + sinon.assert.calledOnce(deps.adapter.unschedule); + assert.equal(deps.adapter.unschedule.getCall(0).args[1].bootstrap, true); + }); + + it('skips reminders whose fire time has already passed', async function () { + const deps = buildDeps({pending: [futureGift(1)]}); + const scheduler = new GiftReminderScheduler(deps); + + await scheduler.rescheduleAll({previousKey: {id: 'k', secret: HEX_OLD}}); + + sinon.assert.notCalled(deps.adapter.unschedule); + sinon.assert.notCalled(deps.adapter.schedule); + }); + + it('is a no-op when the repository has nothing pending', async function () { + const deps = buildDeps({pending: []}); + const scheduler = new GiftReminderScheduler(deps); + + await scheduler.rescheduleAll({previousKey: {id: 'k', secret: HEX_OLD}}); + + sinon.assert.notCalled(deps.adapter.schedule); + sinon.assert.notCalled(deps.adapter.unschedule); + }); + }); +}); diff --git a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts index 00700616627..fc41f3a64bf 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts @@ -38,6 +38,7 @@ describe('GiftService', function () { findPendingConsumption: sinon.SinonStub<[], Promise>; findPendingExpiration: sinon.SinonStub<[], Promise>; findPendingReminder: sinon.SinonStub<[FindPendingReminderOptions], Promise>; + findUnsentReminders: sinon.SinonStub<[], Promise>; create: sinon.SinonStub; update: sinon.SinonStub; transaction: sinon.SinonStub, Promise>; @@ -86,6 +87,7 @@ describe('GiftService', function () { findPendingConsumption: sinon.stub<[], Promise>().resolves([]), findPendingExpiration: sinon.stub<[], Promise>().resolves([]), findPendingReminder: sinon.stub<[FindPendingReminderOptions], Promise>().resolves([]), + findUnsentReminders: sinon.stub<[], Promise>().resolves([]), create: sinon.stub(), update: sinon.stub(), transaction: sinon.stub, Promise>().callsFake(async (callback) => { @@ -128,24 +130,21 @@ describe('GiftService', function () { sinon.restore(); }); + let giftReminderScheduler: {scheduleFor: sinon.SinonStub}; + function createService(overrides: { - schedulerAdapter?: {schedule: sinon.SinonStub} | null; - getSchedulerKey?: (() => Promise<{id: string; secret: string}>) | null; - getSignedAdminToken?: sinon.SinonStub | null; - urlJoin?: sinon.SinonStub | null; - apiUrl?: string | null; + giftReminderScheduler?: {scheduleFor: sinon.SinonStub}; } = {}) { + giftReminderScheduler = overrides.giftReminderScheduler ?? { + scheduleFor: sinon.stub().resolves() + }; return new GiftService({ giftRepository: giftRepository as any, memberRepository, tiersService, giftEmailService, staffServiceEmails, - schedulerAdapter: overrides.schedulerAdapter ?? null, - getSchedulerKey: overrides.getSchedulerKey ?? null, - getSignedAdminToken: overrides.getSignedAdminToken ?? null, - urlJoin: overrides.urlJoin ?? null, - apiUrl: overrides.apiUrl ?? null + giftReminderScheduler }); } @@ -1331,177 +1330,67 @@ describe('GiftService', function () { }); }); - describe('scheduleReminder (via redeem)', function () { - const apiUrl = 'https://example.com/ghost/api/admin'; - - function buildSchedulerDeps() { - const schedule = sinon.stub(); - const getSignedAdminToken = sinon.stub().returns('signed-token'); - const urlJoin = sinon.stub().callsFake((...parts: string[]) => parts.join('/')); - const key = {id: 'key-id', secret: '00'.repeat(32)}; - const getSchedulerKey = sinon.stub().resolves(key); - - return { - schedulerAdapter: {schedule}, - key, - getSchedulerKey, - getSignedAdminToken, - urlJoin, - apiUrl - }; - } - + describe('redeem delegates reminder scheduling', function () { function stubRedeemer() { const memberGet = sinon.stub(); - memberGet.withArgs('status').returns('free'); memberGet.withArgs('name').returns('Member Name'); memberGet.withArgs('email').returns('member@example.com'); - memberRepository.get.resolves({id: 'member_1', get: memberGet}); } - it('schedules a reminder 7 days before consumes_at after redeem commits', async function () { - stubRedeemer(); - - const gift = buildGift(); - giftRepository.getByToken.resolves(gift); - - const deps = buildSchedulerDeps(); - const service = createService(deps); - - const redeemed = await service.redeem('gift-token', 'member_1'); - - assert.ok(redeemed.consumesAt); - const expectedTime = redeemed.consumesAt.getTime() - 7 * 24 * 60 * 60 * 1000; - - sinon.assert.calledOnce(deps.schedulerAdapter.schedule); - const [job] = deps.schedulerAdapter.schedule.getCall(0).args; - assert.equal(job.time, expectedTime); - assert.equal(job.extra.httpMethod, 'PUT'); - assert.equal(job.url, `${apiUrl}/gifts/flush_reminders?token=signed-token`); - - sinon.assert.calledOnceWithExactly(deps.getSignedAdminToken, { - publishedAt: new Date(expectedTime).toISOString(), - apiUrl, - key: deps.key - }); - }); - - it('does NOT schedule a reminder when scheduler deps are absent', async function () { + it('calls giftReminderScheduler.scheduleFor with the redeemed gift after commit', async function () { stubRedeemer(); + giftRepository.getByToken.resolves(buildGift()); - const gift = buildGift(); - giftRepository.getByToken.resolves(gift); - - // No scheduler deps at all const service = createService(); - await service.redeem('gift-token', 'member_1'); - - // If we got here without throwing, the no-op path worked. - // Explicit assertion: staff notification still ran. - sinon.assert.calledOnce(staffServiceEmails.notifyGiftSubscriptionStarted); - }); - - it('does NOT schedule a reminder when only some scheduler deps are present', async function () { - stubRedeemer(); - - const gift = buildGift(); - giftRepository.getByToken.resolves(gift); - - const schedule = sinon.stub(); - // schedulerAdapter provided but apiUrl missing - const service = createService({ - schedulerAdapter: {schedule}, - getSchedulerKey: null, - getSignedAdminToken: sinon.stub(), - urlJoin: sinon.stub(), - apiUrl: null - }); - - await service.redeem('gift-token', 'member_1'); - - sinon.assert.notCalled(schedule); - }); - - it('logs but does not throw when getSignedAdminToken throws', async function () { - stubRedeemer(); - - const gift = buildGift(); - giftRepository.getByToken.resolves(gift); - - const deps = buildSchedulerDeps(); - deps.getSignedAdminToken.throws(new Error('JWT signing failed')); - - const service = createService(deps); - - // Should not throw — error is caught and logged. const redeemed = await service.redeem('gift-token', 'member_1'); - assert.equal(redeemed.status, 'redeemed'); - sinon.assert.notCalled(deps.schedulerAdapter.schedule); + sinon.assert.calledOnceWithExactly(giftReminderScheduler.scheduleFor, redeemed); }); - it('schedules the reminder even when staff notification fails', async function () { + it('schedules even when staff notification fails', async function () { stubRedeemer(); - - const gift = buildGift(); - giftRepository.getByToken.resolves(gift); - + giftRepository.getByToken.resolves(buildGift()); staffServiceEmails.notifyGiftSubscriptionStarted.rejects(new Error('SMTP error')); - const deps = buildSchedulerDeps(); - const service = createService(deps); - + const service = createService(); await service.redeem('gift-token', 'member_1'); - sinon.assert.calledOnce(deps.schedulerAdapter.schedule); + sinon.assert.calledOnce(giftReminderScheduler.scheduleFor); }); - it('schedules a reminder when redeem uses an external transaction (after commit)', async function () { + it('schedules after an external transaction commits', async function () { stubRedeemer(); + giftRepository.getByToken.resolves(buildGift()); - const gift = buildGift(); - giftRepository.getByToken.resolves(gift); - - const deps = buildSchedulerDeps(); - const service = createService(deps); - + const service = createService(); const externalTrx = {executionPromise: Promise.resolve()}; await service.redeem('gift-token', 'member_1', {transacting: externalTrx}); - // Wait for the post-commit notify callback to run. await externalTrx.executionPromise; - // One more microtask flush for the .then chain. await new Promise((resolve) => { setImmediate(resolve); }); - sinon.assert.calledOnce(deps.schedulerAdapter.schedule); + sinon.assert.calledOnce(giftReminderScheduler.scheduleFor); }); - it('does NOT schedule a reminder when an external transaction rolls back', async function () { + it('does NOT schedule when an external transaction rolls back', async function () { stubRedeemer(); + giftRepository.getByToken.resolves(buildGift()); - const gift = buildGift(); - giftRepository.getByToken.resolves(gift); - - const deps = buildSchedulerDeps(); - const service = createService(deps); - + const service = createService(); const rejection = Promise.reject(new Error('rolled back')); - // Suppress unhandled rejection warning rejection.catch(() => {}); const externalTrx = {executionPromise: rejection}; - await service.redeem('gift-token', 'member_1', {transacting: externalTrx}); - // Wait for the .then(notify, () => {}) reject branch. await new Promise((resolve) => { setImmediate(resolve); }); - sinon.assert.notCalled(deps.schedulerAdapter.schedule); + sinon.assert.notCalled(giftReminderScheduler.scheduleFor); }); }); diff --git a/ghost/core/test/unit/server/services/post-scheduling/post-scheduler-service.test.js b/ghost/core/test/unit/server/services/post-scheduling/post-scheduling.test.js similarity index 55% rename from ghost/core/test/unit/server/services/post-scheduling/post-scheduler-service.test.js rename to ghost/core/test/unit/server/services/post-scheduling/post-scheduling.test.js index e9f21a48a37..dd3987d88a1 100644 --- a/ghost/core/test/unit/server/services/post-scheduling/post-scheduler-service.test.js +++ b/ghost/core/test/unit/server/services/post-scheduling/post-scheduling.test.js @@ -1,5 +1,4 @@ const assert = require('node:assert/strict'); -const errors = require('@tryghost/errors'); const sinon = require('sinon'); const moment = require('moment'); const testUtils = require('../../../../utils'); @@ -8,16 +7,15 @@ const events = require('../../../../../core/server/lib/common/events'); const schedulingUtils = require('../../../../../core/server/adapters/scheduling/utils'); const SchedulingDefault = require('../../../../../core/server/adapters/scheduling/scheduling-default'); const urlUtils = require('../../../../../core/shared/url-utils'); -const PostSchedulerService = require('../../../../../core/server/services/post-scheduling/post-scheduler-service'); +const PostScheduling = require('../../../../../core/server/services/post-scheduling/post-scheduling').default; const nock = require('nock'); -describe('Post Scheduler Service', function () { +describe('PostScheduling', function () { let adapter; let internalKeys; beforeEach(function () { adapter = new SchedulingDefault(); - sinon.stub(schedulingUtils, 'createAdapter').returns(Promise.resolve(adapter)); sinon.spy(adapter, 'schedule'); sinon.spy(adapter, 'unschedule'); @@ -32,20 +30,6 @@ describe('Post Scheduler Service', function () { }); describe('constructor', function () { - it('throws when apiUrl is missing', function () { - assert.throws( - () => new PostSchedulerService(), - err => err instanceof errors.IncorrectUsageError - ); - }); - - it('throws when internalKeys is missing', function () { - assert.throws( - () => new PostSchedulerService({apiUrl: 'http://scheduler.local:1111/'}), - err => err instanceof errors.IncorrectUsageError - ); - }); - it('wires event handlers and starts the adapter', async function () { const post = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({ id: 1337, @@ -55,12 +39,7 @@ describe('Post Scheduler Service', function () { nock('http://scheduler.local:1111').post(() => true).query(true).reply(200); nock('http://scheduler.local:1111').put(() => true).query(true).reply(200); - new PostSchedulerService({ - apiUrl: 'http://scheduler.local:1111/', - internalKeys, - adapter, - events - }); + new PostScheduling({apiUrl: 'http://scheduler.local:1111/', internalKeys, adapter}); events.emit('post.scheduled', post); await new Promise((resolve) => { @@ -77,28 +56,27 @@ describe('Post Scheduler Service', function () { }); }); - describe('reschedule', function () { - it('unschedules with the previous key and reschedules with the current key', async function () { + describe('rescheduleAll', function () { + function stubScheduledPost() { const post = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({ id: 4004, mobiledoc: testUtils.DataGenerator.markdownToMobiledoc('something') })); + sinon.stub(models.Post, 'findAll').callsFake(({filter}) => { + return Promise.resolve(filter.includes('type:post') ? [post] : []); + }); + return post; + } + it('unschedules with the previous key and reschedules with the current key', async function () { + stubScheduledPost(); internalKeys = new Map([ ['ghost-scheduler', Promise.resolve({id: 'k1', secret: 'aaaabbbb'})] ]); - const service = new PostSchedulerService({ - apiUrl: 'http://scheduler.local:1111/', - internalKeys, - adapter, - events - }); + const service = new PostScheduling({apiUrl: 'http://scheduler.local:1111/', internalKeys, adapter}); - await service.reschedule( - {post: [post], page: []}, - {previousKey: {id: 'k1', secret: 'ccccdddd'}} - ); + await service.rescheduleAll({previousKey: {id: 'k1', secret: 'ccccdddd'}}); sinon.assert.calledOnce(adapter.unschedule); sinon.assert.calledOnce(adapter.schedule); @@ -108,5 +86,41 @@ describe('Post Scheduler Service', function () { 'unschedule URL (signed with old key) must differ from schedule URL (signed with new key)' ); }); + + it('rotation tells the adapter to actually delete the stale queued job', async function () { + // Outcome: rotation requests a real (non-bootstrap) unschedule of + // the previous-key URL, so the adapter writes a tombstone and the + // stale callback is suppressed at execution time. Without this, + // the old URL keeps firing and the server logs 401s. SchedulingDefault's + // own tests cover the tombstone semantics; here we verify + // PostScheduling honours the contract. + stubScheduledPost(); + internalKeys = new Map([ + ['ghost-scheduler', Promise.resolve({id: 'k1', secret: 'aaaabbbb'})] + ]); + + const service = new PostScheduling({apiUrl: 'http://scheduler.local:1111/', internalKeys, adapter}); + await service.rescheduleAll({previousKey: {id: 'k1', secret: 'ccccdddd'}}); + + sinon.assert.calledOnce(adapter.unschedule); + assert.equal(adapter.unschedule.args[0][1].bootstrap, false); + }); + + it('same-key rebuild marks unschedule as bootstrap so the new job survives', async function () { + // Outcome: when no previousKey is supplied (boot), unschedule and + // schedule use the same URL. PostScheduling must mark the + // unschedule as bootstrap so the adapter skips the tombstone and + // the about-to-be-scheduled job stays pingable. + stubScheduledPost(); + internalKeys = new Map([ + ['ghost-scheduler', Promise.resolve({id: 'k1', secret: 'aaaabbbb'})] + ]); + + const service = new PostScheduling({apiUrl: 'http://scheduler.local:1111/', internalKeys, adapter}); + await service.rescheduleAll(); + + sinon.assert.calledOnce(adapter.unschedule); + assert.equal(adapter.unschedule.args[0][1].bootstrap, true); + }); }); }); diff --git a/ghost/core/test/unit/server/services/settings/private-site-access-code.test.js b/ghost/core/test/unit/server/services/settings/private-site-access-code.test.js index dbcfdb58ccf..0f96f886c9d 100644 --- a/ghost/core/test/unit/server/services/settings/private-site-access-code.test.js +++ b/ghost/core/test/unit/server/services/settings/private-site-access-code.test.js @@ -1,20 +1,29 @@ const assert = require('node:assert/strict'); -const {generatePrivateSiteAccessCode} = require('../../../../../core/server/services/settings/private-site-access-code'); +const {ACCESS_CODE_WORDS, generatePrivateSiteAccessCode} = require('../../../../../core/server/services/settings/private-site-access-code'); describe('UNIT > private-site-access-code', function () { - it('returns a placeholder string matching the fake-### format', function () { + it('returns a curated word with a three-digit suffix', function () { for (let i = 0; i < 50; i++) { const code = generatePrivateSiteAccessCode(); - assert.match(code, /^fake-\d{3}$/); + assert.match(code, /^[a-z]+\d{3}$/); + + const word = code.replace(/\d{3}$/, ''); + assert.ok(ACCESS_CODE_WORDS.includes(word), `expected ${word} to be in the curated word list`); } }); + it('uses a 48-word lowercase list with no duplicate entries', function () { + assert.equal(ACCESS_CODE_WORDS.length, 48); + assert.equal(new Set(ACCESS_CODE_WORDS).size, ACCESS_CODE_WORDS.length); + assert.ok(ACCESS_CODE_WORDS.every(word => /^[a-z]+$/.test(word))); + }); + it('produces varied codes across consecutive calls', function () { const codes = new Set(); for (let i = 0; i < 50; i++) { codes.add(generatePrivateSiteAccessCode()); } - // Just ensure the output isn't constant — 50 draws from a 1,000-value + // Just ensure the output isn't constant — 50 draws from a 48,000-value // space has a non-trivial birthday-collision rate, so any tighter // threshold would be flaky. assert.ok(codes.size > 1, `expected varied codes, got ${codes.size} distinct value(s)`); diff --git a/ghost/core/test/unit/server/services/settings/settings-service.test.js b/ghost/core/test/unit/server/services/settings/settings-service.test.js index 26eeb73b490..3f4f9b980fc 100644 --- a/ghost/core/test/unit/server/services/settings/settings-service.test.js +++ b/ghost/core/test/unit/server/services/settings/settings-service.test.js @@ -174,7 +174,7 @@ describe('UNIT: Settings Service', function () { const writes = editStub.firstCall.args[0]; const writesByKey = Object.fromEntries(writes.map(w => [w.key, w.value])); assert.equal(writesByKey.is_private, true); - assert.match(writesByKey.password, /^fake-\d{3}$/); + assert.match(writesByKey.password, /^[a-z]+\d{3}$/); assert.deepEqual(editStub.firstCall.args[1], {context: {internal: true}}); }); @@ -196,7 +196,7 @@ describe('UNIT: Settings Service', function () { sinon.stub(limits, 'isDisabled').withArgs('publicSiteAccess').returns(true); const findOneStub = sinon.stub(models.Settings, 'findOne'); findOneStub.withArgs({key: 'is_private'}).resolves(fakeSettingRow(true)); - findOneStub.withArgs({key: 'password'}).resolves(fakeSettingRow('fake-042')); + findOneStub.withArgs({key: 'password'}).resolves(fakeSettingRow('anchor042')); const editStub = sinon.stub(models.Settings, 'edit').resolves(); await settingsService.init(); diff --git a/ghost/core/test/unit/server/services/slack.test.js b/ghost/core/test/unit/server/services/slack.test.js index f0cd9b145dc..8a7c6d62e64 100644 --- a/ghost/core/test/unit/server/services/slack.test.js +++ b/ghost/core/test/unit/server/services/slack.test.js @@ -205,7 +205,10 @@ describe('Slack', function () { const wait = ms => new Promise((resolve) => { setTimeout(resolve, ms); }); - while (!loggingStub.calledOnce) { + // Bound the poll so a regression fails with a clear assertion + // below rather than stalling until the suite-wide test timeout. + const deadline = Date.now() + 1000; + while (!loggingStub.calledOnce && Date.now() < deadline) { await wait(50); } sinon.assert.calledOnce(makeRequestStub); diff --git a/ghost/core/test/unit/server/services/users/users-service.test.js b/ghost/core/test/unit/server/services/users/users-service.test.js index a625a22c47e..84536b2bc7e 100644 --- a/ghost/core/test/unit/server/services/users/users-service.test.js +++ b/ghost/core/test/unit/server/services/users/users-service.test.js @@ -5,49 +5,115 @@ const DomainEvents = require('@tryghost/domain-events/lib/DomainEvents'); const Users = require('../../../../../core/server/services/users'); describe('Users service', function () { - describe('resetAllPasswords', function () { - it('resets all user passwords', async function () { - const userToReset = { - save: sinon.mock().resolves(), - get: sinon.mock().withArgs('email').returns('test_email@example.com') - }; - const mockOptions = { - dbBackup: { - backup: sinon.mock().resolves('backup/path/file.json') + describe('lockAll', function () { + function makeUser({email = 'test_email@example.com', status = 'active'} = {}) { + const user = { + status, + locked: false, + passwordRotated: false, + lock() { + // Mirror the model: rotate the password on every user, + // transition to `locked` unless already inactive. + this.passwordRotated = true; + if (this.status !== 'inactive') { + this.status = 'locked'; + } + this.locked = true; + return Promise.resolve(); }, - models: { - Base: { - transaction: (cb) => { - return cb('fake_transaction'); - } - }, - User: { - findAll: sinon.mock().resolves([userToReset]) + get(key) { + if (key === 'email') { + return email; } + if (key === 'status') { + return this.status; + } + return undefined; + } + }; + return user; + } + + function makeService({users} = {users: [makeUser()]}) { + const findAll = () => Promise.resolve({models: users}); + + return new Users({ + dbBackup: {backup: sinon.stub().resolves()}, + models: { + Base: {transaction: cb => cb('fake_transaction')}, + User: {findAll} }, auth: { passwordreset: { - generateToken: sinon.mock().resolves('secret_fake_token'), - sendResetNotification: sinon.mock().resolves('reset_notification_sent') + generateToken: sinon.stub().resolves('secret_fake_token'), + sendResetNotification: sinon.stub().resolves() } }, apiMail: 'fake_api_mail', apiSettings: 'fake_api_settings' - }; - const usersService = new Users(mockOptions); + }); + } - await usersService.resetAllPasswords({ - context: {} + it('locks every user', async function () { + const a = makeUser({email: 'a@example.com'}); + const b = makeUser({email: 'b@example.com'}); + const usersService = makeService({users: [a, b]}); + + const result = await usersService.lockAll({context: {}}); + + assert.equal(result.count, 2); + assert.equal(a.locked, true); + assert.equal(b.locked, true); + }); + + it('does not proactively send any reset emails', async function () { + const usersService = makeService({ + users: [makeUser({email: 'a@example.com'}), makeUser({email: 'b@example.com'})] }); - sinon.assert.calledOnce(mockOptions.auth.passwordreset.generateToken); - assert.equal(mockOptions.auth.passwordreset.generateToken.args[0][0], 'test_email@example.com'); - assert.equal(mockOptions.auth.passwordreset.generateToken.args[0][1], 'fake_api_settings'); - assert.equal(mockOptions.auth.passwordreset.generateToken.args[0][2], 'fake_transaction'); + await usersService.lockAll({context: {}}); + + sinon.assert.notCalled(usersService.auth.passwordreset.generateToken); + sinon.assert.notCalled(usersService.auth.passwordreset.sendResetNotification); + }); + + it('returns count=0 when no users match', async function () { + const usersService = makeService({users: []}); + + const result = await usersService.lockAll({context: {}}); + + assert.equal(result.count, 0); + }); + + it('rotates passwords on suspended users while preserving suspension', async function () { + const active = makeUser({email: 'active@example.com', status: 'active'}); + const suspended = makeUser({email: 'suspended@example.com', status: 'inactive'}); + const usersService = makeService({users: [active, suspended]}); + + const result = await usersService.lockAll({context: {}}); + + assert.equal(result.count, 2); + assert.equal(active.status, 'locked', 'active users transition to locked'); + assert.equal(active.passwordRotated, true); + assert.equal(suspended.status, 'inactive', 'suspended users stay suspended'); + assert.equal(suspended.passwordRotated, true, 'suspended users still get their password rotated so a compromised credential cannot survive a future unsuspend'); + }); + + it('reuses an outer transaction when one is provided', async function () { + const a = makeUser({email: 'a@example.com'}); + const usersService = makeService({users: [a]}); + + let openedOwnTx = false; + usersService.models.Base.transaction = (cb) => { + openedOwnTx = true; + return cb('fake_transaction'); + }; + + const result = await usersService.lockAll({context: {}, transacting: 'outer-tx'}); - sinon.assert.calledOnce(mockOptions.auth.passwordreset.sendResetNotification); - assert.equal(mockOptions.auth.passwordreset.sendResetNotification.args[0][0], 'secret_fake_token'); - assert.equal(mockOptions.auth.passwordreset.sendResetNotification.args[0][1], 'fake_api_mail'); + assert.equal(result.count, 1); + assert.equal(a.locked, true); + assert.equal(openedOwnTx, false, 'no inner transaction is opened when one is passed in'); }); }); diff --git a/ghost/core/test/unit/server/web/admin/controller.test.js b/ghost/core/test/unit/server/web/admin/controller.test.js index 1831e253565..2b146197d84 100644 --- a/ghost/core/test/unit/server/web/admin/controller.test.js +++ b/ghost/core/test/unit/server/web/admin/controller.test.js @@ -15,7 +15,7 @@ describe('Admin App', function () { }; await configUtils.restore(); - configUtils.set('paths:adminAssets', path.resolve('test/utils/fixtures/admin-build')); + configUtils.set('paths:adminAssets', path.resolve(__dirname, '../../../../utils/fixtures/admin-build')); }); afterEach(function () { diff --git a/ghost/core/test/utils/db-utils.js b/ghost/core/test/utils/db-utils.js index 9798a92109d..74821253722 100644 --- a/ghost/core/test/utils/db-utils.js +++ b/ghost/core/test/utils/db-utils.js @@ -2,8 +2,13 @@ const debug = require('@tryghost/debug')('test:dbUtils'); // Utility Packages const fs = require('fs-extra'); +const path = require('path'); const KnexMigrator = require('knex-migrator'); -const knexMigrator = new KnexMigrator(); +// Resolve MigratorConfig.js from the package root explicitly rather than via +// process.cwd(): the unified `pnpm test:watch` runs from the repo root, and +// worker threads cannot chdir. From ghost/core this is the same path, so it +// is a no-op for the standalone mocha/vitest runs. +const knexMigrator = new KnexMigrator({knexMigratorFilePath: path.join(__dirname, '../..')}); const DatabaseInfo = require('@tryghost/database-info'); // Ghost Internals diff --git a/ghost/core/test/utils/fixture-utils.js b/ghost/core/test/utils/fixture-utils.js index 8ee80a6b2da..241782abd7f 100644 --- a/ghost/core/test/utils/fixture-utils.js +++ b/ghost/core/test/utils/fixture-utils.js @@ -6,7 +6,8 @@ const crypto = require('crypto'); const ObjectId = require('bson-objectid').default; const KnexMigrator = require('knex-migrator'); const {sequence} = require('@tryghost/promise'); -const knexMigrator = new KnexMigrator(); +// Resolve MigratorConfig.js from the package root, not process.cwd() — see db-utils.js. +const knexMigrator = new KnexMigrator({knexMigratorFilePath: path.join(__dirname, '../..')}); // Ghost Internals const models = require('../../core/server/models'); diff --git a/ghost/core/test/utils/fixtures/fixtures.json b/ghost/core/test/utils/fixtures/fixtures.json index 50c8f31676c..9646a91d5bc 100644 --- a/ghost/core/test/utils/fixtures/fixtures.json +++ b/ghost/core/test/utils/fixtures/fixtures.json @@ -636,8 +636,8 @@ "object_type": "offer" }, { - "name": "Reset all passwords", - "action_type": "resetAllPasswords", + "name": "Reset authentication", + "action_type": "reset", "object_type": "authentication" }, { @@ -1111,7 +1111,7 @@ "snippet": "all", "custom_theme_setting": "all", "offer": "all", - "authentication": "resetAllPasswords", + "authentication": "reset", "members_stripe_connect": "auth", "newsletter": "all", "explore": "read", diff --git a/ghost/core/test/utils/vitest-setup.ts b/ghost/core/test/utils/vitest-setup.ts index fe869dbd6a4..4764994de1a 100644 --- a/ghost/core/test/utils/vitest-setup.ts +++ b/ghost/core/test/utils/vitest-setup.ts @@ -14,6 +14,13 @@ import crypto from 'node:crypto'; import chalk from 'chalk'; import {beforeAll, beforeEach, afterEach, afterAll} from 'vitest'; +// Register tsx's CommonJS hook so test files (and the Ghost server code they +// pull in) can require() .ts sources. Scoping it here — rather than a global +// NODE_OPTIONS='--import tsx' — keeps the loader out of the sibling app +// projects under the unified `pnpm test:watch`, where it breaks their module +// resolution. Must run before any Ghost source is required below. +require('tsx/cjs'); + process.env.NODE_ENV = process.env.NODE_ENV || 'testing'; process.env.WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'TEST_STRIPE_WEBHOOK_SECRET'; @@ -173,12 +180,17 @@ afterAll(async () => { // the threads pool, or a stuck event loop under forks. try { const db = require('../../core/server/data/db'); - if (process.env.NODE_ENV === 'testing-mysql' && mysqlGenerated) { - await db.knex.raw( - `DROP DATABASE IF EXISTS \`${process.env.database__connection__database}\`` - ); + try { + if (process.env.NODE_ENV === 'testing-mysql' && mysqlGenerated) { + await db.knex.raw( + `DROP DATABASE IF EXISTS \`${process.env.database__connection__database}\`` + ); + } + } finally { + // Destroy the pool even if the DROP above threw — a leaked pool + // is exactly what causes the FATAL crash / hang described above. + await db.knex.destroy(); } - await db.knex.destroy(); } catch (err) { // eslint-disable-next-line no-console console.warn('Failed to clean up test database:', (err as Error).message); diff --git a/ghost/core/vitest.config.ts b/ghost/core/vitest.config.ts index 3709a6c2512..880a1298304 100644 --- a/ghost/core/vitest.config.ts +++ b/ghost/core/vitest.config.ts @@ -1,9 +1,9 @@ import path from 'node:path'; import {defineConfig} from 'vitest/config'; -// Vitest is being introduced incrementally alongside mocha. Each PR -// expands `test.include` to cover a new bucket. Files outside the -// include glob continue to run under mocha via `pnpm test:base`. +// Vitest runs all of ghost/core's unit tests (test/unit). The DB-backed +// integration, e2e-api, and legacy suites still run under mocha via +// `pnpm test:base` — see ghost/core/package.json. export default defineConfig({ test: { globals: true, @@ -18,22 +18,9 @@ export default defineConfig({ WEBHOOK_SECRET: 'TEST_STRIPE_WEBHOOK_SECRET' }, include: [ - 'test/unit/api/**/*.test.{js,ts}', - 'test/unit/bin/**/*.test.{js,ts}', - 'test/unit/frontend/**/*.test.{js,ts}', - 'test/unit/shared/**/*.test.{js,ts}', - 'test/unit/server/adapters/**/*.test.{js,ts}', - 'test/unit/server/api/**/*.test.{js,ts}', - 'test/unit/server/data/**/*.test.{js,ts}', - 'test/unit/server/lib/**/*.test.{js,ts}', - 'test/unit/server/models/**/*.test.{js,ts}', - 'test/unit/server/services/**/*.test.{js,ts}', - 'test/unit/server/web/**/*.test.{js,ts}' + 'test/unit/**/*.test.{js,ts}' ], - // Fake-timer + nock + retry-loop interactions in this file don't - // translate cleanly to vitest's hook ordering; deferred to a follow-up. exclude: [ - 'test/unit/server/adapters/scheduling/scheduling-default.test.js', '**/node_modules/**' ], setupFiles: ['./test/utils/vitest-setup.ts'], diff --git a/package.json b/package.json index e6ea1a23189..c5f99a79624 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "lint": "pnpm nx run-many -t lint", "test": "pnpm nx run-many -t test --exclude @tryghost/e2e --exclude ghost-admin", "test:unit": "pnpm nx run-many -t test:unit", + "test:watch": "vitest", "test:e2e": "pnpm --filter @tryghost/e2e test", "test:e2e:analytics": "pnpm --filter @tryghost/e2e test:analytics", "test:e2e:all": "pnpm --filter @tryghost/e2e test:all", @@ -161,7 +162,8 @@ "rimraf": "6.1.3", "secretlint": "12.3.1", "semver": "7.7.4", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" }, "nx": { "includedScripts": [], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 228db7f9b0c..1493c78720c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,12 @@ catalogs: '@storybook/react-vite': specifier: 10.3.5 version: 10.3.5 + '@tailwindcss/postcss': + specifier: 4.2.2 + version: 4.2.2 + '@tailwindcss/vite': + specifier: 4.2.2 + version: 4.2.2 '@tanstack/react-query': specifier: 4.36.1 version: 4.36.1 @@ -159,6 +165,12 @@ catalogs: storybook: specifier: 10.3.5 version: 10.3.5 + tailwindcss: + specifier: 4.2.2 + version: 4.2.2 + tw-animate-css: + specifier: 1.4.0 + version: 1.4.0 type-fest: specifier: 4.41.0 version: 4.41.0 @@ -187,6 +199,10 @@ catalogs: eslint: specifier: 9.37.0 version: 9.37.0 + tailwind3: + tailwindcss: + specifier: 3.4.18 + version: 3.4.18 overrides: '@tryghost/errors': ^1.3.7 @@ -293,6 +309,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/activitypub: dependencies: @@ -376,14 +395,14 @@ importers: specifier: 'catalog:' version: 29.1.1(@noble/hashes@1.8.0) tailwindcss: - specifier: 4.2.2 + specifier: 'catalog:' version: 4.2.2 vite: specifier: 'catalog:' - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/admin: dependencies: @@ -428,8 +447,8 @@ importers: specifier: catalog:eslint9 version: 9.37.0 '@tailwindcss/vite': - specifier: 4.2.1 - version: 4.2.1(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + specifier: 'catalog:' + version: 4.2.2(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@tanstack/react-query': specifier: 'catalog:' version: 4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -450,7 +469,7 @@ importers: version: 18.3.7(@types/react@18.3.28) '@vitejs/plugin-react-swc': specifier: 4.1.0 - version: 4.1.0(@swc/helpers@0.5.21)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(@swc/helpers@0.5.21)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) eslint: specifier: catalog:eslint9 version: 9.37.0(jiti@2.6.1) @@ -482,7 +501,7 @@ importers: specifier: 3.0.2 version: 3.0.2 tailwindcss: - specifier: 4.2.2 + specifier: 'catalog:' version: 4.2.2 typescript: specifier: 'catalog:' @@ -492,13 +511,13 @@ importers: version: 8.58.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) vite: specifier: 'catalog:' - version: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-tsconfig-paths: specifier: 5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 5.1.4(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/admin-x-design-system: dependencies: @@ -571,16 +590,16 @@ importers: version: 3.2.2(react@18.3.1) '@storybook/addon-docs': specifier: 'catalog:' - version: 10.3.5(@types/react@18.3.28)(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) + version: 10.3.5(@types/react@18.3.28)(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) '@storybook/addon-links': specifier: 'catalog:' version: 10.3.5(react@18.3.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@storybook/react-vite': specifier: 'catalog:' - version: 10.3.5(esbuild@0.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) + version: 10.3.5(esbuild@0.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) '@tailwindcss/postcss': - specifier: 4.2.1 - version: 4.2.1 + specifier: 'catalog:' + version: 4.2.2 '@testing-library/react': specifier: 14.3.1 version: 14.3.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -604,7 +623,7 @@ importers: version: 8.49.0(eslint@8.57.1)(typescript@5.9.3) '@vitejs/plugin-react': specifier: 'catalog:' - version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) autoprefixer: specifier: 10.4.21 version: 10.4.21(postcss@8.5.6) @@ -625,7 +644,7 @@ importers: version: 0.4.24(eslint@8.57.1) eslint-plugin-tailwindcss: specifier: 4.0.0-beta.0 - version: 4.0.0-beta.0(tailwindcss@4.2.1) + version: 4.0.0-beta.0(tailwindcss@4.2.2) glob: specifier: 'catalog:' version: 10.5.0 @@ -654,8 +673,8 @@ importers: specifier: 'catalog:' version: 10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tailwindcss: - specifier: 4.2.1 - version: 4.2.1 + specifier: 'catalog:' + version: 4.2.2 typescript: specifier: 'catalog:' version: 5.9.3 @@ -664,13 +683,13 @@ importers: version: 13.12.0 vite: specifier: 'catalog:' - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-svgr: specifier: 'catalog:' - version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/admin-x-framework: dependencies: @@ -728,7 +747,7 @@ importers: version: 18.3.7(@types/react@18.3.28) '@vitejs/plugin-react': specifier: 'catalog:' - version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.5(vitest@4.1.5) @@ -764,16 +783,16 @@ importers: version: 5.9.3 vite: specifier: 'catalog:' - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-css-injected-by-js: specifier: 3.5.2 - version: 3.5.2(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 3.5.2(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vite-plugin-svgr: specifier: 'catalog:' - version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/admin-x-settings: dependencies: @@ -917,14 +936,14 @@ importers: specifier: 'catalog:' version: 29.1.1(@noble/hashes@1.8.0) tailwindcss: - specifier: 4.2.2 + specifier: 'catalog:' version: 4.2.2 vite: specifier: 'catalog:' - version: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/announcement-bar: dependencies: @@ -937,7 +956,7 @@ importers: devDependencies: '@vitejs/plugin-react': specifier: 'catalog:' - version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.5(vitest@4.1.5) @@ -955,13 +974,13 @@ importers: version: 29.1.1(@noble/hashes@1.8.0) vite: specifier: 'catalog:' - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-svgr: specifier: 'catalog:' - version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/comments-ui: dependencies: @@ -1031,7 +1050,7 @@ importers: version: 0.12.10 '@vitejs/plugin-react': specifier: 'catalog:' - version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.5(vitest@4.1.5) @@ -1069,17 +1088,17 @@ importers: specifier: 'catalog:' version: 21.1.1 tailwindcss: - specifier: 3.4.18 + specifier: catalog:tailwind3 version: 3.4.18(tsx@4.21.0)(yaml@2.8.3) vite: specifier: 'catalog:' - version: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-svgr: specifier: 'catalog:' - version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/portal: dependencies: @@ -1113,7 +1132,7 @@ importers: version: 8.49.0(eslint@8.57.1)(typescript@5.9.3) '@vitejs/plugin-react': specifier: 'catalog:' - version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.5(vitest@4.1.5) @@ -1143,16 +1162,16 @@ importers: version: 17.0.2(react@17.0.2) vite: specifier: 'catalog:' - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-css-injected-by-js: specifier: 3.5.2 - version: 3.5.2(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 3.5.2(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vite-plugin-svgr: specifier: 'catalog:' - version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/posts: dependencies: @@ -1248,14 +1267,14 @@ importers: specifier: 'catalog:' version: 2.12.14(@types/node@25.6.0)(typescript@5.9.3) tailwindcss: - specifier: 4.2.2 + specifier: 'catalog:' version: 4.2.2 vite: specifier: 'catalog:' - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/shade: dependencies: @@ -1385,16 +1404,16 @@ importers: devDependencies: '@storybook/addon-docs': specifier: 'catalog:' - version: 10.3.5(@types/react@18.3.28)(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) + version: 10.3.5(@types/react@18.3.28)(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) '@storybook/addon-links': specifier: 'catalog:' version: 10.3.5(react@18.3.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@storybook/react-vite': specifier: 'catalog:' - version: 10.3.5(esbuild@0.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) + version: 10.3.5(esbuild@0.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) '@tailwindcss/postcss': - specifier: 4.2.1 - version: 4.2.1 + specifier: 'catalog:' + version: 4.2.2 '@testing-library/react': specifier: 14.3.1 version: 14.3.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1409,7 +1428,7 @@ importers: version: 8.49.0(eslint@8.57.1)(typescript@5.9.3) '@vitejs/plugin-react': specifier: 'catalog:' - version: 4.7.0(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.7.0(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.5(vitest@4.1.5) @@ -1430,7 +1449,7 @@ importers: version: 10.3.5(eslint@8.57.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3) eslint-plugin-tailwindcss: specifier: 4.0.0-beta.0 - version: 4.0.0-beta.0(tailwindcss@4.2.1) + version: 4.0.0-beta.0(tailwindcss@4.2.2) glob: specifier: 'catalog:' version: 10.5.0 @@ -1447,8 +1466,8 @@ importers: specifier: 'catalog:' version: 10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tailwindcss: - specifier: 4.2.1 - version: 4.2.1 + specifier: 'catalog:' + version: 4.2.2 tsc-alias: specifier: 1.8.17 version: 1.8.17 @@ -1456,20 +1475,20 @@ importers: specifier: 4.21.0 version: 4.21.0 tw-animate-css: - specifier: 1.4.0 + specifier: 'catalog:' version: 1.4.0 typescript: specifier: 'catalog:' version: 5.9.3 vite: specifier: 'catalog:' - version: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-svgr: specifier: 'catalog:' - version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/signup-form: dependencies: @@ -1488,13 +1507,13 @@ importers: version: 1.59.1 '@storybook/addon-docs': specifier: 'catalog:' - version: 10.3.5(@types/react@18.3.28)(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) + version: 10.3.5(@types/react@18.3.28)(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) '@storybook/addon-links': specifier: 'catalog:' version: 10.3.5(react@18.3.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@storybook/react-vite': specifier: 'catalog:' - version: 10.3.5(esbuild@0.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) + version: 10.3.5(esbuild@0.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) '@tailwindcss/line-clamp': specifier: 0.4.4 version: 0.4.4(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)) @@ -1509,7 +1528,7 @@ importers: version: 18.3.7(@types/react@18.3.28) '@vitejs/plugin-react': specifier: 'catalog:' - version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) autoprefixer: specifier: 10.4.21 version: 10.4.21(postcss@8.5.6) @@ -1535,17 +1554,17 @@ importers: specifier: 'catalog:' version: 10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tailwindcss: - specifier: 3.4.18 + specifier: catalog:tailwind3 version: 3.4.18(tsx@4.21.0)(yaml@2.8.3) vite: specifier: 'catalog:' - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-svgr: specifier: 'catalog:' - version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/sodo-search: dependencies: @@ -1573,7 +1592,7 @@ importers: version: 12.1.5(@types/react@18.3.28)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) '@vitejs/plugin-react': specifier: 'catalog:' - version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.5(vitest@4.1.5) @@ -1593,17 +1612,17 @@ importers: specifier: 13.5.6 version: 13.5.6 tailwindcss: - specifier: 3.4.18 + specifier: catalog:tailwind3 version: 3.4.18(tsx@4.21.0)(yaml@2.8.3) vite: specifier: 'catalog:' - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-svgr: specifier: 'catalog:' - version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/stats: dependencies: @@ -1678,17 +1697,17 @@ importers: specifier: 'catalog:' version: 29.1.1(@noble/hashes@1.8.0) tailwindcss: - specifier: 4.2.2 + specifier: 'catalog:' version: 4.2.2 vite: specifier: 'catalog:' - version: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-svgr: specifier: 'catalog:' - version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) e2e: devDependencies: @@ -2761,7 +2780,7 @@ importers: version: 13.12.0 vitest: specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) optionalDependencies: '@tryghost/html-to-mobiledoc': specifier: 3.3.1 @@ -8137,69 +8156,69 @@ packages: peerDependencies: tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1' - '@tailwindcss/node@4.2.1': - resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} - '@tailwindcss/oxide-android-arm64@4.2.1': - resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.1': - resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.1': - resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.1': - resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': - resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': - resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': - resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': - resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.1': - resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.1': - resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -8210,29 +8229,29 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': - resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': - resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.1': - resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} engines: {node: '>= 20'} - '@tailwindcss/postcss@4.2.1': - resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==} + '@tailwindcss/postcss@4.2.2': + resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} - '@tailwindcss/vite@4.2.1': - resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} peerDependencies: - vite: ^5.2.0 || ^6 || ^7 + vite: ^5.2.0 || ^6 || ^7 || ^8 '@tanstack/query-core@4.36.1': resolution: {integrity: sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA==} @@ -16048,78 +16067,78 @@ packages: resolution: {integrity: sha512-DlIPlJUkCV0Ips2zf2pJP0unEoT1kwYhiiPUGF3s/jtxTCjziNLoiVVh+jqWOWeFi6mmwQ5fNxvAUyPad4Dfog==} engines: {node: '>= 0.8'} - lightningcss-android-arm64@1.31.1: - resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.31.1: - resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.31.1: - resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.31.1: - resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.31.1: - resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.31.1: - resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.31.1: - resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.31.1: - resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.31.1: - resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.31.1: - resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.31.1: - resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.31.1: - resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} lilconfig@3.1.3: @@ -20480,9 +20499,6 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - tailwindcss@4.2.1: - resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} - tailwindcss@4.2.2: resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} @@ -25080,19 +25096,19 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) optionalDependencies: typescript: 5.9.3 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) optionalDependencies: typescript: 5.9.3 @@ -28139,10 +28155,10 @@ snapshots: '@stdlib/utils-constructor-name': 0.2.3 '@stdlib/utils-global': 0.2.3 - '@storybook/addon-docs@10.3.5(@types/react@18.3.28)(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': + '@storybook/addon-docs@10.3.5(@types/react@18.3.28)(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': dependencies: '@mdx-js/react': 3.1.1(@types/react@18.3.28)(react@18.3.1) - '@storybook/csf-plugin': 10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) + '@storybook/csf-plugin': 10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) '@storybook/icons': 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@storybook/react-dom-shim': 10.3.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) react: 18.3.1 @@ -28156,10 +28172,10 @@ snapshots: - vite - webpack - '@storybook/addon-docs@10.3.5(@types/react@18.3.28)(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': + '@storybook/addon-docs@10.3.5(@types/react@18.3.28)(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': dependencies: '@mdx-js/react': 3.1.1(@types/react@18.3.28)(react@18.3.1) - '@storybook/csf-plugin': 10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) + '@storybook/csf-plugin': 10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) '@storybook/icons': 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@storybook/react-dom-shim': 10.3.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) react: 18.3.1 @@ -28180,46 +28196,46 @@ snapshots: optionalDependencies: react: 18.3.1 - '@storybook/builder-vite@10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': + '@storybook/builder-vite@10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': dependencies: - '@storybook/csf-plugin': 10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) + '@storybook/csf-plugin': 10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ts-dedent: 2.2.0 - vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/builder-vite@10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': + '@storybook/builder-vite@10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': dependencies: - '@storybook/csf-plugin': 10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) + '@storybook/csf-plugin': 10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ts-dedent: 2.2.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': + '@storybook/csf-plugin@10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': dependencies: storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.4 rollup: 4.60.0 - vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) webpack: 5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4) - '@storybook/csf-plugin@10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': + '@storybook/csf-plugin@10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': dependencies: storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.4 rollup: 4.60.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) webpack: 5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4) '@storybook/global@5.0.0': {} @@ -28235,11 +28251,11 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@storybook/react-vite@10.3.5(esbuild@0.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': + '@storybook/react-vite@10.3.5(esbuild@0.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@rollup/pluginutils': 5.3.0(rollup@4.60.0) - '@storybook/builder-vite': 10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) + '@storybook/builder-vite': 10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) '@storybook/react': 10.3.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -28249,7 +28265,7 @@ snapshots: resolve: 1.22.11 storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tsconfig-paths: 4.2.0 - vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - esbuild - rollup @@ -28257,11 +28273,11 @@ snapshots: - typescript - webpack - '@storybook/react-vite@10.3.5(esbuild@0.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': + '@storybook/react-vite@10.3.5(esbuild@0.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@rollup/pluginutils': 5.3.0(rollup@4.60.0) - '@storybook/builder-vite': 10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) + '@storybook/builder-vite': 10.3.5(esbuild@0.27.4)(rollup@4.60.0)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.21(@swc/helpers@0.5.21))(esbuild@0.27.4)) '@storybook/react': 10.3.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -28271,7 +28287,7 @@ snapshots: resolve: 1.22.11 storybook: 10.3.5(@testing-library/dom@10.4.0)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tsconfig-paths: 4.2.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - esbuild - rollup @@ -28444,81 +28460,81 @@ snapshots: dependencies: tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.3) - '@tailwindcss/node@4.2.1': + '@tailwindcss/node@4.2.2': dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.20.1 jiti: 2.6.1 - lightningcss: 1.31.1 + lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.1 + tailwindcss: 4.2.2 - '@tailwindcss/oxide-android-arm64@4.2.1': + '@tailwindcss/oxide-android-arm64@4.2.2': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.1': + '@tailwindcss/oxide-darwin-arm64@4.2.2': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.1': + '@tailwindcss/oxide-darwin-x64@4.2.2': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.1': + '@tailwindcss/oxide-freebsd-x64@4.2.2': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.1': + '@tailwindcss/oxide-linux-x64-musl@4.2.2': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.1': + '@tailwindcss/oxide-wasm32-wasi@4.2.2': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': optional: true - '@tailwindcss/oxide@4.2.1': + '@tailwindcss/oxide@4.2.2': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-x64': 4.2.1 - '@tailwindcss/oxide-freebsd-x64': 4.2.1 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-x64-musl': 4.2.1 - '@tailwindcss/oxide-wasm32-wasi': 4.2.1 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - - '@tailwindcss/postcss@4.2.1': + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/postcss@4.2.2': dependencies: '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.2.1 - '@tailwindcss/oxide': 4.2.1 + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 postcss: 8.5.6 - tailwindcss: 4.2.1 + tailwindcss: 4.2.2 - '@tailwindcss/vite@4.2.1(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@tailwindcss/vite@4.2.2(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@tailwindcss/node': 4.2.1 - '@tailwindcss/oxide': 4.2.1 - tailwindcss: 4.2.1 - vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) '@tanstack/query-core@4.36.1': {} @@ -30278,15 +30294,15 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react-swc@4.1.0(@swc/helpers@0.5.21)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitejs/plugin-react-swc@4.1.0(@swc/helpers@0.5.21)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.35 '@swc/core': 1.15.21(@swc/helpers@0.5.21) - vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@swc/helpers' - '@vitejs/plugin-react@4.7.0(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitejs/plugin-react@4.7.0(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -30294,11 +30310,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitejs/plugin-react@4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -30306,11 +30322,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitejs/plugin-react@4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -30318,7 +30334,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -30334,7 +30350,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@3.2.4': dependencies: @@ -30353,32 +30369,32 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.5(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.14(@types/node@22.19.18)(typescript@5.9.3) - vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/mocker@4.1.5(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.5(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.14(@types/node@25.6.0)(typescript@5.9.3) - vite: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/mocker@4.1.5(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.5(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.14(@types/node@25.6.0)(typescript@5.9.3) - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) '@vitest/pretty-format@3.2.4': dependencies: @@ -36324,14 +36340,6 @@ snapshots: postcss: 8.5.6 tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.3) - eslint-plugin-tailwindcss@4.0.0-beta.0(tailwindcss@4.2.1): - dependencies: - fast-glob: 3.3.3 - postcss: 8.5.6 - synckit: 0.11.12 - tailwind-api-utils: 1.0.3(tailwindcss@4.2.1) - tailwindcss: 4.2.1 - eslint-plugin-tailwindcss@4.0.0-beta.0(tailwindcss@4.2.2): dependencies: fast-glob: 3.3.3 @@ -38003,7 +38011,7 @@ snapshots: jest: 29.7.0(@types/node@22.19.18)(babel-plugin-macros@3.1.0)(node-notifier@10.0.1)(ts-node@10.9.2(@swc/core@1.15.21(@swc/helpers@0.5.21))(@types/node@22.19.18)(typescript@5.9.3)) jest-diff: 29.7.0 jest-snapshot: 29.7.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) html2canvas-objectfit-fix@1.2.0: dependencies: @@ -39896,54 +39904,54 @@ snapshots: rechoir: 0.6.2 resolve: 1.22.11 - lightningcss-android-arm64@1.31.1: + lightningcss-android-arm64@1.32.0: optional: true - lightningcss-darwin-arm64@1.31.1: + lightningcss-darwin-arm64@1.32.0: optional: true - lightningcss-darwin-x64@1.31.1: + lightningcss-darwin-x64@1.32.0: optional: true - lightningcss-freebsd-x64@1.31.1: + lightningcss-freebsd-x64@1.32.0: optional: true - lightningcss-linux-arm-gnueabihf@1.31.1: + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true - lightningcss-linux-arm64-gnu@1.31.1: + lightningcss-linux-arm64-gnu@1.32.0: optional: true - lightningcss-linux-arm64-musl@1.31.1: + lightningcss-linux-arm64-musl@1.32.0: optional: true - lightningcss-linux-x64-gnu@1.31.1: + lightningcss-linux-x64-gnu@1.32.0: optional: true - lightningcss-linux-x64-musl@1.31.1: + lightningcss-linux-x64-musl@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.31.1: + lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-x64-msvc@1.31.1: + lightningcss-win32-x64-msvc@1.32.0: optional: true - lightningcss@1.31.1: + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.31.1 - lightningcss-darwin-arm64: 1.31.1 - lightningcss-darwin-x64: 1.31.1 - lightningcss-freebsd-x64: 1.31.1 - lightningcss-linux-arm-gnueabihf: 1.31.1 - lightningcss-linux-arm64-gnu: 1.31.1 - lightningcss-linux-arm64-musl: 1.31.1 - lightningcss-linux-x64-gnu: 1.31.1 - lightningcss-linux-x64-musl: 1.31.1 - lightningcss-win32-arm64-msvc: 1.31.1 - lightningcss-win32-x64-msvc: 1.31.1 + 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 lilconfig@3.1.3: {} @@ -45417,13 +45425,6 @@ snapshots: tagged-tag@1.0.0: {} - tailwind-api-utils@1.0.3(tailwindcss@4.2.1): - dependencies: - enhanced-resolve: 5.20.1 - jiti: 2.6.1 - local-pkg: 1.1.2 - tailwindcss: 4.2.1 - tailwind-api-utils@1.0.3(tailwindcss@4.2.2): dependencies: enhanced-resolve: 5.20.1 @@ -45461,8 +45462,6 @@ snapshots: - tsx - yaml - tailwindcss@4.2.1: {} - tailwindcss@4.2.2: {} tap-parser@7.0.0: @@ -46516,55 +46515,55 @@ snapshots: - bare-abort-controller - react-native-b4a - vite-plugin-css-injected-by-js@3.5.2(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vite-plugin-css-injected-by-js@3.5.2(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vite-plugin-svgr@4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vite-plugin-svgr@4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.60.0) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - rollup - supports-color - typescript - vite-plugin-svgr@4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vite-plugin-svgr@4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.60.0) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - rollup - supports-color - typescript - vite-plugin-svgr@4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vite-plugin-svgr@4.5.0(rollup@4.60.0)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.60.0) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - rollup - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: debug: 4.4.3(supports-color@5.5.0) globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color - typescript - vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) @@ -46577,12 +46576,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 less: 4.6.4 - lightningcss: 1.31.1 + lightningcss: 1.32.0 terser: 5.46.1 tsx: 4.21.0 yaml: 2.8.3 - vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) @@ -46595,12 +46594,12 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 less: 4.6.4 - lightningcss: 1.31.1 + lightningcss: 1.32.0 terser: 5.46.1 tsx: 4.21.0 yaml: 2.8.3 - vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) @@ -46613,15 +46612,15 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 less: 4.6.4 - lightningcss: 1.31.1 + lightningcss: 1.32.0 terser: 5.46.1 tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.18)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.5(msw@2.12.14(@types/node@22.19.18)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -46638,7 +46637,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@22.19.18)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -46648,10 +46647,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.5(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -46668,7 +46667,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -46678,10 +46677,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.5(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -46698,7 +46697,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5e1f5df0e09..c74d51ba760 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,8 @@ catalog: '@storybook/addon-docs': 10.3.5 '@storybook/addon-links': 10.3.5 '@storybook/react-vite': 10.3.5 + '@tailwindcss/postcss': 4.2.2 + '@tailwindcss/vite': 4.2.2 '@tanstack/react-query': 4.36.1 '@tryghost/color-utils': 0.2.16 '@tryghost/custom-fonts': 1.0.8 @@ -59,6 +61,8 @@ catalog: sinon: 21.1.1 sonner: 2.0.7 storybook: 10.3.5 + tailwindcss: 4.2.2 + tw-animate-css: 1.4.0 type-fest: 4.41.0 typescript: 5.9.3 validator: 13.12.0 @@ -71,3 +75,5 @@ catalogs: eslint9: '@eslint/js': 9.37.0 eslint: 9.37.0 + tailwind3: + tailwindcss: 3.4.18 diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 00000000000..ff2bb0f1ec2 --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,34 @@ +import path from 'node:path'; +import {defineConfig} from 'vitest/config'; + +// Root Vitest config — a single watcher across every Vitest-based package in +// the monorepo. `pnpm test:watch` runs this. Each package is a project that +// keeps its own config (environment, setup, pool); scope to one with a path +// filter, e.g. `pnpm test:watch apps/posts`. +// +// Not included: ghost/i18n and ghost/parse-email-address (still on Mocha) and +// ghost-admin (Ember QUnit). admin-x-activitypub is a dead directory with no +// package.json or test config. signup-form has no Vitest unit tests (its +// test:unit is a build; test/unit holds only an empty placeholder). +export default defineConfig({ + test: { + projects: [ + 'ghost/core', + 'apps/*', + '!apps/admin-x-activitypub', + '!apps/signup-form' + ], + // ghost/core's snapshot tests use @tryghost/jest-snapshot, which + // manages its own __snapshots__/*.snap files. Vitest's native + // snapshot system would otherwise adopt and rewrite them (down to the + // header). The project-level resolveSnapshotPath in + // ghost/core/vitest.config.ts is not honored once projects run under + // this root config, so redirect ghost/core's native snapshots to a + // never-written path here. App projects keep the default location. + resolveSnapshotPath: (testPath, snapExtension) => { + const isGhostCore = testPath.includes(`${path.sep}ghost${path.sep}core${path.sep}`); + const dir = isGhostCore ? '__vitest_snapshots__' : '__snapshots__'; + return path.join(path.dirname(testPath), dir, path.basename(testPath) + snapExtension); + } + } +});