This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
CCIP Serverless is the serverless OPass backend built on Cloudflare Workers with Durable Objects. This is a rewritten version from v1-legacy that will replace the old design. The application uses Hono.js for HTTP handling, Drizzle ORM for database operations, and TSyringe for dependency injection.
pnpm dev- Start development server with Vitepnpm build- Build the project for productionpnpm deploy- Build and deploy to Cloudflare Workers
pnpm lint- Run ESLint on src directorypnpm lint:fix- Run ESLint with auto-fixpnpm format- Run Prettier to format all files
pnpm e2e- Run Cucumber.js BDD testspnpm e2e -- features/specific_file.feature- Run single feature filepnpm e2e --dry-run- Validate test structure without execution
pnpm gen:migration- Generate Drizzle ORM migrationspnpm cf-typegen- Generate Cloudflare Worker types (includes Env interface)
- Runtime: Cloudflare Workers with Durable Objects
- Framework: Hono.js with JSX runtime
- Database: SQLite via Drizzle ORM with Durable Objects storage
- API Documentation: Chanfana (OpenAPI integration)
- Dependency Injection: TSyringe with reflection support
- Testing: Cucumber.js for BDD testing
Entry Points:
src/index.tsx- Main application entry with Hono app setupsrc/renderer.tsx- JSX renderer configuration
Constants:
src/constant.ts- Application-wide constants (e.g., DEFAULT_DATABASE_NAME)
Database Layer:
src/infra/DatabaseConnector.ts- Database connection abstraction patternsrc/infra/EventDatabase.ts- Durable Object for database operationssrc/db/schema.ts- Drizzle database schema definitions
Entity Layer:
src/entity/- Pure domain objects with business logic- No framework dependencies or external imports
- Self-contained with all related behavior
Repository Pattern:
src/repository/DoAttendeeRepository.ts- Attendee data access layer- Uses DatabaseConnector for consistent data access
Use Cases Layer:
src/usecase/- Business logic and use case implementationssrc/usecase/interface.ts- Repository interfaces and dependency injection tokens- Use cases are plain classes without @injectable decorator (entities and use cases have no dependencies)
Presenter Layer:
src/presenter/- Output formatters that define API response schemas- Convert domain entities to API-specific data structures
- Implement presenter interfaces defined in use case layer
API Controllers:
src/handler/api/- API route controllers using Chanfana OpenAPI- Controllers extend OpenAPIRoute base class
Entity: Announcement - Manages event announcements with multi-language support
Features:
- Multi-language messages with
AnnouncementLocaleenum (EN, ZH_TW) - Publishing workflow with timestamps
- Role-based visibility
Repository: AnnouncementRepository - Provides role-based announcement queries
Use Cases:
AllAnnouncementQuery- Lists announcements for a specific roleCreateAnnouncementCommand- Creates new announcements
API Endpoints:
GET /announcement- List announcements (filters by attendee role)POST /announcement- Create new announcement
Current schema includes:
- attendees:
token(PK),display_name,first_used_at,role
@/*maps tosrc/*- use absolute imports for better maintainability
IMPORTANT: After making code changes, you MUST run these commands in order:
pnpm format- Format code with Prettierpnpm lint- Run ESLint to check code qualitypnpm tsc- Check TypeScript typespnpm e2e- Run BDD tests to verify functionality
This ensures code consistency and prevents breaking existing functionality.
Database connections are managed through dependency injection:
// ✅ Correct - Container manages database connection
export function configureContainer(env: Env) {
const dbConnection = DatabaseConnector.build(
env.EVENT_DATABASE,
"ccip-serverless",
);
container.register(DatabaseConnectionToken, { useValue: dbConnection });
container.register(AttendeeRepositoryToken, {
useClass: DoAttendeeRepository,
});
}
// ❌ Wrong - Manual connection creation in controllers
const conn = DatabaseConnector.build(env.EVENT_DATABASE, "ccip-serverless");
const repository = new DoAttendeeRepository(conn);Controllers extend OpenAPIRoute and use dependency injection:
export class ExampleController extends OpenAPIRoute {
schema = {
/* OpenAPI schema */
};
async handle(c: Context<{ Bindings: Env }>) {
// ✅ Correct - Use container.resolve()
const repository = container.resolve<AttendeeRepository>(
AttendeeRepositoryToken,
);
// Implementation using resolved dependencies
}
}Repositories are injectable and use interface dependencies:
@injectable()
export class DoAttendeeRepository implements AttendeeRepository {
constructor(
@inject(DatabaseConnectionToken)
private readonly connection: IDatabaseConnection,
) {}
async findByToken(token: string): Promise<Schema | null> {
const res = await this.connection.executeAll(
sql`SELECT * FROM table WHERE token = ${token}`,
);
return res.length > 0 ? res[0] : null;
}
}CCIP Serverless follows Clean Architecture with strict layer separation:
- Entities (
src/entity/): Pure domain objects with business rules - Use Cases (
src/usecase/): Application business logic - Interface Adapters (
src/presenter/,src/repository/): Convert data between layers - Frameworks & Drivers (
src/handler/,src/infra/): External concerns
- Pure domain objects: No framework dependencies or external imports
- Business rules only: Core domain logic and invariants
- Self-contained: All behavior related to the entity
// ✅ Correct - Pure domain entity
export class Announcement {
private messages: Map<AnnouncementLocale, string> = new Map();
private _publishedAt?: Date;
constructor(
public readonly id: string,
public readonly uri: string,
) {}
setMessage(locale: AnnouncementLocale, content: string): void {
this.messages.set(locale, content);
}
publish(time: Date): void {
this._publishedAt = time;
}
get publishedAt(): Date | undefined {
return this._publishedAt;
}
}- Application logic: Orchestrate entities and repositories
- Framework-free: No DI decorators, pure constructor injection
- Presenter pattern: Use presenters for output formatting instead of returning data directly
- No presenter imports: NEVER import presenter implementations directly in use cases - always accept via constructor
- Pure dependency injection: Use cases should only import from
./interfaceand entity files
// ✅ Correct - Use case with presenter pattern via constructor injection
export class AllAnnouncementQuery {
constructor(
private readonly presenter: AnnouncementListPresenter, // Interface from ./interface
private readonly repository: AnnouncementRepository,
) {}
async execute(token?: string): Promise<void> {
// Business logic here
// Call presenter.addAnnouncement() for each result
}
}
// ❌ Wrong - Use case importing presenter implementation
import { JsonAnnouncementListPresenter } from "@/presenter/JsonAnnouncementListPresenter";
export class AllAnnouncementQuery {
async execute(token?: string): Promise<AnnouncementData[]> {
const presenter = new JsonAnnouncementListPresenter(); // Never create presenters in use cases
return presenter.toJson(); // Don't return API-specific data
}
}- API schema definition: Presenters define output format, not use cases
- Data transformation: Convert domain entities to API responses
- Layer separation: Keep domain logic separate from presentation concerns
EvaluationResult encapsulates the evaluation state of all rules for an attendee:
// Contains evaluation results for multiple rules
export class EvaluationResult {
getVisibleRules(): RuleEvaluationResult[];
getRule(ruleId: string): RuleEvaluationResult | null;
hasUsableRules(): boolean;
isDisabled(): boolean; // Checks if all visible rules are disabled
}This is passed to presenters for formatting into API responses.
// ✅ Correct - Presenter defines API schema
export class JsonAnnouncementListPresenter
implements AnnouncementListPresenter
{
private announcements: Announcement[] = [];
addAnnouncement(announcement: Announcement): void {
this.announcements.push(announcement);
}
toJson(): AnnouncementData[] {
return this.announcements.map((announcement) => ({
datetime: announcement.publishedAt
? Math.floor(announcement.publishedAt.getTime() / 1000)
: 0,
msgEn: announcement.getMessage(AnnouncementLocale.EN) || "",
msgZh: announcement.getMessage(AnnouncementLocale.ZH_TW) || "",
uri: announcement.uri,
}));
}
}- Dependency resolution: Controllers handle all DI and object creation
- Use case orchestration: Create presenters and inject into use cases
- Response formatting: Get final output from presenters
// ✅ Correct - Controller orchestrates clean architecture
async handle(c: Context<{ Bindings: Env }>) {
const presenter = new JsonAnnouncementListPresenter();
const useCase = new AllAnnouncementQuery(presenter);
await useCase.execute(query.token);
return c.json(presenter.toJson());
}Use symbols for dependency injection tokens:
// ✅ Correct - Symbol-based tokens
export const AttendeeRepositoryToken = Symbol("AttendeeRepository");
export interface AttendeeRepository {
findAttendeeByToken(token: string): Promise<Attendee | null>;
}
// ❌ Wrong - String-based tokens
container.register("AttendeeRepository", { useValue: repository });Token and interface placement by layer:
- Domain interfaces and tokens:
src/usecase/interface.ts(e.g.,AttendeeRepository,AttendeeRepositoryToken) - Infrastructure interfaces and tokens:
src/infra/(e.g.,IDatabaseConnection,DatabaseConnectionToken)
Repository dependency injection:
// ✅ Correct - Injectable repository with interface dependency
@injectable()
export class DoAttendeeRepository implements AttendeeRepository {
constructor(
@inject(DatabaseConnectionToken)
private readonly connection: IDatabaseConnection,
) {}
}Container registration patterns:
// ✅ Correct - Use factory with direct env access from cloudflare:workers
import { env } from "cloudflare:workers";
container.register(DatabaseConnectionToken, {
useFactory: () => {
return DatabaseConnector.build(env.EVENT_DATABASE, DEFAULT_DATABASE_NAME);
},
});
// ✅ Correct - Register repositories as useClass for DI
container.register(AttendeeRepositoryToken, { useClass: DoAttendeeRepository });
// ✅ Correct - Register services as useClass for DI
container.register(DatetimeServiceToken, { useClass: NativeDatetimeService });
// ❌ Wrong - Manual instantiation bypasses DI
container.register(AttendeeRepositoryToken, {
useValue: new DoAttendeeRepository(connection),
});Controller dependency resolution:
// ✅ Correct - Use container.resolve() in controllers
const attendeeRepository = container.resolve<AttendeeRepository>(
AttendeeRepositoryToken,
);
// ❌ Wrong - Manual instantiation in controllers
const connection = DatabaseConnector.build(c.env.EVENT_DATABASE, "name");
const attendeeRepository = new DoAttendeeRepository(connection);IMPORTANT: The Env interface is auto-generated by pnpm cf-typegen, do NOT import from Hono:
// ❌ Wrong - Will cause type check failures
import { Context, Env } from "hono";
// ✅ Correct - Import Context only, Env is globally available
import { Context } from "hono";
// Use correct type for controllers
async handle(c: Context<{ Bindings: Env }>) {
// Access environment via c.env
const database = c.env.EVENT_DATABASE;
}
// Env interface is automatically available and includes:
interface Env {
EVENT_DATABASE: DurableObjectNamespace<EventDatabase>;
}Run pnpm cf-typegen after changing wrangler.jsonc to regenerate types.
IMPORTANT: Use cloudflare:workers import for accessing environment variables directly:
// ✅ Correct - Import env from cloudflare:workers for direct access
import { env } from "cloudflare:workers";
// Use in services that need environment access
export class NativeDatetimeService implements IDatetimeService {
getCurrentTime(): Date {
if (env.__TEST__ === "true" && env.__MOCK_DATETIME__) {
return new Date(env.__MOCK_DATETIME__);
}
return new Date();
}
}
// ❌ Wrong - Passing env through controller context in services
// Services should import env directly, not receive it via injectionWhen implementing new API endpoints, follow this systematic approach:
- Feature-Driven Development: Start with BDD feature files to define expected behavior
- Clean Architecture Flow: Implement layers in dependency order (Entity → Use Case → Presenter → Controller)
- Entity First: Add business logic methods to entities (e.g.,
checkIn()for usage tracking) - Repository Methods: Add persistence methods like
save()for entity state changes - Use Case Implementation: Orchestrate business logic without returning data directly
- Presenter Pattern: Define API response format in presenters, not use cases
- Controller Integration: Wire everything together following OpenAPI patterns
State Management in Entities:
// ✅ Correct - Private state with controlled access
export class Attendee {
private _firstUsedAt: number | null = null;
get firstUsedAt(): number | null {
return this._firstUsedAt;
}
checkIn(time: Date): void {
if (!this._firstUsedAt) {
this._firstUsedAt = Math.floor(time.getTime() / 1000);
}
}
}External Dependencies in Entities:
- Entities should NOT import external libraries (crypto, etc.)
- Complex computations should be handled in repository layer
- Public tokens, hashes, etc. calculated during entity construction
Saving Entity Changes:
// ✅ Correct - Update all entity attributes
async save(attendee: Attendee): Promise<void> {
await this.connection.executeAll(sql`
UPDATE attendees
SET display_name = ${attendee.displayName},
first_used_at = ${attendee.firstUsedAt},
role = ${attendee.role}
WHERE token = ${attendee.token}
`);
}Entity Mapping with External Dependencies:
// ✅ Correct - Calculate external dependencies in repository
private mapToEntity(row: AttendeeSchema): Attendee {
const publicToken = createHash("sha1").update(row.token).digest("hex");
const attendee = new Attendee(row.token, row.display_name, publicToken);
// Restore state from database
if (row.first_used_at) {
attendee.checkIn(new Date(row.first_used_at * 1000));
}
return attendee;
}Cloudflare Workers with Node.js APIs:
When using Node.js built-ins (like crypto), ensure compatibility configuration is synchronized:
- wrangler.jsonc: Add
"compatibility_flags": ["nodejs_compat"] - features/support/World.ts: Add
compatibilityFlags: ["nodejs_compat"]to Miniflare config
BDD Test Development:
- Start with passing scenarios to establish core functionality
- Mark complex scenarios as
@wipfor future implementation - Add missing step definitions progressively
- Ensure test environment matches production configuration
Step Definition Patterns:
// Property validation steps
Then('the response json should have property {string} is not null', ...)
Then('the response json should have property {string} is null', ...)
// HTTP request steps
When('I make a GET request to {string}', ...)
When('I make a POST request to {string}:', ...)Environment Variable Configuration:
For test-specific behavior (like datetime mocking), use environment variables:
// wrangler.jsonc - Production defaults
"vars": {
"__TEST__": false,
"__MOCK_DATETIME__": "2023-08-26T16:00:00.000Z"
}
// .dev.vars - Development overrides
__TEST__=true
__MOCK_DATETIME__=2023-08-26T16:00:00.000Z
// .dev.vars.example - Template for new developers
__TEST__=false
__MOCK_DATETIME__=2023-08-26T16:00:00.000ZTest Environment Setup (Miniflare):
Configure test-specific environment in features/support/World.ts:
// ✅ Correct - Use bindings for test environment variables
this._miniflare = new Miniflare({
bindings: {
__TEST__: "true",
__MOCK_DATETIME__: "2023-08-26T16:00:00.000Z",
},
// ... other config
});
// ❌ Wrong - vars/envVars don't work for Cloudflare environment variables
// Use bindings insteadService Layer Mocking:
Implement environment-aware services using cloudflare:workers env:
// ✅ Correct - Environment-aware service
import { env } from "cloudflare:workers";
@injectable()
export class NativeDatetimeService implements IDatetimeService {
getCurrentTime(): Date {
// Check if we're in test mode and return mock datetime
if (env.__TEST__ === "true" && env.__MOCK_DATETIME__) {
return new Date(env.__MOCK_DATETIME__);
}
return new Date();
}
}
// ❌ Wrong - Hardcoded or manual injection approaches
// Don't use globalThis or manual dependency injection for env varsType Generation for Environment Variables:
- Add variables to
wrangler.jsoncvars section - Create
.dev.varswith string values for proper type inference - Run
pnpm cf-typegento regenerate types - Environment variables are always typed as
stringin Cloudflare Workers
Testing Workflow with Mock Data:
- Define environment variables in
wrangler.jsoncwith production defaults - Override in
.dev.varsfor development/testing - Configure test bindings in
features/support/World.ts - Implement conditional logic in services using
envfromcloudflare:workers - Verify tests pass with predictable mock data
- Ensure TypeScript compilation succeeds with
pnpm tsc
When removing legacy fields (like event_id):
- Update feature files to remove deprecated columns
- Update expected JSON responses
- Avoid adding fields to database schema if not needed in current phase
- Use presenter layer for backward compatibility if required
- wrangler.jsonc: Cloudflare Workers config with Durable Objects binding and compatibility flags
- drizzle.config.ts: Database ORM configuration
- tsconfig.json: TypeScript config with JSX support and experimental decorators
- worker-configuration.d.ts: Auto-generated Cloudflare Worker types (do not edit)
BDD testing with Cucumber.js:
- Feature files in
features/directory - Step definitions in
features/steps/ - World setup in
features/support/World.ts - Test categories: landing, attendee import, puzzles, scenarios
- ESLint: TypeScript and React configurations
- Prettier: With organize-imports plugin
- Path aliases: Use
@/prefix for src imports
The ruleset system is one of the most complex features in CCIP Serverless. It implements a flexible rule engine for event attendee interactions using an AST-based approach.
Core Concept: The system replaces hardcoded event logic with configurable rules that control what attendees can do, when they can do it, and how many times actions can be performed.
Key Components:
- Ruleset: Aggregate root that manages a collection of rules
- Rule: Individual action/resource with visibility and usability logic
- Conditions: AST nodes implementing Strategy + Composite patterns
- Actions: Operations executed when rules are used
1. AST-Based Condition System:
- Uses polymorphic JSON structure with
typefield - Factory pattern for creating condition nodes from JSON
- Supports complex logic with And/Or composite conditions
- Extensible for future condition types
2. Flexible Message System:
- Rules use
messages: Map<string, I18nText>instead of fixed fields - Different messages for different states (display, locked, expired, etc.)
- All locales returned in API responses
3. State Storage in Attendee Metadata:
- Rule usage tracked via
_rule_{id}keys in attendee metadata - Denormalized for performance (no separate state table)
- Atomic updates with attendee data
4. Durable Object KV Storage:
- Each event has isolated ruleset storage
- Single KV entry for atomic updates:
durableObject.set("rulesets", data) - Role-based organization at top level
When implementing the ruleset system:
- Start with the Domain Model: Implement entities before infrastructure
- Use Factory Pattern: ConditionNodeFactory for parsing JSON to domain objects
- Leverage DI: RulesetRepository as interface with DO implementation
- Follow Clean Architecture: Use cases orchestrate, presenters format output
- Test with BDD: Missing step definition for ruleset setup needs implementation
Key Condition Types (All Implemented):
AlwaysTrue: No conditions (base case)Attribute: Check attendee metadata valuesUsedRule: Check if another rule was usedAnd: All child conditions must be true (composite)Or: At least one child condition must be true (composite)
Not Yet Implemented (will throw error in ConditionNodeFactory):
Role: Check attendee roleStaff: Check if staff query mode
Critical Patterns:
// Evaluation context includes staff query flag
const context = new EvaluationContext(attendee, currentTime, isStaffQuery);
// Rules evaluate visibility and usability separately
if (rule.isVisible(context) && rule.isUsable(context)) {
rule.apply(new ExecutionContext(attendee, currentTime));
}
// State stored in attendee metadata
attendee.setMetadata(`_rule_${ruleId}`, timestamp.toString());- Don't hardcode messages: Use flexible message system with IDs
- Don't forget staff mode: Include isStaffQuery in evaluation context
- Don't mix concerns: Keep AST parsing separate from domain logic
- Don't skip factory pattern: Always use factories for JSON→Domain conversion
- The
@wiptag on scenario features indicates work in progress - Legacy format migration tool needed for test compatibility
- Mock datetime crucial for predictable test results
- Step definition uses simplified format:
"the ruleset is:"without event/role parameters
For detailed design documentation, see docs/ruleset.md.
✅ All Core Components Implemented:
- Domain Entities:
Ruleset,Rule,EvaluationContext,TimeWindow,LocalizedText - Factory Pattern:
RuleFactoryfor JSON to domain object conversion with full AST support - Repository Pattern:
DoRulesetRepositorywith proper DI and factory injection - Value Objects:
RuleEvaluationResult,EvaluationResultwithisDisabled()method - Full Condition System:
AlwaysTrueCondition,AttributeCondition,UsedRuleCondition,AndCondition,OrCondition - Action System: Implemented via
UseRuleCommanduse case for rule execution - Metadata Filtering:
Attendee.visibleMetadata()filters internal keys starting with_
✅ Condition Node Implementation:
// ConditionNodeFactory is in src/entity/ConditionFactory.ts (separate from domain entities)
// Supports recursive parsing for complex AST structures
import { ConditionNodeFactory } from "@/entity/ConditionFactory";
export class ConditionNodeFactory {
static create(json: Record<string, unknown>): ConditionNode {
switch (json.type as string) {
case "AlwaysTrue":
return new AlwaysTrueCondition();
case "Attribute":
return new AttributeCondition(json.key, json.value);
case "UsedRule":
return new UsedRuleCondition(json.ruleId);
case "And":
return new AndCondition(
json.children.map((child) => ConditionNodeFactory.create(child)),
);
case "Or":
return new OrCondition(
json.children.map((child) => ConditionNodeFactory.create(child)),
);
}
}
}✅ Rule Execution Pattern:
// UseRuleCommand handles action execution without DI decorators
export class UseRuleCommand {
constructor(
private readonly attendeeRepository: AttendeeRepository,
private readonly rulesetRepository: RulesetRepository,
private readonly evaluationService: RuleEvaluationService,
private readonly datetimeService: IDatetimeService,
) {}
async execute(token: string, ruleId: string): Promise<AttendeeStatusData> {
// Load attendee, evaluate rule, mark as used, return updated status
const timestamp = Math.floor(currentTime.getTime() / 1000);
attendee.setMetadata(`_rule_${ruleId}`, timestamp.toString());
}
}Key Architectural Conventions:
- Factory Separation: Domain entities contain no parsing logic, factories handle JSON→Entity conversion
- Value Object Organization: Dedicated files for
TimeWindow.ts,Locale.tswith proper enums - Repository Design: Load all rules via
load(), role filtering happens via condition evaluation - Clean Architecture: Use cases are plain classes, controllers handle all DI resolution
- Metadata Visibility: Internal metadata (keys starting with
_) filtered via domain methods
BDD Test Coverage:
- 24 scenarios covering basic usage, attribute-based rules, composite conditions, progressive unlocking
- Step definitions use current format:
"the ruleset is:"without redundant parameters - Environment-aware mocking with
__TEST__and__MOCK_DATETIME__variables