diff --git a/worker/agents/core/codingAgent.ts b/worker/agents/core/codingAgent.ts index 8f3d7894..d4074e39 100644 --- a/worker/agents/core/codingAgent.ts +++ b/worker/agents/core/codingAgent.ts @@ -29,6 +29,8 @@ import { ProjectObjective } from "./objectives/base"; import { FileOutputType } from "../schemas"; import { SecretsClient, type UserSecretsStoreStub } from '../../services/secrets/SecretsClient'; import { StateMigration } from './stateMigration'; +import { PendingWsTicket, TicketConsumptionResult } from '../../types/auth-types'; +import { WsTicketManager } from '../../utils/wsTicketManager'; const DEFAULT_CONVERSATION_SESSION_ID = 'default'; @@ -43,6 +45,10 @@ export class CodeGeneratorAgent extends Agent implements AgentI private objective!: ProjectObjective; private secretsClient: SecretsClient | null = null; protected static readonly PROJECT_NAME_PREFIX_MAX_LENGTH = 20; + + /** Ticket manager for WebSocket authentication */ + private ticketManager = new WsTicketManager(); + // Services readonly fileManager: FileManager; readonly deploymentManager: DeploymentManager; @@ -778,4 +784,34 @@ export class CodeGeneratorAgent extends Agent implements AgentI clearGitHubToken(): void { this.objective.clearGitHubToken(); } + + // ========================================== + // WebSocket Ticket Management + // ========================================== + + /** + * Store a WebSocket ticket for later consumption + * Called by controller after ownership is verified via JWT + */ + storeWsTicket(ticket: PendingWsTicket): void { + this.ticketManager.store(ticket); + this.logger().info('WebSocket ticket stored', { + userId: ticket.user.id, + expiresIn: Math.floor((ticket.expiresAt - Date.now()) / 1000), + }); + } + + /** + * Consume a WebSocket ticket (one-time use) + * Returns user session if valid, null otherwise + */ + consumeWsTicket(token: string): TicketConsumptionResult | null { + const result = this.ticketManager.consume(token); + if (result) { + this.logger().info('Ticket consumed successfully', { userId: result.user.id }); + } else { + this.logger().warn('Ticket consumption failed', { tokenPrefix: token.slice(0, 10) }); + } + return result; + } } diff --git a/worker/api/controllers/agent/controller.ts b/worker/api/controllers/agent/controller.ts index c872143a..f69c6891 100644 --- a/worker/api/controllers/agent/controller.ts +++ b/worker/api/controllers/agent/controller.ts @@ -22,6 +22,7 @@ import { getPreviewDomain } from 'worker/utils/urls'; import { ImageType, uploadImage } from 'worker/utils/images'; import { ProcessedImageAttachment } from 'worker/types/image-attachment'; import { getTemplateImportantFiles } from 'worker/services/sandbox/utils'; +import { hasTicketParam } from '../../../middleware/auth/ticketAuth'; const defaultCodeGenArgs: Partial = { language: 'typescript', @@ -209,6 +210,10 @@ export class CodingAgentController extends BaseController { /** * Handle WebSocket connections for code generation * This routes the WebSocket connection directly to the Agent + * + * Supports two authentication methods: + * 1. Ticket-based auth (SDK): ?ticket=tk_xxx in URL + * 2. JWT-based auth (Browser): Cookie/Header with origin validation */ static async handleWebSocketConnection( request: Request, @@ -217,8 +222,8 @@ export class CodingAgentController extends BaseController { context: RouteContext ): Promise { try { - const chatId = context.pathParams.agentId; // URL param is still agentId for backward compatibility - if (!chatId) { + const agentId = context.pathParams.agentId; + if (!agentId) { return CodingAgentController.createErrorResponse('Missing agent ID parameter', 400); } @@ -226,43 +231,34 @@ export class CodingAgentController extends BaseController { if (request.headers.get('Upgrade') !== 'websocket') { return new Response('Expected WebSocket upgrade', { status: 426 }); } - - // Validate WebSocket origin - if (!validateWebSocketOrigin(request, env)) { - return new Response('Forbidden: Invalid origin', { status: 403 }); - } - // Extract user for rate limiting - const user = context.user!; + // User already authenticated via ticket OR JWT by middleware + const user = context.user; if (!user) { - return CodingAgentController.createErrorResponse('Missing user', 401); + return CodingAgentController.createErrorResponse('Authentication required', 401); } - this.logger.info(`WebSocket connection request for chat: ${chatId}`); - - // Log request details for debugging - const headers: Record = {}; - request.headers.forEach((value, key) => { - headers[key] = value; - }); - this.logger.info('WebSocket request details', { - headers, - url: request.url, - chatId + // Origin validation only for non-ticket auth (ticket auth is origin-agnostic) + const isTicketAuth = hasTicketParam(request); + if (!isTicketAuth && !validateWebSocketOrigin(request, env)) { + return new Response('Forbidden: Invalid origin', { status: 403 }); + } + + this.logger.info('WebSocket connection authorized', { + agentId, + userId: user.id, + authMethod: isTicketAuth ? 'ticket' : 'jwt', }); try { // Get the agent instance to handle the WebSocket connection - const agentInstance = await getAgentStub(env, chatId); - - this.logger.info(`Successfully got agent instance for chat: ${chatId}`); + const agentInstance = await getAgentStub(env, agentId); // Let the agent handle the WebSocket connection directly return agentInstance.fetch(request); } catch (error) { - this.logger.error(`Failed to get agent instance with ID ${chatId}:`, error); + this.logger.error(`Failed to get agent instance with ID ${agentId}:`, error); // Return an appropriate WebSocket error response - // We need to emulate a WebSocket response even for errors const { 0: client, 1: server } = new WebSocketPair(); server.accept(); diff --git a/worker/api/controllers/ticket/controller.ts b/worker/api/controllers/ticket/controller.ts new file mode 100644 index 00000000..1914f3c0 --- /dev/null +++ b/worker/api/controllers/ticket/controller.ts @@ -0,0 +1,127 @@ +/** + * Ticket Controller + * + * Creates one-time-use tickets for WebSocket authentication. + * Tickets are stored in the appropriate DO and consumed on connection. + */ + +import { BaseController } from '../baseController'; +import { RouteContext } from '../../types/route-context'; +import { createLogger } from '../../../logger'; +import { checkAppOwnership } from '../../../middleware/auth/routeAuth'; +import { generateTicketToken, getResourceStub } from '../../../middleware/auth/ticketAuth'; +import type { TicketResourceType } from '../../../middleware/auth/routeAuth'; +import type { PendingWsTicket, AuthUser } from '../../../types/auth-types'; + +const TICKET_TTL_MS = 15_000; + +interface CreateTicketRequest { + resourceType: TicketResourceType; + resourceId?: string; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +async function verifyOwnership( + user: AuthUser, + resourceType: TicketResourceType, + resourceId: string, + env: Env +): Promise { + switch (resourceType) { + case 'agent': + return checkAppOwnership(user, { agentId: resourceId }, env); + case 'vault': + return resourceId === user.id; + } +} + +function resolveResourceId( + body: CreateTicketRequest, + userId: string +): { resourceId: string } | { error: string } { + if (body.resourceType === 'vault') { + return { resourceId: userId }; + } + if (!body.resourceId) { + return { error: 'resourceId is required for agent tickets' }; + } + return { resourceId: body.resourceId }; +} + +// ============================================================================ +// Controller +// ============================================================================ + +export class TicketController extends BaseController { + static readonly logger = createLogger('TicketController'); + + /** + * Create a WebSocket ticket + * POST /api/ws-ticket + */ + static async createTicket( + request: Request, + env: Env, + _ctx: ExecutionContext, + context: RouteContext + ): Promise { + const user = context.user; + if (!user) { + return this.createErrorResponse('Authentication required', 401); + } + + // Parse and validate request + let body: CreateTicketRequest; + try { + body = await request.json() as CreateTicketRequest; + } catch { + return this.createErrorResponse('Invalid JSON body', 400); + } + + if (!body.resourceType || !['agent', 'vault'].includes(body.resourceType)) { + return this.createErrorResponse('Invalid resourceType', 400); + } + + // Resolve resource ID + const resolved = resolveResourceId(body, user.id); + if ('error' in resolved) { + return this.createErrorResponse(resolved.error, 400); + } + const { resourceId } = resolved; + + // Verify ownership + if (!await verifyOwnership(user, body.resourceType, resourceId, env)) { + this.logger.warn('Ticket creation denied', { userId: user.id, resourceType: body.resourceType, resourceId }); + return this.createErrorResponse('Access denied', 403); + } + + // Create and store ticket + const now = Date.now(); + const ticket: PendingWsTicket = { + token: generateTicketToken(body.resourceType, resourceId), + user, + sessionId: context.sessionId ?? `ticket:${body.resourceType}:${resourceId}`, + createdAt: now, + expiresAt: now + TICKET_TTL_MS, + }; + + try { + const stub = await getResourceStub(env, body.resourceType, resourceId); + await stub.storeWsTicket(ticket); + + this.logger.info('Ticket created', { resourceType: body.resourceType, resourceId, userId: user.id }); + + return this.createSuccessResponse({ + ticket: ticket.token, + expiresIn: Math.floor(TICKET_TTL_MS / 1000), + expiresAt: new Date(ticket.expiresAt).toISOString(), + }); + } catch (error) { + this.logger.error('Failed to create ticket', { resourceType: body.resourceType, resourceId, error }); + return this.createErrorResponse('Failed to create ticket', 500); + } + } +} diff --git a/worker/api/routes/codegenRoutes.ts b/worker/api/routes/codegenRoutes.ts index 9633e40f..6d9fa33a 100644 --- a/worker/api/routes/codegenRoutes.ts +++ b/worker/api/routes/codegenRoutes.ts @@ -19,9 +19,11 @@ export function setupCodegenRoutes(app: Hono): void { // APP EDITING ROUTES (/chat/:id frontend) // ======================================== - // WebSocket for app editing - OWNER ONLY (for /chat/:id route) - // Only the app owner should be able to connect and modify via WebSocket - app.get('/api/agent/:agentId/ws', setAuthLevel(AuthConfig.ownerOnly), adaptController(CodingAgentController, CodingAgentController.handleWebSocketConnection)); + // WebSocket for app editing - OWNER ONLY with ticket support + // Supports ticket-based auth (SDK) or JWT-based auth (browser) + app.get('/api/agent/:agentId/ws', setAuthLevel(AuthConfig.ownerOnly, { + ticketAuth: { resourceType: 'agent', paramName: 'agentId' } + }), adaptController(CodingAgentController, CodingAgentController.handleWebSocketConnection)); // Connect to existing agent for editing - OWNER ONLY // Only the app owner should be able to connect for editing purposes diff --git a/worker/api/routes/index.ts b/worker/api/routes/index.ts index edee7eff..696c7a74 100644 --- a/worker/api/routes/index.ts +++ b/worker/api/routes/index.ts @@ -11,6 +11,7 @@ import { setupCodegenRoutes } from './codegenRoutes'; import { setupScreenshotRoutes } from './imagesRoutes'; import { setupSentryRoutes } from './sentryRoutes'; import { setupCapabilitiesRoutes } from './capabilitiesRoutes'; +import { setupTicketRoutes } from './ticketRoutes'; import { Hono } from "hono"; import { AppEnv } from "../../types/appenv"; import { setupStatusRoutes } from './statusRoutes'; @@ -33,6 +34,9 @@ export function setupRoutes(app: Hono): void { // Authentication and user management routes setupAuthRoutes(app); + // WebSocket ticket routes + setupTicketRoutes(app); + // Codegen routes setupCodegenRoutes(app); diff --git a/worker/api/routes/ticketRoutes.ts b/worker/api/routes/ticketRoutes.ts new file mode 100644 index 00000000..a815cf3d --- /dev/null +++ b/worker/api/routes/ticketRoutes.ts @@ -0,0 +1,19 @@ +/** + * WebSocket Ticket Routes + */ + +import { Hono } from 'hono'; +import { AppEnv } from '../../types/appenv'; +import { AuthConfig, setAuthLevel } from '../../middleware/auth/routeAuth'; +import { adaptController } from '../honoAdapter'; +import { TicketController } from '../controllers/ticket/controller'; + +export function setupTicketRoutes(app: Hono): void { + // Create WebSocket ticket - requires authentication + // Ownership check is done in the controller based on resourceType + app.post( + '/api/ws-ticket', + setAuthLevel(AuthConfig.authenticated), + adaptController(TicketController, TicketController.createTicket) + ); +} diff --git a/worker/api/routes/userSecretsRoutes.ts b/worker/api/routes/userSecretsRoutes.ts index f94b4e98..e550df65 100644 --- a/worker/api/routes/userSecretsRoutes.ts +++ b/worker/api/routes/userSecretsRoutes.ts @@ -13,9 +13,12 @@ export function setupUserSecretsRoutes(app: Hono): void { const vaultRouter = new Hono(); // WebSocket connection to vault DO - must be before other routes + // Supports ticket-based auth (SDK) or JWT-based auth (browser) vaultRouter.get( '/ws', - setAuthLevel(AuthConfig.authenticated), + setAuthLevel(AuthConfig.authenticated, { + ticketAuth: { resourceType: 'vault' } + }), adaptController(UserSecretsController, UserSecretsController.handleWebSocketConnection) ); diff --git a/worker/middleware/auth/routeAuth.ts b/worker/middleware/auth/routeAuth.ts index 77d04a83..1cf2a099 100644 --- a/worker/middleware/auth/routeAuth.ts +++ b/worker/middleware/auth/routeAuth.ts @@ -14,6 +14,7 @@ import { AppEnv } from '../../types/appenv'; import { RateLimitExceededError } from 'shared/types/errors'; import * as Sentry from '@sentry/cloudflare'; import { getUserConfigurableSettings } from 'worker/config'; +import { authenticateViaTicket, hasTicketParam } from './ticketAuth'; const logger = createLogger('RouteAuth'); @@ -31,6 +32,26 @@ export interface AuthRequirement { resourceOwnershipCheck?: (user: AuthUser, params: Record, env: Env) => Promise; } +/** + * Ticket authentication configuration + */ +export type TicketResourceType = 'agent' | 'vault'; + +export interface TicketAuthConfig { + /** Type of resource this ticket authenticates */ + resourceType: TicketResourceType; + /** Path parameter name containing the resource ID (for agent) */ + paramName?: string; +} + +/** + * Additional options for setAuthLevel middleware + */ +export interface AuthLevelOptions { + /** Ticket-based authentication configuration */ + ticketAuth?: TicketAuthConfig; +} + /** * Common auth requirement configurations */ @@ -138,7 +159,9 @@ export async function routeAuthChecks( export async function enforceAuthRequirement(c: Context) : Promise { let user: AuthUser | null = c.get('user') || null; - const requirement = c.get('authLevel'); + const requirement = c.get('authLevel') as AuthRequirement | undefined; + const authOptions = c.get('authLevelOptions') as AuthLevelOptions | undefined; + if (!requirement) { logger.error('No authentication level found'); return errorResponse('No authentication level found', 500); @@ -146,26 +169,53 @@ export async function enforceAuthRequirement(c: Context) : Promise) : Promise { c.set('authLevel', requirement); + if (options) { + c.set('authLevelOptions', options); + } return await next(); }) } diff --git a/worker/middleware/auth/ticketAuth.ts b/worker/middleware/auth/ticketAuth.ts new file mode 100644 index 00000000..595f7e8c --- /dev/null +++ b/worker/middleware/auth/ticketAuth.ts @@ -0,0 +1,188 @@ +/** + * WebSocket Ticket Authentication + * + * Provides ticket-based authentication for WebSocket connections. + * Tickets are opaque, one-time-use tokens stored in DO memory. + * + * Token formats: + * - Agent: tk_{random} (resource ID from URL param) + * - Vault: tkv_{userId}_{random} (resource ID encoded in token) + */ + +import { getAgentStub } from '../../agents'; +import type { AuthUserSession, PendingWsTicket, TicketConsumptionResult } from '../../types/auth-types'; +import { createLogger } from '../../logger'; +import type { TicketAuthConfig, TicketResourceType } from './routeAuth'; + +const logger = createLogger('TicketAuth'); + +// ============================================================================ +// Constants +// ============================================================================ + +const MIN_TICKET_LENGTH = 35; +const AGENT_TICKET_PREFIX = 'tk_'; +const VAULT_TICKET_PREFIX = 'tkv_'; +const AGENT_TICKET_PATTERN = /^tk_[a-f0-9]+$/; + +// ============================================================================ +// Interfaces +// ============================================================================ + +/** + * Interface for DOs that support WebSocket ticket authentication + */ +export interface TicketAuthenticatable { + storeWsTicket(ticket: PendingWsTicket): Promise | void; + consumeWsTicket(token: string): Promise | TicketConsumptionResult | null; +} + +// ============================================================================ +// Resource Stub Resolution +// ============================================================================ + +/** + * Get a resource stub that supports ticket authentication + */ +export async function getResourceStub( + env: Env, + resourceType: TicketResourceType, + resourceId: string +): Promise { + switch (resourceType) { + case 'agent': + return getAgentStub(env, resourceId); + case 'vault': { + const id = env.UserSecretsStore.idFromName(resourceId); + return env.UserSecretsStore.get(id); + } + } +} + +// ============================================================================ +// Ticket Extraction & Validation +// ============================================================================ + +/** + * Check if request has a ticket parameter + */ +export function hasTicketParam(request: Request): boolean { + return new URL(request.url).searchParams.has('ticket'); +} + +/** + * Extract ticket from request, returning null if not present or invalid format + */ +function extractAndValidateTicket( + request: Request, + resourceType: TicketResourceType +): string | null { + const ticket = new URL(request.url).searchParams.get('ticket'); + if (!ticket || ticket.length < MIN_TICKET_LENGTH) { + return null; + } + + if (resourceType === 'vault') { + return ticket.startsWith(VAULT_TICKET_PREFIX) && parseVaultTicket(ticket) ? ticket : null; + } + + return ticket.startsWith(AGENT_TICKET_PREFIX) && AGENT_TICKET_PATTERN.test(ticket) ? ticket : null; +} + +/** + * Parse a vault ticket to extract the user ID + * Format: tkv_{userId}_{random} + */ +function parseVaultTicket(ticket: string): string | null { + const withoutPrefix = ticket.slice(VAULT_TICKET_PREFIX.length); + const separatorIndex = withoutPrefix.indexOf('_'); + if (separatorIndex <= 0) { + return null; + } + return withoutPrefix.slice(0, separatorIndex); +} + +/** + * Resolve the resource ID from ticket and route params + */ +function resolveResourceId( + ticket: string, + config: TicketAuthConfig, + params: Record +): string | null { + if (config.resourceType === 'vault') { + return parseVaultTicket(ticket); + } + + if (!config.paramName) { + logger.error('paramName required for agent ticket auth'); + return null; + } + + return params[config.paramName] || null; +} + +// ============================================================================ +// Authentication +// ============================================================================ + +/** + * Authenticate a request via WebSocket ticket + */ +export async function authenticateViaTicket( + request: Request, + env: Env, + config: TicketAuthConfig, + params: Record +): Promise { + const ticket = extractAndValidateTicket(request, config.resourceType); + if (!ticket) { + logger.warn('Invalid or missing ticket', { resourceType: config.resourceType }); + return null; + } + + const resourceId = resolveResourceId(ticket, config, params); + if (!resourceId) { + logger.warn('Could not resolve resource ID', { resourceType: config.resourceType }); + return null; + } + + try { + const stub = await getResourceStub(env, config.resourceType, resourceId); + const result = await stub.consumeWsTicket(ticket); + + if (!result) { + logger.warn('Ticket consumption failed', { resourceType: config.resourceType, resourceId }); + return null; + } + + logger.info('Ticket authenticated', { + resourceType: config.resourceType, + resourceId, + userId: result.user.id, + }); + + return { user: result.user, sessionId: result.sessionId }; + } catch (error) { + logger.error('Ticket authentication error', { resourceType: config.resourceType, resourceId, error }); + return null; + } +} + +// ============================================================================ +// Token Generation +// ============================================================================ + +/** + * Generate a ticket token for the specified resource + */ +export function generateTicketToken(resourceType: TicketResourceType, resourceId: string): string { + const random = crypto.randomUUID().replace(/-/g, ''); + + switch (resourceType) { + case 'vault': + return `${VAULT_TICKET_PREFIX}${resourceId}_${random}`; + case 'agent': + return `${AGENT_TICKET_PREFIX}${random}`; + } +} diff --git a/worker/services/secrets/UserSecretsStore.ts b/worker/services/secrets/UserSecretsStore.ts index bb16ab85..aadb6653 100644 --- a/worker/services/secrets/UserSecretsStore.ts +++ b/worker/services/secrets/UserSecretsStore.ts @@ -24,6 +24,8 @@ import { CLEANUP_INTERVAL_MS, STORAGE_LIMITS, } from './vault-types'; +import { PendingWsTicket, TicketConsumptionResult } from '../../types/auth-types'; +import { WsTicketManager } from '../../utils/wsTicketManager'; interface VaultSession { encryptedVMK: ArrayBuffer; @@ -35,6 +37,9 @@ interface VaultSession { export class UserSecretsStore extends DurableObject { private session: VaultSession | null = null; + + /** Ticket manager for WebSocket authentication */ + private ticketManager = new WsTicketManager(); constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); @@ -898,4 +903,21 @@ export class UserSecretsStore extends DurableObject { arr.fill(0); return result; } + + // ========== WEBSOCKET TICKET MANAGEMENT ========== + + /** + * Store a WebSocket ticket for later consumption + */ + storeWsTicket(ticket: PendingWsTicket): void { + this.ticketManager.store(ticket); + } + + /** + * Consume a WebSocket ticket (one-time use) + * Returns user session if valid, null otherwise + */ + consumeWsTicket(token: string): TicketConsumptionResult | null { + return this.ticketManager.consume(token); + } } diff --git a/worker/types/appenv.ts b/worker/types/appenv.ts index 5aaa7d65..ec1155df 100644 --- a/worker/types/appenv.ts +++ b/worker/types/appenv.ts @@ -1,5 +1,5 @@ import { GlobalConfigurableSettings } from "../config"; -import { AuthRequirement } from "../middleware/auth/routeAuth"; +import { AuthLevelOptions, AuthRequirement } from "../middleware/auth/routeAuth"; import { AuthUser } from "./auth-types"; @@ -10,5 +10,6 @@ export type AppEnv = { sessionId: string | null; config: GlobalConfigurableSettings; authLevel: AuthRequirement; + authLevelOptions?: AuthLevelOptions; } } diff --git a/worker/types/auth-types.ts b/worker/types/auth-types.ts index cc0387ec..7d6bfeb0 100644 --- a/worker/types/auth-types.ts +++ b/worker/types/auth-types.ts @@ -170,3 +170,23 @@ export interface SecurityContext { export type AuditLogEntry = AuditLog & { securityContext?: Partial; }; + +/** + * WebSocket ticket for secure, one-time-use authentication + * Stored in Agent DO memory, consumed on WebSocket connection + */ +export interface PendingWsTicket { + token: string; + user: AuthUser; + sessionId: string; + createdAt: number; + expiresAt: number; +} + +/** + * Result of ticket consumption from Agent DO + */ +export interface TicketConsumptionResult { + user: AuthUser; + sessionId: string; +} diff --git a/worker/utils/wsTicketManager.ts b/worker/utils/wsTicketManager.ts new file mode 100644 index 00000000..bf962985 --- /dev/null +++ b/worker/utils/wsTicketManager.ts @@ -0,0 +1,66 @@ +/** + * WebSocket Ticket Manager + */ + +import { PendingWsTicket, TicketConsumptionResult } from '../types/auth-types'; + +/** + * Manages in-memory storage of WebSocket tickets. + */ +export class WsTicketManager { + private tickets = new Map(); + + /** + * Store a ticket for later consumption + */ + store(ticket: PendingWsTicket): void { + this.tickets.set(ticket.token, ticket); + + // Schedule auto-cleanup after expiry + const ttl = ticket.expiresAt - Date.now(); + if (ttl > 0) { + setTimeout(() => { + this.tickets.delete(ticket.token); + }, ttl + 1000); // +1s buffer + } + } + + /** + * Consume a ticket (one-time use) + * Returns user session if valid, null otherwise + */ + consume(token: string): TicketConsumptionResult | null { + const ticket = this.tickets.get(token); + + if (!ticket) { + return null; + } + + // Delete immediately (one-time use) + this.tickets.delete(token); + + // Check expiry + if (Date.now() > ticket.expiresAt) { + return null; + } + + return { + user: ticket.user, + sessionId: ticket.sessionId, + }; + } + + /** + * Check if a ticket exists (without consuming it) + */ + has(token: string): boolean { + return this.tickets.has(token); + } + + /** + * Get the number of pending tickets + */ + get size(): number { + return this.tickets.size; + } +}