This commit is contained in:
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user