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
```