Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,30 @@ module.exports = {
},
to: { path: '^@tryghost/(shade|admin-x-framework)' },
},
// ============================================================
// apps/ — admin is an app, not a library
// ============================================================
{
name: 'admin-is-app',
comment:
'No sibling app or library may depend on @tryghost/admin - whether by package specifier or by relative reach-in. Admin sits at the top of the layer stack.',
severity: 'error',
from: { path: '^apps/', pathNot: '^apps/admin/' },
to: { path: '^@tryghost/admin($|/)|^apps/admin/' },
},
// ============================================================
// apps/admin — shared/ must stay domain-free
// ============================================================
{
name: 'admin-shared-no-domains',
comment:
'apps/admin/src/shared must not import from feature domains. Move code used by a single domain into that domain; keep shared/ generic. In-app imports use the @/ alias, which the cruiser sees as an unresolved @/-prefixed specifier.',
severity: 'error',
from: { path: '^apps/admin/src/shared/' },
to: {
path: '^(@/|apps/admin/src/)(members|settings|analytics|posts|tags|comments|automations|onboarding|whats-new|layout)($|/)',
},
},
],
options: {
doNotFollow: { path: 'node_modules' },
Expand Down
2 changes: 1 addition & 1 deletion .lintstagedrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ module.exports = {
];
},
'ghost/core/core/{server,shared,frontend}/**/*.{js,ts}': (files) => buildBoundaryCommand(files),
'apps/{shade,admin-x-framework,activitypub,portal,comments-ui,signup-form,sodo-search,announcement-bar,admin-toolbar}/src/**/*.{js,ts,tsx,jsx}':
'apps/{admin,shade,admin-x-framework,activitypub,portal,comments-ui,signup-form,sodo-search,announcement-bar,admin-toolbar}/src/**/*.{js,ts,tsx,jsx}':
(files) => buildBoundaryCommand(files),
'*.{mjs,mts,cts,json,jsonc,json5,yml,yaml,css,mdx}': (files) => buildOxfmtCommand(files),
'**/*.md': (files) => [buildOxfmtCommand(files), ...buildMarkdownCommands(files)],
Expand Down
1 change: 1 addition & 0 deletions apps/admin-x-framework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"@tanstack/react-query": "catalog:",
"@tinybirdco/charts": "0.3.0",
"@tryghost/custom-field-types": "workspace:*",
"@tryghost/limit-service": "catalog:",
"@tryghost/nql-string": "workspace:*",
"@tryghost/shade": "workspace:*",
"bson-objectid": "catalog:",
Expand Down
4 changes: 4 additions & 0 deletions apps/admin-x-framework/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export type {
} from './hooks/use-form';
export { default as useHandleError } from './hooks/use-handle-error';
export { useFeatureFlag } from './hooks/use-feature-flag';
export { useHostLimits } from './hooks/use-host-limits';
export type { HostLimits } from './hooks/use-host-limits';
export { useLimiter } from './hooks/use-limiter';
export type { Limiter } from './hooks/use-limiter';
export { useKoenigFileUpload, koenigFileUploadTypes } from './hooks/use-koenig-file-upload';
export { useKoenigFetchEmbed } from './hooks/use-koenig-fetch-embed';
export type { KoenigFileUploadType } from './hooks/use-koenig-file-upload';
Expand Down
12 changes: 12 additions & 0 deletions apps/admin-x-framework/src/hooks/use-host-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { type Config, useBrowseConfig } from '../api/config';

export type HostLimits = NonNullable<NonNullable<Config['hostSettings']>['limits']>;

/**
* The site's host plan limits from config — undefined while config is loading
* or when the site has none.
*/
export const useHostLimits = (): HostLimits | undefined => {
const { data } = useBrowseConfig({ refetchOnMount: false });
return data?.config.hostSettings?.limits;
};
Original file line number Diff line number Diff line change
@@ -1,39 +1,18 @@
import useStaffUsers from './use-staff-users';
import { useBrowseMembers } from '@tryghost/admin-x-framework/api/members';
import { useBrowseNewsletters } from '@tryghost/admin-x-framework/api/newsletters';
import { useEffect, useMemo, useState } from 'react';
import { useGlobalData } from '@/settings/providers/global-data-context';
import { useBrowseConfig } from '../api/config';
import { useBrowseInvites } from '../api/invites';
import { useBrowseMembers } from '../api/members';
import { useBrowseNewsletters } from '../api/newsletters';
import { useBrowseRoles } from '../api/roles';
import { useBrowseUsers } from '../api/users';
import { HostLimitError } from '../utils/errors';

const limitServiceImport = import('@tryghost/limit-service');

export class LimitError extends Error {
public readonly errorType: string;
public readonly errorDetails: string;

constructor({
errorType,
errorDetails,
message,
}: {
errorType: string;
errorDetails: string;
message: string;
}) {
// limit-service constructs its misconfiguration error with a single options object
class IncorrectUsageError extends Error {
constructor({ message }: { message: string }) {
super(message);
this.errorType = errorType;
this.errorDetails = errorDetails;
}
}

export class IncorrectUsageError extends LimitError {
constructor(options: { errorDetails: string; message: string }) {
super(Object.assign({ errorType: 'IncorrectUsageError' }, options));
}
}

export class HostLimitError extends LimitError {
constructor(options: { errorDetails: string; message: string }) {
super(Object.assign({ errorType: 'HostLimitError' }, options));
}
}

Expand All @@ -55,8 +34,17 @@ interface LimiterLimits {
};
}

export const useLimiter = () => {
const { config } = useGlobalData();
export interface Limiter {
isLimited: (limitName: string) => boolean;
isDisabled: (limitName: string) => boolean;
checkWouldGoOverLimit: (limitName: string) => Promise<boolean>;
errorIfWouldGoOverLimit: (limitName: string, metadata?: Record<string, unknown>) => Promise<void>;
errorIfIsOverLimit: (limitName: string) => Promise<void>;
}

export const useLimiter = (): Limiter => {
const { data: configData } = useBrowseConfig({ refetchOnMount: false });
const config = configData?.config;
const [LimitService, setLimitService] = useState<
typeof import('@tryghost/limit-service').default | null
>(null);
Expand All @@ -65,7 +53,10 @@ export const useLimiter = () => {
void limitServiceImport.then((exports) => setLimitService(() => exports.default));
}, []);

const { users, contributorUsers, invites, isLoading } = useStaffUsers();
const { data: { users } = { users: [] }, isLoading: usersLoading } = useBrowseUsers();
const { data: { invites } = { invites: [] }, isLoading: invitesLoading } = useBrowseInvites();
const { data: { roles } = {}, isLoading: rolesLoading } = useBrowseRoles();
const isStaffLoading = usersLoading || invitesLoading || rolesLoading;
const { refetch: fetchMembers } = useBrowseMembers({
searchParams: { limit: '1' },
enabled: false,
Expand All @@ -76,12 +67,12 @@ export const useLimiter = () => {
});

const helpLink = useMemo(() => {
if (config.hostSettings?.billing?.enabled === true && config.hostSettings?.billing?.url) {
if (config?.hostSettings?.billing?.enabled === true && config.hostSettings.billing.url) {
return config.hostSettings.billing.url;
} else {
return 'https://ghost.org/help/';
}
}, [config.hostSettings?.billing]);
}, [config?.hostSettings?.billing]);

return useMemo(() => {
// Return a stable no-op API when the limiter isn't ready
Expand All @@ -94,7 +85,7 @@ export const useLimiter = () => {
errorIfIsOverLimit: (): Promise<void> => Promise.resolve(),
};

if (!LimitService || !config.hostSettings?.limits || isLoading) {
if (!LimitService || !config?.hostSettings?.limits || isStaffLoading) {
return noOpLimiter;
}

Expand All @@ -103,12 +94,16 @@ export const useLimiter = () => {

if (limits.staff) {
limits.staff.currentCountQuery = () => {
// useStaffUsers will only return the first 100 users by default, but we can assume
// that either there's no limit or the limit is <100
// Keep the existing first-page behavior for this move. Full pagination is tracked in
// PLA-369 because excluded users/invites can push countable staff onto later pages.
const staffUsers = users.filter(
(u) => u.status !== 'inactive' && !contributorUsers.includes(u),
(user) =>
user.status !== 'inactive' && !user.roles.some((role) => role.name === 'Contributor'),
);
const staffInvites = invites.filter((i) => i.role !== 'Contributor');
const staffInvites = invites.filter((invite) => {
const role = roles?.find(({ id }) => id === invite.role_id);
return role?.name !== 'Contributor';
});

return Promise.resolve(staffUsers.length + staffInvites.length);
};
Expand Down Expand Up @@ -152,12 +147,12 @@ export const useLimiter = () => {
}, [
LimitService,
config,
contributorUsers,
fetchMembers,
fetchNewsletters,
helpLink,
invites,
isLoading,
isStaffLoading,
roles,
users,
]);
};
2 changes: 2 additions & 0 deletions apps/admin-x-framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export {
export { useNavigationStack } from './providers/navigation-stack-provider';
export {
Link,
NavigationType,
Outlet,
useBlocker,
useLocation,
Expand All @@ -95,6 +96,7 @@ export {
useMatch,
useMatches,
} from 'react-router';
export type { BlockerFunction } from 'react-router';

// Lazy component loader
export { lazyComponent } from './utils/lazy-component';
Expand Down
19 changes: 19 additions & 0 deletions apps/admin-x-framework/src/limit-service.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
declare module '@tryghost/limit-service' {
type LimitOptions = Record<string, unknown>;
export default class LimitService {
loadLimits(config: {
limits: object;
subscription?: unknown;
helpLink?: string;
db?: unknown;
errors: Record<string, unknown>;
}): void;
isLimited(limitName: string): boolean;
isDisabled(limitName: string): boolean;
checkIsOverLimit(limitName: string, options?: LimitOptions): Promise<boolean>;
checkWouldGoOverLimit(limitName: string, options?: LimitOptions): Promise<boolean>;
errorIfIsOverLimit(limitName: string, options?: LimitOptions): Promise<void>;
errorIfWouldGoOverLimit(limitName: string, options?: LimitOptions): Promise<void>;
checkIfAnyOverLimit(options?: LimitOptions): Promise<boolean>;
}
}
37 changes: 34 additions & 3 deletions apps/admin-x-framework/src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class JSONError extends APIError {
public readonly data?: ErrorResponse;

constructor(
response: Response,
response: Response | undefined,
data?: ErrorResponse,
message?: string,
errorOptions?: ErrorOptions,
Expand Down Expand Up @@ -111,9 +111,40 @@ export class ThemeValidationError extends JSONError {
}
}

export interface HostLimitErrorDetails {
name?: string;
limit?: number;
total?: number;
}

interface HostLimitOptions {
message?: string;
errorDetails?: HostLimitErrorDetails;
help?: string;
}

// Constructed two ways: from an API error response, and by @tryghost/limit-service
// (via useLimiter), which news the registered class with a single options object.
export class HostLimitError extends JSONError {
constructor(response: Response, data: ErrorResponse, errorOptions?: ErrorOptions) {
super(response, data, 'A hosting plan limit was reached or exceeded.', errorOptions);
public readonly errorDetails?: HostLimitErrorDetails;

constructor(response: Response, data: ErrorResponse, errorOptions?: ErrorOptions);
constructor(limit: HostLimitOptions);
constructor(
responseOrLimit: Response | HostLimitOptions,
data?: ErrorResponse,
errorOptions?: ErrorOptions,
) {
if (responseOrLimit instanceof Response) {
super(responseOrLimit, data, 'A hosting plan limit was reached or exceeded.', errorOptions);
} else {
super(
undefined,
undefined,
responseOrLimit.message || 'A hosting plan limit was reached or exceeded.',
);
this.errorDetails = responseOrLimit.errorDetails;
}
}
}

Expand Down
46 changes: 46 additions & 0 deletions apps/admin-x-framework/test/unit/hooks/use-host-limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { renderHook } from '@testing-library/react';
import { useHostLimits } from '../../../src/hooks/use-host-limits';

vi.mock('../../../src/api/config', () => ({
useBrowseConfig: vi.fn(),
}));

import { useBrowseConfig } from '../../../src/api/config';

const mockUseBrowseConfig = vi.mocked(useBrowseConfig);

const withConfig = (config: unknown) => {
mockUseBrowseConfig.mockReturnValue({ data: config && { config } } as ReturnType<
typeof useBrowseConfig
>);
};

describe('useHostLimits', () => {
afterEach(() => {
vi.clearAllMocks();
});

it('returns the host limits from config', () => {
withConfig({ hostSettings: { limits: { limitAnalytics: { disabled: true } } } });

const { result } = renderHook(() => useHostLimits());

expect(result.current?.limitAnalytics?.disabled).toBe(true);
});

it('returns undefined when the site has no host limits', () => {
withConfig({ hostSettings: {} });

const { result } = renderHook(() => useHostLimits());

expect(result.current).toBeUndefined();
});

it('returns undefined before config has loaded', () => {
withConfig(undefined);

const { result } = renderHook(() => useHostLimits());

expect(result.current).toBeUndefined();
});
});
20 changes: 19 additions & 1 deletion apps/admin-x-framework/test/unit/utils/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,24 @@ describe('errors utils', () => {
expect(error.data).toBe(mockErrorResponse);
});

// The shape @tryghost/limit-service passes when it constructs the registered class
it('creates error from a limit-service options object', () => {
const error = new HostLimitError({
message: 'Your plan supports up to 5 staff users.',
errorDetails: { name: 'staff', limit: 5, total: 6 },
help: 'https://ghost.org/help/',
});
expect(error.message).toBe('Your plan supports up to 5 staff users.');
expect(error.errorDetails).toEqual({ name: 'staff', limit: 5, total: 6 });
expect(error.response).toBeUndefined();
expect(error.data).toBeUndefined();
});

it('falls back to the generic message when a limit-service error has none', () => {
const error = new HostLimitError({ errorDetails: { name: 'customThemes' } });
expect(error.message).toBe('A hosting plan limit was reached or exceeded.');
});

it('is included in errorsWithMessage', () => {
expect(errorsWithMessage).toContain(HostLimitError);
});
Expand Down Expand Up @@ -365,7 +383,7 @@ describe('errors utils', () => {
},
],
};
return new HostLimitError({} as Response, data);
return new HostLimitError(new Response(), data);
};

it('returns the context of any error carrying an API body, not only a validation one', () => {
Expand Down
Loading
Loading