This file provides comprehensive guidance for AI agents contributing to this repository.
Note: This document contains detailed code examples and implementation patterns. For a concise human-readable overview, see README.md.
A multitenant ActivityPub server for Ghost, built with Fedify. This service makes it possible for independent websites to publish their content directly to the Fediverse, enabling networked publishing to the open social web
- Node.js - Runtime
- TypeScript - Programming language
- Yarn - Node package management
- Biome - Linter & code formatter
- esbuild - Bundler
- Hono - Web Server
- Fedify - Federation
- Awilix - Dependency injection
- Zod - Schema validation
- Knex - SQL query builder
- Vitest - Testing (unit / integration)
- Cucumber - Testing (e2e)
- Wiremock - API mocking (for e2e tests)
- migrate - Database migrations
- Docker - Containerisation
- Docker Compose - Container orchestration
- MySQL - Database
- Google Cloud Cloud Run - Production deployment
- Google Cloud Cloud SQL - Production database deployment
- Google Cloud Pub/Sub - Production messaging
- Google Cloud Cloud Storage - Production file storage
/dev- Development related tools, configurations, and utilities/features- Cucumber feature files for e2e testing/jobs- One-off jobs to be executed in a production environment (Google Cloud)/migrate- Database migrations/src- Source code for the application
To run the linter:
yarn lintTo run the formatter:
yarn fmtTo run type checking:
yarn test:typesTo run all tests (slow):
yarn testTo run unit tests only (fast):
yarn test:unitTo run integration tests only (slow):
yarn test:integrationTo run e2e tests only (slow):
yarn test:cucumberTo run a single unit / integration test (fast):
yarn test:single 'path/to/test'To run a single e2e test (slow):
- Add a
@onlytag either above a feature file OR a scenario in a feature file:
# hello-world.feature
@only
Feature: Hello world
Scenario: It prints "Hello, world!"
...- Run the test:
yarn test:cucumber @only- Cover as much as possible with unit tests
- Use integration tests for anything that cannot be reasonably unit tested
- Use e2e tests to cover features at a high level
- All unit & integration test files should have the prefix
.test.ts - The type of test should be indicated by the file extension:
.unit.test.tsfor unit tests.integration.test.tsfor integration tests
- Tests should be co-located with the code they test
- e2e tests should reside in the
featuresdirectory - Tests should execute quickly, there is an upper limit of 10 seconds
- Tests are executed within a Docker container when executed via
yarn. This means extra flags passed toyarnwill not be passed to the test runner
Use Tailscale to expose the local machine to the internet:
tailscale funnel 80- Nginx - Reverse proxy used to proxy traffic from port
80to port8080if traffic is meant for activitypub, or forward on to the docker host (host.docker.internal) for any other traffic (i.e Ghost) - MySQL - Database
- Port:
3307 - User:
ghost - Password:
password - Database:
activitypub
- Port:
- Google Cloud Pub/Sub emulator - Pub/Sub emulator
- Port:
8085
- Port:
- Google Cloud Storage emulator - Storage emulator
- Port:
4443
- Port:
yarn devyarn dev && yarn logsyarn stopThis will also stop any service dependencies
yarn wipe-dbWhen there are issues with the environment, this command will attempt to resolve them:
yarn fixyarn migration 'name-of-migration'Do not use spaces in the name of the migration
yarn migrateThis will run any migrations that have not yet been applied
Currently unsupported
- Migrations are run automatically when the application is started via:
yarn dev
📚 See /adr directory for Architecture Decision Records
- Dependency injection is heavily used to manage dependencies and facilitate testing
- The
Resultpattern is preferred over throwing errors, with an exhaustive check on the result to ensure that all possible errors are handled - Business logic is modelled in the entities
- Repositories are used to abstract away the database operations
- Repositories should not be used directly, they should be used through the services
- Services are used to orchestrate business logic
- Services can depend on other services
- Controllers should only be lean and delegate to services where appropriate
- Views are used at the HTTP layer to present data to the client in a fast and
efficient way
- Views can talk directly to the database if necessary
- Views should not be responsible for any business logic
The codebase follows a CQRS-inspired pattern:
Write Path (Commands):
- Controller → Service → Repository → Entity
- Follows strict layering and repository pattern
- Handles business logic, validations, and domain events
Read Path (Queries):
- Controller → View → Database
- Views make optimized queries directly to the database
- Returns DTOs with presentation-ready data
- Includes user-specific context (e.g., followedByMe, blockedByMe)
// ❌ WRONG - Returns no results!
await db('accounts').where('ap_id', apId)
// ✅ CORRECT - Use hash lookup
await db('accounts').whereRaw('ap_id_hash = UNHEX(SHA2(?, 256))', [apId])Always use the helper functions with Result types:
// ✅ CORRECT - Use helpers
const result = await someFunction();
if (isError(result)) {
const error = getError(result);
// handle error
} else {
const value = getValue(result);
// use value
}
// ❌ WRONG - Don't destructure directly
const [error, value] = someResult; // Implementation detail - don't do this!Awilix uses CLASSIC injection mode - parameter names must match registration names:
constructor(
private readonly accountService: AccountService, // Must be registered as 'accountService'
private readonly db: Knex, // Must be registered as 'db'
)Routes are defined using decorators, not direct registration - see ADR-0010
@APIRoute('GET', 'account/:handle') // Defines route
@RequireRoles(GhostRole.Owner) // Adds role check
async handleGetAccount() { }dispatchers.ts contains 1100+ lines of legacy factory functions. New handlers should follow the class-based pattern in /activity-handlers/ - see ADR-0006
- Step definitions should be grouped together by the high level feature they are
testing, i.e: Step definitions related to "reposting" should be grouped together
in
features/step_definitions/repost_steps.js- This is not necessarily a 1-to-1 mapping between feature files and step definition files
These patterns are based on our architecture decisions (see /adr directory):
// ❌ Avoid: Mutable entities with dirty flags
class Post {
private _likeCount: number;
private _likeCountDirty: boolean;
like() {
this._likeCount++;
this._likeCountDirty = true;
}
}
// ✅ Prefer: Immutable entities that generate events
class Post {
constructor(
readonly id: string,
readonly likeCount: number,
private events: DomainEvent[] = []
) {}
like(): Post {
const newPost = new Post(this.id, this.likeCount + 1);
newPost.events.push(new PostLikedEvent(this.id));
return newPost;
}
pullEvents(): DomainEvent[] {
return [...this.events];
}
}// ❌ Avoid: String literal errors without context
Result<Account, 'not-found' | 'network-error'>
// ✅ Prefer: Error objects with context
type AccountError =
| { type: 'not-found'; accountId: string }
| { type: 'network-error'; retryable: boolean }
async function getAccount(id: string): Promise<Result<Account, AccountError>> {
const account = await repository.findById(id);
if (!account) {
return error({ type: 'not-found', accountId: id });
}
return ok(account);
}
// Usage with exhaustive handling
const result = await getAccount('123');
if (isError(result)) {
const err = getError(result);
switch (err.type) {
case 'not-found':
log(`Account ${err.accountId} not found`);
break;
case 'network-error':
if (err.retryable) retry();
break;
default:
exhaustiveCheck(err);
}
}// ❌ Avoid: Function factories
export function createFollowHandler(accountService: AccountService) {
return async function handleFollow(ctx: Context, follow: Follow) {
// implementation
}
}
// ✅ Prefer: Classes with dependency injection
export class FollowHandler {
constructor(
private readonly accountService: AccountService,
private readonly notificationService: NotificationService
) {}
async handle(ctx: Context, follow: Follow) {
// implementation
}
}
// Registration with Awilix
container.register('followHandler', asClass(FollowHandler).singleton())// ❌ Avoid: Direct database queries in services
class AccountService {
async getFollowers(accountId: number) {
return await this.db('follows')
.join('accounts', 'accounts.id', 'follows.follower_id')
.where('follows.following_id', accountId);
}
}
// ✅ Prefer: Repository handles all data access
class AccountRepository {
async getFollowers(accountId: number) {
return await this.db('follows')
.join('accounts', 'accounts.id', 'follows.follower_id')
.where('follows.following_id', accountId);
}
}
class AccountService {
constructor(private readonly accountRepository: AccountRepository) {}
async getFollowers(accountId: number) {
return await this.accountRepository.getFollowers(accountId);
}
}// Views are used for complex read operations that need optimization
export class AccountView {
constructor(private readonly db: Knex) {}
async viewById(id: number, context: ViewContext): Promise<AccountDTO> {
// Direct database query with complex joins and aggregations
const accountData = await this.db('accounts')
.innerJoin('users', 'users.account_id', 'accounts.id')
.select(
'accounts.*',
this.db.raw('(select count(*) from posts where posts.author_id = accounts.id) as post_count'),
this.db.raw('(select count(*) from follows where follows.follower_id = accounts.id) as following_count')
)
.where('accounts.id', id)
.first();
// Add user-specific context
const followedByMe = context.requestUserAccount
? await this.db('follows')
.where('follower_id', context.requestUserAccount.id)
.where('following_id', id)
.first() !== undefined
: false;
// Return presentation-ready DTO
return {
id: accountData.id,
handle: accountData.handle,
postCount: accountData.post_count,
followingCount: accountData.following_count,
followedByMe
};
}
}- When adding / changing functionality, you should ensure that the code is:
- Covered by tests at the appropriate level (i.e not every test requires an e2e test)
- Free of linting errors
- Free of type errors
- Following existing code conventions (explicitly and implicitly)
- Following the architecture patterns outlined in the architecture patterns section
- Improving the overall quality of the codebase
- It is important that the application has a quick boot time, especially when running in a cloud environment like Google Cloud Run. Synchronous operations should be avoided during boot (where possible) and any operation that cannot be asynchronous should be reviewed for the impact it has on the boot time
Known things that are a little weird or not ideal: