first commit
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled

This commit is contained in:
2026-04-22 19:51:20 +07:00
commit 93d1b7c3d3
579 changed files with 99797 additions and 0 deletions
@@ -0,0 +1,17 @@
---
name: Add Admin API Endpoint
description: Add a new endpoint or endpoints to Ghost's Admin API at `ghost/api/admin/**`.
---
# Create Admin API Endpoint
## Instructions
1. If creating an endpoint for an entirely new resource, create a new endpoint file in `ghost/core/core/server/api/endpoints/`. Otherwise, locate the existing endpoint file in the same directory.
2. The endpoint file should create a controller object using the JSDoc type from (@tryghost/api-framework).Controller, including at minimum a `docName` and a single endpoint definition, i.e. `browse`.
3. Add routes for each endpoint to `ghost/core/core/server/web/api/endpoints/admin/routes.js`.
4. Add basic `e2e-api` tests for the endpoint in `ghost/core/test/e2e-api/admin` to ensure the new endpoints function as expected.
5. Run the tests and iterate until they pass: `cd ghost/core && pnpm test:single test/e2e-api/admin/{test-file-name}`.
## Reference
For a detailed reference on Ghost's API framework and how to create API controllers, see [reference.md](reference.md).
@@ -0,0 +1,711 @@
# API Controller Permissions Guide
This guide explains how to configure permissions in api-framework controllers, covering all available patterns and best practices.
## Table of Contents
- [Overview](#overview)
- [Permission Patterns](#permission-patterns)
- [Boolean `true` - Default Permission Check](#pattern-1-boolean-true---default-permission-check)
- [Boolean `false` - Skip Permissions](#pattern-2-boolean-false---skip-permissions)
- [Function - Custom Permission Logic](#pattern-3-function---custom-permission-logic)
- [Configuration Object - Default with Hooks](#pattern-4-configuration-object---default-with-hooks)
- [The Frame Object](#the-frame-object)
- [Configuration Object Properties](#configuration-object-properties)
- [Complete Examples](#complete-examples)
- [Best Practices](#best-practices)
---
## Overview
The api-framework uses a **pipeline-based permission system** where permissions are handled as one of five request processing stages:
1. Input validation
2. Input serialisation
3. **Permissions** ← You are here
4. Query (controller execution)
5. Output serialisation
**Important**: Every controller method **MUST** explicitly define the `permissions` property. This is a security requirement that prevents accidental security holes and makes permission handling explicit.
```javascript
// This will throw an IncorrectUsageError
edit: {
query(frame) {
return models.Post.edit(frame.data, frame.options);
}
// Missing permissions property!
}
```
---
## Permission Patterns
### Pattern 1: Boolean `true` - Default Permission Check
The most common pattern that delegates to the default permission handler.
```javascript
edit: {
headers: {
cacheInvalidate: true
},
options: ['include'],
validation: {
options: {
include: {
required: true,
values: ['tags']
}
}
},
permissions: true,
query(frame) {
return models.Post.edit(frame.data, frame.options);
}
}
```
**When to use:**
- Standard CRUD operations
- When the default permission handler meets your needs
- Most common case for authenticated endpoints
#### How the Default Permission Handler Works
When you set `permissions: true`, the framework delegates to the default permission handler at `ghost/core/core/server/api/endpoints/utils/permissions.js`. Here's what happens:
1. **Singular Name Derivation**: The handler converts the `docName` to singular form:
- `posts``post`
- `automated_emails``automated_email`
- `categories``category` (handles `ies``y`)
2. **Permission Check**: It calls the permissions service:
```javascript
permissions.canThis(frame.options.context)[method][singular](identifier, unsafeAttrs)
```
For example, with `docName: 'posts'` and method `edit`:
```javascript
permissions.canThis(context).edit.post(postId, unsafeAttrs)
```
3. **Database Lookup**: The permissions service checks the `permissions` and `permissions_roles` tables:
- Looks for a permission with `action_type` matching the method (e.g., `edit`)
- And `object_type` matching the singular docName (e.g., `post`)
- Verifies the user's role has that permission assigned
#### Required Database Setup
For the default handler to work, you must have:
1. **Permission records** in the `permissions` table:
```sql
INSERT INTO permissions (name, action_type, object_type) VALUES
('Browse posts', 'browse', 'post'),
('Read posts', 'read', 'post'),
('Edit posts', 'edit', 'post'),
('Add posts', 'add', 'post'),
('Delete posts', 'destroy', 'post');
```
2. **Role-permission mappings** in `permissions_roles` linking permissions to roles like Administrator, Editor, etc.
These are typically added via:
- Initial fixtures in `ghost/core/core/server/data/schema/fixtures/fixtures.json`
- Database migrations using `addPermissionWithRoles()` from `ghost/core/core/server/data/migrations/utils/permissions.js`
---
### Pattern 2: Boolean `false` - Skip Permissions
Completely bypasses the permissions stage.
```javascript
browse: {
options: ['page', 'limit'],
permissions: false,
query(frame) {
return models.PublicResource.findAll(frame.options);
}
}
```
**When to use:**
- Public endpoints that don't require authentication
- Health check or status endpoints
- Resources that should be accessible to everyone
**Warning**: Use with caution. Only disable permissions when you're certain the endpoint should be publicly accessible.
---
### Pattern 3: Function - Custom Permission Logic
Allows complete control over permission validation.
```javascript
delete: {
options: ['id'],
permissions: async function(frame) {
// Ensure user is authenticated
if (!frame.user || !frame.user.id) {
const UnauthorizedError = require('@tryghost/errors').UnauthorizedError;
return Promise.reject(new UnauthorizedError({
message: 'You must be logged in to perform this action'
}));
}
// Only the owner or an admin can delete
const resource = await models.Resource.findOne({id: frame.options.id});
if (resource.get('author_id') !== frame.user.id && frame.user.role !== 'admin') {
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
return Promise.reject(new NoPermissionError({
message: 'You do not have permission to delete this resource'
}));
}
return Promise.resolve();
},
query(frame) {
return models.Resource.destroy(frame.options);
}
}
```
**When to use:**
- Complex permission logic that varies by resource
- Owner-based permissions
- Role-based access control beyond the default handler
- When you need to query the database for permission decisions
---
### Pattern 4: Configuration Object - Default with Hooks
Combines default permission handling with configuration options and hooks.
```javascript
edit: {
options: ['include'],
permissions: {
unsafeAttrs: ['author', 'status'],
before: async function(frame) {
// Load additional user data needed for permission checks
frame.user.permissions = await loadUserPermissions(frame.user.id);
}
},
query(frame) {
return models.Post.edit(frame.data, frame.options);
}
}
```
**When to use:**
- Default permission handler is sufficient but needs configuration
- You have attributes that require special permission handling
- You need to prepare data before permission checks run
---
## The Frame Object
Permission handlers receive a `frame` object containing complete request context:
```javascript
Frame {
// Request data
original: {}, // Original untransformed input
options: {}, // Query/URL parameters
data: {}, // Request body
// User context
user: {}, // Logged-in user object
// File uploads
file: {}, // Single uploaded file
files: [], // Multiple uploaded files
// API context
apiType: String, // 'content' or 'admin'
docName: String, // Endpoint name (e.g., 'posts')
method: String, // Method name (e.g., 'browse', 'add', 'edit')
// HTTP context (added by HTTP wrapper)
context: {
api_key: {}, // API key information
user: userId, // User ID or null
integration: {}, // Integration details
member: {} // Member information or null
}
}
```
---
## Configuration Object Properties
When using Pattern 4, these properties are available:
### `unsafeAttrs` (Array)
Specifies attributes that require special permission handling.
```javascript
permissions: {
unsafeAttrs: ['author', 'visibility', 'status']
}
```
These attributes are passed to the permission handler for additional validation. Use this for fields that only certain users should be able to modify (e.g., only admins can change the author of a post).
### `before` (Function)
A hook that runs before the default permission handler.
```javascript
permissions: {
before: async function(frame) {
// Prepare data needed for permission checks
const membership = await loadMembership(frame.user.id);
frame.user.membershipLevel = membership.level;
}
}
```
---
## Complete Examples
### Example 1: Public Browse Endpoint
```javascript
module.exports = {
docName: 'articles',
browse: {
options: ['page', 'limit', 'filter'],
validation: {
options: {
limit: {
values: [10, 25, 50, 100]
}
}
},
permissions: false,
query(frame) {
return models.Article.findPage(frame.options);
}
}
};
```
### Example 2: Authenticated CRUD Controller
```javascript
module.exports = {
docName: 'posts',
browse: {
options: ['include', 'page', 'limit', 'filter', 'order'],
permissions: true,
query(frame) {
return models.Post.findPage(frame.options);
}
},
read: {
options: ['include'],
data: ['id', 'slug'],
permissions: true,
query(frame) {
return models.Post.findOne(frame.data, frame.options);
}
},
add: {
headers: {
cacheInvalidate: true
},
options: ['include'],
permissions: {
unsafeAttrs: ['author_id']
},
query(frame) {
return models.Post.add(frame.data.posts[0], frame.options);
}
},
edit: {
headers: {
cacheInvalidate: true
},
options: ['include', 'id'],
permissions: {
unsafeAttrs: ['author_id', 'status']
},
query(frame) {
return models.Post.edit(frame.data.posts[0], frame.options);
}
},
destroy: {
headers: {
cacheInvalidate: true
},
options: ['id'],
permissions: true,
statusCode: 204,
query(frame) {
return models.Post.destroy(frame.options);
}
}
};
```
### Example 3: Owner-Based Permissions
```javascript
module.exports = {
docName: 'user_settings',
read: {
options: ['user_id'],
permissions: async function(frame) {
// Users can only read their own settings
if (frame.options.user_id !== frame.user.id) {
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
return Promise.reject(new NoPermissionError({
message: 'You can only view your own settings'
}));
}
return Promise.resolve();
},
query(frame) {
return models.UserSetting.findOne({user_id: frame.options.user_id});
}
},
edit: {
options: ['user_id'],
permissions: async function(frame) {
// Users can only edit their own settings
if (frame.options.user_id !== frame.user.id) {
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
return Promise.reject(new NoPermissionError({
message: 'You can only edit your own settings'
}));
}
return Promise.resolve();
},
query(frame) {
return models.UserSetting.edit(frame.data, frame.options);
}
}
};
```
### Example 4: Role-Based Access Control
```javascript
module.exports = {
docName: 'admin_settings',
browse: {
permissions: async function(frame) {
const allowedRoles = ['Owner', 'Administrator'];
if (!frame.user || !allowedRoles.includes(frame.user.role)) {
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
return Promise.reject(new NoPermissionError({
message: 'Only administrators can access these settings'
}));
}
return Promise.resolve();
},
query(frame) {
return models.AdminSetting.findAll();
}
},
edit: {
permissions: async function(frame) {
// Only the owner can edit admin settings
if (!frame.user || frame.user.role !== 'Owner') {
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
return Promise.reject(new NoPermissionError({
message: 'Only the site owner can modify these settings'
}));
}
return Promise.resolve();
},
query(frame) {
return models.AdminSetting.edit(frame.data, frame.options);
}
}
};
```
### Example 5: Permission with Data Preparation
```javascript
module.exports = {
docName: 'premium_content',
read: {
options: ['id'],
permissions: {
before: async function(frame) {
// Load user's subscription status
if (frame.user) {
const subscription = await models.Subscription.findOne({
user_id: frame.user.id
});
frame.user.subscription = subscription;
}
}
},
async query(frame) {
// The query can now use frame.user.subscription
const content = await models.Content.findOne({id: frame.options.id});
if (content.get('premium') && !frame.user?.subscription?.active) {
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
throw new NoPermissionError({
message: 'Premium subscription required'
});
}
return content;
}
}
};
```
---
## Best Practices
### 1. Always Define Permissions Explicitly
```javascript
// Good - explicit about being public
permissions: false
// Good - explicit about requiring auth
permissions: true
// Bad - missing permissions (will throw error)
// permissions: undefined
```
### 2. Use the Appropriate Pattern
| Scenario | Pattern |
|----------|---------|
| Public endpoint | `permissions: false` |
| Standard authenticated CRUD | `permissions: true` |
| Need unsafe attrs tracking | `permissions: { unsafeAttrs: [...] }` |
| Complex custom logic | `permissions: async function(frame) {...}` |
| Need pre-processing | `permissions: { before: async function(frame) {...} }` |
### 3. Keep Permission Logic Focused
Permission functions should only check permissions, not perform business logic:
```javascript
// Good - only checks permissions
permissions: async function(frame) {
if (!frame.user || frame.user.role !== 'admin') {
throw new NoPermissionError();
}
}
// Bad - mixes permission check with business logic
permissions: async function(frame) {
if (!frame.user) throw new NoPermissionError();
// Don't do this in permissions!
frame.data.processed = true;
await sendNotification(frame.user);
}
```
### 4. Use Meaningful Error Messages
```javascript
permissions: async function(frame) {
if (!frame.user) {
throw new UnauthorizedError({
message: 'Please log in to access this resource'
});
}
if (frame.user.role !== 'admin') {
throw new NoPermissionError({
message: 'Administrator access required for this operation'
});
}
}
```
### 5. Validate Resource Ownership
When resources belong to specific users, always verify ownership:
```javascript
permissions: async function(frame) {
const resource = await models.Resource.findOne({id: frame.options.id});
if (!resource) {
throw new NotFoundError({message: 'Resource not found'});
}
const isOwner = resource.get('user_id') === frame.user.id;
const isAdmin = frame.user.role === 'admin';
if (!isOwner && !isAdmin) {
throw new NoPermissionError({
message: 'You do not have permission to access this resource'
});
}
}
```
### 6. Use `unsafeAttrs` for Sensitive Fields
Mark fields that require elevated permissions:
```javascript
permissions: {
unsafeAttrs: [
'author_id', // Only admins should change authorship
'status', // Publishing requires special permission
'visibility', // Changing visibility is restricted
'featured' // Only editors can feature content
]
}
```
---
## Error Types
Use appropriate error types from `@tryghost/errors`:
- **UnauthorizedError** - User is not authenticated
- **NoPermissionError** - User is authenticated but lacks permission
- **NotFoundError** - Resource doesn't exist (use carefully to avoid information leakage)
- **ValidationError** - Input validation failed
```javascript
const {
UnauthorizedError,
NoPermissionError,
NotFoundError
} = require('@tryghost/errors');
```
---
## Adding Permissions via Migrations
When creating a new API endpoint that uses the default permission handler (`permissions: true`), you need to add permissions to the database. Ghost provides utilities to make this easy.
### Migration Utilities
Import the permission utilities from `ghost/core/core/server/data/migrations/utils`:
```javascript
const {combineTransactionalMigrations, addPermissionWithRoles} = require('../../utils');
```
### Example: Adding CRUD Permissions for a New Resource
```javascript
// ghost/core/core/server/data/migrations/versions/X.X/YYYY-MM-DD-HH-MM-SS-add-myresource-permissions.js
const {combineTransactionalMigrations, addPermissionWithRoles} = require('../../utils');
module.exports = combineTransactionalMigrations(
addPermissionWithRoles({
name: 'Browse my resources',
action: 'browse',
object: 'my_resource' // Singular form of docName
}, [
'Administrator',
'Admin Integration'
]),
addPermissionWithRoles({
name: 'Read my resources',
action: 'read',
object: 'my_resource'
}, [
'Administrator',
'Admin Integration'
]),
addPermissionWithRoles({
name: 'Edit my resources',
action: 'edit',
object: 'my_resource'
}, [
'Administrator',
'Admin Integration'
]),
addPermissionWithRoles({
name: 'Add my resources',
action: 'add',
object: 'my_resource'
}, [
'Administrator',
'Admin Integration'
]),
addPermissionWithRoles({
name: 'Delete my resources',
action: 'destroy',
object: 'my_resource'
}, [
'Administrator',
'Admin Integration'
])
);
```
### Available Roles
Common roles you can assign permissions to:
- **Administrator** - Full admin access
- **Admin Integration** - API integrations with admin scope
- **Editor** - Can manage all content
- **Author** - Can manage own content
- **Contributor** - Can create drafts only
- **Owner** - Site owner (inherits all Administrator permissions)
### Permission Naming Conventions
- **name**: Human-readable, e.g., `'Browse automated emails'`
- **action**: The API method - `browse`, `read`, `edit`, `add`, `destroy`
- **object**: Singular form of `docName` - `automated_email` (not `automated_emails`)
### Restricting to Administrators Only
To make an endpoint accessible only to administrators (not editors, authors, etc.), only assign permissions to:
- `Administrator`
- `Admin Integration`
```javascript
addPermissionWithRoles({
name: 'Browse sensitive data',
action: 'browse',
object: 'sensitive_data'
}, [
'Administrator',
'Admin Integration'
])
```
@@ -0,0 +1,633 @@
# Ghost API Framework Reference
## Overview
The API framework is a pipeline-based system that processes HTTP requests through a series of stages before executing the controller logic. It provides consistent validation, serialization, and permission handling across all API endpoints.
## Request Flow
Each request goes through these stages in order:
1. **Input Validation** - Validates query params, URL params, and request body
2. **Input Serialization** - Transforms incoming data (e.g., maps `include` to `withRelated`)
3. **Permissions** - Checks if the user/API key has access to the resource
4. **Query** - Executes the actual business logic (your controller code)
5. **Output Serialization** - Formats the response for the client
## The Frame Object
The `Frame` class holds all request information and is passed through each stage. Each stage can modify it by reference.
### Frame Structure
```javascript
{
original: Object, // Original input (for debugging)
options: Object, // Query params, URL params, context, custom options
data: Object, // Request body, or query/URL params if configured via `data`
user: Object, // Logged in user object
file: Object, // Single uploaded file
files: Array, // Multiple uploaded files
apiType: String, // 'content' or 'admin'
docName: String, // Endpoint name (e.g., 'posts')
method: String, // Method name (e.g., 'browse', 'read', 'add', 'edit')
response: Object // Set by output serialization
}
```
### Frame Example
```javascript
{
original: {
include: 'tags,authors'
},
options: {
withRelated: ['tags', 'authors'],
context: { user: '123' }
},
data: {
posts: [{ title: 'My Post' }]
}
}
```
## API Controller Structure
Controllers are objects with a `docName` property and method configurations.
### Basic Structure
```javascript
module.exports = {
docName: 'posts', // Required: endpoint name
browse: {
headers: {},
options: [],
data: [],
validation: {},
permissions: true,
query(frame) {}
},
read: { /* ... */ },
add: { /* ... */ },
edit: { /* ... */ },
destroy: { /* ... */ }
};
```
## Controller Method Properties
### `headers` (Object)
Configure HTTP response headers.
```javascript
headers: {
// Invalidate cache after mutation
cacheInvalidate: true,
// Or with specific path
cacheInvalidate: { value: '/posts/*' },
// File disposition for downloads
disposition: {
type: 'csv', // 'csv', 'json', 'yaml', or 'file'
value: 'export.csv' // Can also be a function
},
// Location header (auto-generated for 'add' methods)
location: false // Disable auto-generation
}
```
### `options` (Array)
Allowed query/URL parameters that go into `frame.options`.
```javascript
options: ['include', 'filter', 'page', 'limit', 'order']
```
Can also be a function:
```javascript
options: (frame) => {
return frame.apiType === 'content'
? ['include']
: ['include', 'filter'];
}
```
### `data` (Array)
Parameters that go into `frame.data` instead of `frame.options`. Useful for READ requests where the model expects `findOne(data, options)`.
```javascript
data: ['id', 'slug', 'email']
```
### `validation` (Object | Function)
Configure input validation. The framework validates against global validators automatically.
```javascript
validation: {
options: {
include: {
required: true,
values: ['tags', 'authors', 'tiers']
},
filter: {
required: false
}
},
data: {
slug: {
required: true,
values: ['specific-slug'] // Restrict to specific values
}
}
}
```
**Global validators** (automatically applied when parameters are present):
- `id` - Must match `/^[a-f\d]{24}$|^1$|me/i`
- `page` - Must be a number
- `limit` - Must be a number or 'all'
- `uuid` - Must be a valid UUID
- `slug` - Must be a valid slug
- `email` - Must be a valid email
- `order` - Must match `/^[a-z0-9_,. ]+$/i`
For custom validation, use a function:
```javascript
validation(frame) {
if (!frame.data.posts[0].title) {
return Promise.reject(new errors.ValidationError({
message: 'Title is required'
}));
}
}
```
### `permissions` (Boolean | Object | Function)
**Required field** - you must always specify permissions to avoid security holes.
```javascript
// Use default permission handling
permissions: true,
// Skip permission checking (use sparingly!)
permissions: false,
// With configuration
permissions: {
// Attributes that require elevated permissions
unsafeAttrs: ['status', 'authors'],
// Run code before permission check
before(frame) {
// Modify frame or do pre-checks
},
// Specify which resource type to check against
docName: 'posts',
// Specify different method for permission check
method: 'browse'
}
// Custom permission handling
permissions: async function(frame) {
const hasAccess = await checkCustomAccess(frame);
if (!hasAccess) {
return Promise.reject(new errors.NoPermissionError());
}
}
```
### `query` (Function) - Required
The main business logic. Returns the API response.
```javascript
query(frame) {
// Access validated options
const { include, filter, page, limit } = frame.options;
// Access request body
const postData = frame.data.posts[0];
// Access context
const userId = frame.options.context.user;
// Return model response
return models.Post.findPage(frame.options);
}
```
### `statusCode` (Number | Function)
Set the HTTP status code. Defaults to 200.
```javascript
// Fixed status code
statusCode: 201,
// Dynamic based on result
statusCode: (result) => {
return result.posts.length ? 200 : 204;
}
```
### `response` (Object)
Configure response format.
```javascript
response: {
format: 'plain' // Send as plain text instead of JSON
}
```
### `cache` (Object)
Enable endpoint-level caching.
```javascript
cache: {
async get(cacheKey, fallback) {
const cached = await redis.get(cacheKey);
return cached || await fallback();
},
async set(cacheKey, response) {
await redis.set(cacheKey, response, 'EX', 3600);
}
}
```
### `generateCacheKeyData` (Function)
Customize cache key generation.
```javascript
generateCacheKeyData(frame) {
// Default uses frame.options
return {
...frame.options,
customKey: 'value'
};
}
```
## Complete Controller Examples
### Browse Endpoint (List)
```javascript
browse: {
headers: {
cacheInvalidate: false
},
options: [
'include',
'filter',
'fields',
'formats',
'page',
'limit',
'order'
],
validation: {
options: {
include: {
values: ['tags', 'authors', 'tiers']
},
formats: {
values: ['html', 'plaintext', 'mobiledoc']
}
}
},
permissions: true,
query(frame) {
return models.Post.findPage(frame.options);
}
}
```
### Read Endpoint (Single)
```javascript
read: {
headers: {
cacheInvalidate: false
},
options: ['include', 'fields', 'formats'],
data: ['id', 'slug'],
validation: {
options: {
include: {
values: ['tags', 'authors']
}
}
},
permissions: true,
query(frame) {
return models.Post.findOne(frame.data, frame.options);
}
}
```
### Add Endpoint (Create)
```javascript
add: {
headers: {
cacheInvalidate: true
},
options: ['include'],
validation: {
options: {
include: {
values: ['tags', 'authors']
}
},
data: {
title: { required: true }
}
},
permissions: {
unsafeAttrs: ['status', 'authors']
},
statusCode: 201,
query(frame) {
return models.Post.add(frame.data.posts[0], frame.options);
}
}
```
### Edit Endpoint (Update)
```javascript
edit: {
headers: {
cacheInvalidate: true
},
options: ['include', 'id'],
validation: {
options: {
include: {
values: ['tags', 'authors']
},
id: {
required: true
}
}
},
permissions: {
unsafeAttrs: ['status', 'authors']
},
query(frame) {
return models.Post.edit(frame.data.posts[0], frame.options);
}
}
```
### Destroy Endpoint (Delete)
```javascript
destroy: {
headers: {
cacheInvalidate: true
},
options: ['id'],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
statusCode: 204,
query(frame) {
return models.Post.destroy(frame.options);
}
}
```
### File Upload Endpoint
```javascript
uploadImage: {
headers: {
cacheInvalidate: false
},
permissions: {
method: 'add'
},
query(frame) {
// Access uploaded file
const file = frame.file;
return imageService.upload({
path: file.path,
name: file.name,
type: file.type
});
}
}
```
### CSV Export Endpoint
```javascript
exportCSV: {
headers: {
disposition: {
type: 'csv',
value() {
return `members.${new Date().toISOString()}.csv`;
}
}
},
options: ['filter'],
permissions: true,
response: {
format: 'plain'
},
query(frame) {
return membersService.export(frame.options);
}
}
```
## Using the Framework
### HTTP Wrapper
Wrap controllers for Express routes:
```javascript
const {http} = require('@tryghost/api-framework');
// In routes
router.get('/posts', http(api.posts.browse));
router.get('/posts/:id', http(api.posts.read));
router.post('/posts', http(api.posts.add));
router.put('/posts/:id', http(api.posts.edit));
router.delete('/posts/:id', http(api.posts.destroy));
```
### Internal API Calls
Call controllers programmatically:
```javascript
// With data and options
const result = await api.posts.add(
{ posts: [{ title: 'New Post' }] }, // data
{ context: { user: userId } } // options
);
// Options only
const posts = await api.posts.browse({
filter: 'status:published',
include: 'tags',
context: { user: userId }
});
```
### Custom Validators
Create endpoint-specific validators in the API utils:
```javascript
// In api/utils/validators/input/posts.js
module.exports = {
add(apiConfig, frame) {
// Custom validation for posts.add
const post = frame.data.posts[0];
if (post.status === 'published' && !post.title) {
return Promise.reject(new errors.ValidationError({
message: 'Published posts must have a title'
}));
}
}
};
```
### Custom Serializers
Create input/output serializers:
```javascript
// Input serializer
module.exports = {
all(apiConfig, frame) {
// Transform include to withRelated
if (frame.options.include) {
frame.options.withRelated = frame.options.include.split(',');
}
}
};
// Output serializer
module.exports = {
posts: {
browse(response, apiConfig, frame) {
// Transform model response to API response
frame.response = {
posts: response.data.map(post => serializePost(post)),
meta: {
pagination: response.meta.pagination
}
};
}
}
};
```
## Common Patterns
### Checking User Context
```javascript
query(frame) {
const isAdmin = frame.options.context.user;
const isIntegration = frame.options.context.integration;
const isMember = frame.options.context.member;
if (isAdmin) {
return models.Post.findPage(frame.options);
} else {
frame.options.filter = 'status:published';
return models.Post.findPage(frame.options);
}
}
```
### Handling Express Response Directly
For streaming or special responses:
```javascript
query(frame) {
// Return a function to handle Express response
return function handler(req, res, next) {
const stream = generateStream();
stream.pipe(res);
};
}
```
### Setting Custom Headers in Query
```javascript
query(frame) {
// Set headers from within query
frame.setHeader('X-Custom-Header', 'value');
return models.Post.findPage(frame.options);
}
```
## Error Handling
Use `@tryghost/errors` for consistent error responses:
```javascript
const errors = require('@tryghost/errors');
query(frame) {
if (!frame.data.posts[0].title) {
throw new errors.ValidationError({
message: 'Title is required'
});
}
if (notFound) {
throw new errors.NotFoundError({
message: 'Post not found'
});
}
if (noAccess) {
throw new errors.NoPermissionError({
message: 'You do not have permission to access this resource'
});
}
}
```
## Best Practices
1. **Always specify `permissions`** - Never omit this field, it's a security requirement
2. **Use `options` to whitelist params** - Only allowed params are passed through
3. **Prefer declarative validation** - Use the validation object over custom functions
4. **Set `cacheInvalidate` appropriately** - True for mutations, false for reads
5. **Use `unsafeAttrs` for sensitive fields** - Requires elevated permissions to modify
6. **Return model responses from `query`** - Let serializers handle transformation
7. **Use `data` for READ endpoints** - When the model expects `findOne(data, options)`
@@ -0,0 +1,747 @@
# API Controller Validation Guide
This guide explains how to configure validations in api-framework controllers, covering all available patterns, built-in validators, and best practices.
## Table of Contents
- [Overview](#overview)
- [Validation Patterns](#validation-patterns)
- [Object-Based Validation](#pattern-1-object-based-validation)
- [Function-Based Validation](#pattern-2-function-based-validation)
- [Validating Options (Query Parameters)](#validating-options-query-parameters)
- [Validating Data (Request Body)](#validating-data-request-body)
- [Built-in Global Validators](#built-in-global-validators)
- [Method-Specific Validation Behavior](#method-specific-validation-behavior)
- [Complete Examples](#complete-examples)
- [Error Handling](#error-handling)
- [Best Practices](#best-practices)
---
## Overview
The api-framework uses a **pipeline-based validation system** where validations run as the first processing stage:
1. **Validation** ← You are here
2. Input serialisation
3. Permissions
4. Query (controller execution)
5. Output serialisation
Validation ensures that:
- Required fields are present
- Values are in allowed lists
- Data types are correct (IDs, emails, slugs, etc.)
- Request structure is valid before processing
---
## Validation Patterns
### Pattern 1: Object-Based Validation
The most common pattern using configuration objects:
```javascript
browse: {
options: ['include', 'page', 'limit'],
validation: {
options: {
include: {
values: ['tags', 'authors'],
required: true
},
page: {
required: false
}
}
},
permissions: true,
query(frame) {
return models.Post.findPage(frame.options);
}
}
```
**When to use:**
- Standard field validation (required, allowed values)
- Most common case for API endpoints
---
### Pattern 2: Function-Based Validation
Complete control over validation logic:
```javascript
add: {
validation(frame) {
const {ValidationError} = require('@tryghost/errors');
if (!frame.data.posts || !frame.data.posts.length) {
return Promise.reject(new ValidationError({
message: 'No posts provided'
}));
}
const post = frame.data.posts[0];
if (!post.title || post.title.length < 3) {
return Promise.reject(new ValidationError({
message: 'Title must be at least 3 characters'
}));
}
return Promise.resolve();
},
permissions: true,
query(frame) {
return models.Post.add(frame.data.posts[0], frame.options);
}
}
```
**When to use:**
- Complex validation logic
- Cross-field validation
- Conditional validation rules
- Custom error messages
---
## Validating Options (Query Parameters)
Options are URL query parameters and route params. Define allowed options in the `options` array and configure validation rules.
### Required Fields
```javascript
browse: {
options: ['filter'],
validation: {
options: {
filter: {
required: true
}
}
},
permissions: true,
query(frame) {
return models.Post.findAll(frame.options);
}
}
```
### Allowed Values
Two equivalent syntaxes:
**Object notation:**
```javascript
validation: {
options: {
include: {
values: ['tags', 'authors', 'count.posts']
}
}
}
```
**Array shorthand:**
```javascript
validation: {
options: {
include: ['tags', 'authors', 'count.posts']
}
}
```
### Combined Rules
```javascript
validation: {
options: {
include: {
values: ['tags', 'authors'],
required: true
},
status: {
values: ['draft', 'published', 'scheduled'],
required: false
}
}
}
```
### Special Behavior: Include Parameter
The `include` parameter has special handling - invalid values are silently filtered instead of causing an error:
```javascript
// Request: ?include=tags,invalid_field,authors
// Result: frame.options.include = 'tags,authors'
```
This allows for graceful degradation when clients request unsupported includes.
---
## Validating Data (Request Body)
Data validation applies to request body content. The structure differs based on the HTTP method.
### For READ Operations
Data comes from query parameters:
```javascript
read: {
data: ['id', 'slug'],
validation: {
data: {
slug: {
values: ['featured', 'latest']
}
}
},
permissions: true,
query(frame) {
return models.Post.findOne(frame.data, frame.options);
}
}
```
### For ADD/EDIT Operations
Data comes from the request body with a root key:
```javascript
add: {
validation: {
data: {
title: {
required: true
},
status: {
required: false
}
}
},
permissions: true,
query(frame) {
return models.Post.add(frame.data.posts[0], frame.options);
}
}
```
**Request body structure:**
```json
{
"posts": [{
"title": "My Post",
"status": "draft"
}]
}
```
### Root Key Validation
For ADD/EDIT operations, the framework automatically validates:
1. Root key exists (e.g., `posts`, `users`)
2. Root key contains an array with at least one item
3. Required fields exist and are not null
---
## Built-in Global Validators
The framework automatically validates common field types using the `@tryghost/validator` package:
| Field Name | Validation Rule | Example Valid Values |
|------------|-----------------|---------------------|
| `id` | MongoDB ObjectId, `1`, or `me` | `507f1f77bcf86cd799439011`, `me` |
| `uuid` | UUID format | `550e8400-e29b-41d4-a716-446655440000` |
| `slug` | URL-safe slug | `my-post-title` |
| `email` | Email format | `user@example.com` |
| `page` | Numeric | `1`, `25` |
| `limit` | Numeric or `all` | `10`, `all` |
| `from` | Date format | `2024-01-15` |
| `to` | Date format | `2024-12-31` |
| `order` | Sort format | `created_at desc`, `title asc` |
| `columns` | Column list | `id,title,created_at` |
### Fields with No Validation
These fields skip validation by default:
- `filter`
- `context`
- `forUpdate`
- `transacting`
- `include`
- `formats`
- `name`
---
## Method-Specific Validation Behavior
Different HTTP methods have different validation behaviors:
### BROWSE / READ
- Validates `frame.data` against `apiConfig.data`
- Allows empty data
- Uses global validators for field types
### ADD
1. Validates root key exists in `frame.data`
2. Checks required fields are present
3. Checks required fields are not null
**Error examples:**
- `"No root key ('posts') provided."`
- `"Validation (FieldIsRequired) failed for title"`
- `"Validation (FieldIsInvalid) failed for title"` (when null)
### EDIT
1. Performs all ADD validations
2. Validates ID consistency between URL and body
```javascript
// URL: /posts/123
// Body: { "posts": [{ "id": "456", ... }] }
// Error: "Invalid id provided."
```
### Special Methods
These methods use specific validation behaviors:
- `changePassword()` - Uses ADD rules
- `resetPassword()` - Uses ADD rules
- `setup()` - Uses ADD rules
- `publish()` - Uses BROWSE rules
---
## Complete Examples
### Example 1: Simple Browse with Options
```javascript
module.exports = {
docName: 'posts',
browse: {
options: ['include', 'page', 'limit', 'filter', 'order'],
validation: {
options: {
include: ['tags', 'authors', 'count.posts'],
page: {
required: false
},
limit: {
required: false
}
}
},
permissions: true,
query(frame) {
return models.Post.findPage(frame.options);
}
}
};
```
### Example 2: Read with Data Validation
```javascript
module.exports = {
docName: 'posts',
read: {
options: ['include'],
data: ['id', 'slug'],
validation: {
options: {
include: ['tags', 'authors']
},
data: {
id: {
required: false
},
slug: {
required: false
}
}
},
permissions: true,
query(frame) {
return models.Post.findOne(frame.data, frame.options);
}
}
};
```
### Example 3: Add with Required Fields
```javascript
module.exports = {
docName: 'users',
add: {
validation: {
data: {
name: {
required: true
},
email: {
required: true
},
password: {
required: true
},
role: {
required: false
}
}
},
permissions: true,
query(frame) {
return models.User.add(frame.data.users[0], frame.options);
}
}
};
```
### Example 4: Custom Validation Function
```javascript
module.exports = {
docName: 'subscriptions',
add: {
validation(frame) {
const {ValidationError} = require('@tryghost/errors');
const subscription = frame.data.subscriptions?.[0];
if (!subscription) {
return Promise.reject(new ValidationError({
message: 'No subscription data provided'
}));
}
// Validate email format
if (!subscription.email || !subscription.email.includes('@')) {
return Promise.reject(new ValidationError({
message: 'Valid email address is required'
}));
}
// Validate plan
const validPlans = ['free', 'basic', 'premium'];
if (!validPlans.includes(subscription.plan)) {
return Promise.reject(new ValidationError({
message: `Plan must be one of: ${validPlans.join(', ')}`
}));
}
// Cross-field validation
if (subscription.plan !== 'free' && !subscription.payment_method) {
return Promise.reject(new ValidationError({
message: 'Payment method required for paid plans'
}));
}
return Promise.resolve();
},
permissions: true,
query(frame) {
return models.Subscription.add(frame.data.subscriptions[0], frame.options);
}
}
};
```
### Example 5: Edit with ID Consistency
```javascript
module.exports = {
docName: 'posts',
edit: {
options: ['id', 'include'],
validation: {
options: {
include: ['tags', 'authors']
},
data: {
title: {
required: false
},
status: {
values: ['draft', 'published', 'scheduled']
}
}
},
permissions: {
unsafeAttrs: ['status', 'author_id']
},
query(frame) {
return models.Post.edit(frame.data.posts[0], frame.options);
}
}
};
```
### Example 6: Complex Browse with Multiple Validations
```javascript
module.exports = {
docName: 'analytics',
browse: {
options: ['from', 'to', 'interval', 'metrics', 'dimensions'],
validation: {
options: {
from: {
required: true
},
to: {
required: true
},
interval: {
values: ['hour', 'day', 'week', 'month'],
required: false
},
metrics: {
values: ['pageviews', 'visitors', 'sessions', 'bounce_rate'],
required: true
},
dimensions: {
values: ['page', 'source', 'country', 'device'],
required: false
}
}
},
permissions: true,
query(frame) {
return analytics.query(frame.options);
}
}
};
```
---
## Error Handling
### Error Types
Validation errors use types from `@tryghost/errors`:
- **ValidationError** - Field validation failed
- **BadRequestError** - Malformed request structure
### Error Message Format
```javascript
// Missing required field
"Validation (FieldIsRequired) failed for title"
// Invalid value
"Validation (AllowedValues) failed for status"
// Field is null when required
"Validation (FieldIsInvalid) failed for title"
// Missing root key
"No root key ('posts') provided."
// ID mismatch
"Invalid id provided."
```
### Custom Error Messages
When using function-based validation:
```javascript
validation(frame) {
const {ValidationError} = require('@tryghost/errors');
if (!frame.data.email) {
return Promise.reject(new ValidationError({
message: 'Email address is required',
context: 'Please provide a valid email address to continue',
help: 'Check that the email field is included in your request'
}));
}
return Promise.resolve();
}
```
---
## Best Practices
### 1. Define All Allowed Options
Always explicitly list allowed options to prevent unexpected parameters:
```javascript
// Good - explicit allowed options
options: ['include', 'page', 'limit', 'filter'],
// Bad - no options defined (might allow anything)
// options: undefined
```
### 2. Use Built-in Validators
Let the framework handle common field types:
```javascript
// Good - framework validates automatically
options: ['id', 'email', 'slug']
// Unnecessary - these are validated by default
validation: {
options: {
id: { matches: /^[a-f\d]{24}$/ } // Already built-in
}
}
```
### 3. Mark Required Fields Explicitly
Be explicit about which fields are required:
```javascript
validation: {
data: {
title: { required: true },
slug: { required: false },
status: { required: false }
}
}
```
### 4. Use Array Shorthand for Simple Cases
When only validating allowed values:
```javascript
// Shorter and cleaner
validation: {
options: {
include: ['tags', 'authors'],
status: ['draft', 'published']
}
}
// Equivalent verbose form
validation: {
options: {
include: { values: ['tags', 'authors'] },
status: { values: ['draft', 'published'] }
}
}
```
### 5. Combine with Permissions
Validation runs before permissions, ensuring data structure is valid:
```javascript
edit: {
validation: {
data: {
author_id: { required: false }
}
},
permissions: {
unsafeAttrs: ['author_id'] // Validated first, then permission-checked
},
query(frame) {
return models.Post.edit(frame.data.posts[0], frame.options);
}
}
```
### 6. Use Custom Functions for Complex Logic
When validation rules depend on multiple fields or external state:
```javascript
validation(frame) {
// Date range validation
if (frame.options.from && frame.options.to) {
const from = new Date(frame.options.from);
const to = new Date(frame.options.to);
if (from > to) {
return Promise.reject(new ValidationError({
message: 'From date must be before to date'
}));
}
// Max 30 day range
const diffDays = (to - from) / (1000 * 60 * 60 * 24);
if (diffDays > 30) {
return Promise.reject(new ValidationError({
message: 'Date range cannot exceed 30 days'
}));
}
}
return Promise.resolve();
}
```
### 7. Provide Helpful Error Messages
Make errors actionable for API consumers:
```javascript
// Good - specific and actionable
"Status must be one of: draft, published, scheduled"
// Bad - vague
"Invalid status"
```
---
## Validation Flow Diagram
```
HTTP Request
Frame Creation
Frame Configuration (pick options/data)
┌─────────────────────────────┐
│ VALIDATION STAGE │
├─────────────────────────────┤
│ Is validation a function? │
│ ├─ Yes → Run custom logic │
│ └─ No → Framework validation│
│ ├─ Global validators │
│ ├─ Required fields │
│ ├─ Allowed values │
│ └─ Method-specific rules│
└─────────────────────────────┘
Input Serialisation
Permissions
Query Execution
Output Serialisation
HTTP Response
```
@@ -0,0 +1,28 @@
---
name: add-private-feature-flag
description: Use when adding a new private (developer experiments) feature flag to Ghost, including the backend registration and settings UI toggle.
---
# Add Private Feature Flag
## Overview
Adds a new private feature flag to Ghost. Private flags appear in Labs settings under the "Private features" tab, visible only when developer experiments are enabled.
## Steps
1. **Add the flag to `ghost/core/core/shared/labs.js`**
- Add the flag name (camelCase string) to the `PRIVATE_FEATURES` array.
2. **Add a UI toggle in `apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx`**
- Add a new entry to the `features` array with `title`, `description`, and `flag` (must match the string in `labs.js`).
3. **Run tests and update the config API snapshot**
- Unit: `cd ghost/core && pnpm test:single test/unit/shared/labs.test.js`
- Update snapshot and run e2e: `cd ghost/core && UPDATE_SNAPSHOTS=1 pnpm test:single test/e2e-api/admin/config.test.js`
- Review the diff of `ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap` to confirm only your new flag was added.
## Notes
- No database migration is needed. Labs flags are stored in a single JSON `labs` setting.
- The flag name must be identical in `labs.js`, `private-features.tsx`, and the snapshot.
- Flags are camelCase strings (e.g. `welcomeEmailDesignCustomization`).
- For public beta flags (visible to all users), add to `PUBLIC_BETA_FEATURES` in `labs.js` instead and add the toggle to `apps/admin-x-settings/src/components/settings/advanced/labs/beta-features.tsx`.
+61
View File
@@ -0,0 +1,61 @@
---
name: commit
description: Commit message formatting and guidelines
---
# Commit
Use this skill whenever the user asks you to create a git commit for the current work.
## Instructions
1. Review the current git state before committing:
- `git status`
- `git diff`
- `git log -5 --oneline`
2. Only stage files relevant to the requested change. Do not include unrelated untracked files, generated files, or likely-local artifacts.
3. Always follow Ghost's commit conventions (see below) for commit messages
4. Run `git status --short` after committing and confirm the result.
## Important
- Do not push to remote unless the user explicitly asks
- Keep commits focused and avoid bundling unrelated changes
- If there are no relevant changes, do not create an empty commit
- If hooks fail, fix the issue and create a new commit. Never bypass hooks.
## Commit message format
We have a handful of simple standards for commit messages which help us to generate readable changelogs. Please follow this wherever possible and mention the associated issue number.
- **1st line:** Max 80 character summary
- Written in past tense e.g. “Fixed the thing” not “Fixes the thing”
- Start with one of: Fixed, Changed, Updated, Improved, Added, Removed, Reverted, Moved, Released, Bumped, Cleaned
- **2nd line:** [Always blank]
- **3rd line:** `ref <issue link>`, `fixes <issue link>`, `closes <issue link>` or blank
- **4th line:** Why this change was made - the code includes the what, the commit message should describe the context of why - why this, why now, why not something else?
If your change is **user-facing** please prepend the first line of your commit with **an emoji**.
Because emoji commits are the release notes, it's important that anything that gets an emoji is a user-facing change that's significant and relevant for end-users to see.
The first line of an emoji commit message should be from the perspective of the user. For example, 🐛 Fixed a race condition in the members service is technical and tells the user nothing, but 🐛 Fixed a bug causing active members to lose access to paid content tells the user reading the release notes “oh yeah, they fixed that bug I kept hitting.”
### Main emojis we are using:
- ✨ Feature
- 🎨 Improvement / change
- 🐛 Bug Fix
- 🌐 i18n (translation) submissions
- 💡 Anything else flagged to users or whoever is writing release notes
### Example
```
✨ Added config flag for disabling page analytics
ref https://linear.app/tryghost/issue/ENG-1234/
- analytics are brand new under development, therefore they need to be behind a flag
- not using the developerExperiments flag as that is already in wide use and we aren't ready to deploy this anywhere yet
- using the term `pageAnalytics` as this was discussed as best reflecting what this does
```
@@ -0,0 +1,24 @@
---
name: Create database migration
description: Create a database migration to add a table, add columns to an existing table, add a setting, or otherwise change the schema of Ghost's MySQL database. Use this skill whenever the task involves modifying Ghost's database schema — including adding, removing, or renaming columns or tables, adding new settings, creating indexes, updating data, or any change that requires a migration file in ghost/core. Also use when the user references schema.js, knex-migrator, the migrations directory, or asks to "add a field" or "add a column" to any Ghost model/table. Even if the user frames it as a feature or Linear issue, if the implementation requires a schema change, this skill applies.
---
# Create Database Migration
## Instructions
1. Create a new, empty migration file: `cd ghost/core && pnpm migrate:create <kebab-case-slug>`. IMPORTANT: do not create the migration file manually; always use this script to create the initial empty migration file. The slug must be kebab-case (e.g. `add-column-to-posts`).
2. The above command will create a new directory in `ghost/core/core/server/data/migrations/versions` if needed, create the empty migration file with the appropriate name, and bump the core and admin package versions to RC if this is the first migration after a release.
3. Update the migration file with the changes you want to make in the database, following the existing patterns in the codebase. Where appropriate, prefer to use the utility functions in `ghost/core/core/server/data/migrations/utils/*`.
4. Update the schema definition file in `ghost/core/core/server/data/schema/schema.js`, and make sure it aligns with the latest changes from the migration.
5. Test the migration manually: `cd ghost/core && pnpm knex-migrator migrate --v {version directory} --force`
6. If adding or dropping a table, update `ghost/core/core/server/data/exporter/table-lists.js` as appropriate.
7. If adding or dropping a table, also add or remove the table name from the expected tables list in `ghost/core/test/integration/exporter/exporter.test.js`. This test has a hardcoded alphabetically-sorted array of all database tables — it runs in CI integration tests (not unit tests) and will fail if the new table is missing.
8. Run the schema integrity test, and update the hash: `cd ghost/core && pnpm test:single test/unit/server/data/schema/integrity.test.js`
9. Run unit tests in Ghost core, and iterate until they pass: `cd ghost/core && pnpm test:unit`
## Examples
See [examples.md](examples.md) for example migrations.
## Rules
See [rules.md](rules.md) for rules that should always be followed when creating database migrations.
@@ -0,0 +1,17 @@
# Example database migrations
## Create a table
See [add mentions table](../../../ghost/core/core/server/data/migrations/versions/5.31/2023-01-19-07-46-add-mentions-table.js).
## Add column(s) to an existing table
See [add source columns to emails table](../../../ghost/core/core/server/data/migrations/versions/5.24/2022-11-21-09-32-add-source-columns-to-emails-table.js).
## Add a setting
See [add member track source setting](../../../ghost/core/core/server/data/migrations/versions/5.21/2022-10-27-09-50-add-member-track-source-setting.js)
## Manipulate data
See [update newsletter subscriptions](../../../ghost/core/core/server/data/migrations/versions/5.31/2022-12-05-09-56-update-newsletter-subscriptions.js).
@@ -0,0 +1,33 @@
# Rules for creating database migrations
## Migrations must be idempotent
It must be safe to run the migration twice. It's possible for a migration to stop executing due to external factors, so it must be safe to run the migration again successfully.
## Migrations must NOT use the model layer
Migrations are written for a specific version, and when they use the model layer, the asusmption is that they are using the models at that version. In reality, the models are of the version which is being migrated to, not from. This means that breaking changes in the models can inadvertently break migrations.
## Migrations are Immutable
Once migrations are on the `main` branch, they're final. If you need to make further changes after merging to main, create a new migration instead.
## Use utility functions
Wherever possible, use the utility functions in `ghost/core/core/server/data/migrations/utils`, such as `addTable`, `createTransactionalMigration`, and `addSetting`. These util functions have been tested and already include protections for idempotency, as well as log statements where appropriate to make migrations easier to debug.
## Migration PRs should be as minimal as possible
Migration PRs should contain the minimal amount of code to create the migration. Usually this means it should only include:
- the new migration file
- updates to the schema.js file
- updated schema integrity hash tests
- updated exporter table lists (when adding or removing tables)
## Migrations should be defensive
Protect against missing data. If a migration crashes, Ghost cannot boot.
## Migrations should log every code path
If we have to debug a migration, we need to know what it actually did. Without logging, that's impossible, so ensure all code paths and early returns contain logging. Note: when using the utility functions, logging is typically handled in the utility function itself, so no additional logging statements are necessary.
+60
View File
@@ -0,0 +1,60 @@
---
name: Format numbers
description: Format numbers using the formatNumber function from Shade whenever someone edits a TSX file.
autoTrigger:
- fileEdit: "**/*.tsx"
---
# Format Numbers
When editing `.tsx` files, ensure all user-facing numbers are formatted using the `formatNumber` utility from `@tryghost/shade`.
## Import
```typescript
import {formatNumber} from '@tryghost/shade';
```
## When to use formatNumber
Use `formatNumber()` when rendering any numeric value that is displayed to the user, including:
- Member counts, visitor counts, subscriber counts
- Email engagement metrics (opens, clicks, bounces)
- Revenue amounts (combine with `centsToDollars()` for monetary values)
- Post analytics (views, link clicks)
- Any count or quantity shown in UI
## Correct usage
```tsx
<span>{formatNumber(totalMembers)}</span>
<span>{formatNumber(link.count || 0)}</span>
<span>{`${currencySymbol}${formatNumber(centsToDollars(mrr))}`}</span>
<span>{post.members > 0 ? `+${formatNumber(post.members)}` : '0'}</span>
```
## Antipatterns to avoid
Do NOT use any of these patterns for formatting numbers in TSX files:
```tsx
// BAD: raw .toLocaleString()
<span>{count.toLocaleString()}</span>
// BAD: manual Intl.NumberFormat
<span>{new Intl.NumberFormat('en-US').format(count)}</span>
// BAD: raw number without formatting
<span>{memberCount}</span>
// BAD: manual regex formatting
<span>{count.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')}</span>
```
## Related utilities
- `formatPercentage()` - for percentages (e.g., open rates, click rates)
- `abbreviateNumber()` - for compact notation (e.g., 1.2M, 50k)
- `centsToDollars()` - convert cents to dollars before passing to `formatNumber`
All are imported from `@tryghost/shade`.