import path from 'node:path' import {hrtime} from 'node:process' import {contact} from 'api/contact' import {createVote} from 'api/create-vote' import {deleteMessage} from 'api/delete-message' import {editMessage} from 'api/edit-message' import {getChannelMemberships} from 'api/get-channel-memberships' import {getLastSeenChannelTime, setChannelLastSeenTime} from 'api/get-channel-seen-time' import {getHiddenProfiles} from 'api/get-hidden-profiles' import {getLastMessages} from 'api/get-last-messages' import {getMessagesCountEndpoint} from 'api/get-messages-count' import {getOptionsEndpoint} from 'api/get-options' import {getPinnedCompatibilityQuestions} from 'api/get-pinned-compatibility-questions' import {getChannelMessagesEndpoint} from 'api/get-private-messages' import {getUser} from 'api/get-user' import {hideProfile} from 'api/hide-profile' import {reactToMessage} from 'api/react-to-message' import {saveSubscription} from 'api/save-subscription' import {saveSubscriptionMobile} from 'api/save-subscription-mobile' import {sendSearchNotifications} from 'api/send-search-notifications' import {localSendTestEmail} from 'api/test' import {unhideProfile} from 'api/unhide-profile' import {updateCompatibilityQuestionPin} from 'api/update-compatibility-question-pin' import {updateConnectionInterests} from 'api/update-connection-interests' import {updateOptions} from 'api/update-options' import {vote} from 'api/vote' import {API, type APIPath} from 'common/api/schema' import {APIError, APIErrors, pathWithPrefix} from 'common/api/utils' import {sendDiscordMessage} from 'common/discord/core' import {DEPLOYED_WEB_URL} from 'common/envs/constants' import {IS_LOCAL} from 'common/hosting/constants' import {filterDefined} from 'common/util/array' import cors from 'cors' import * as crypto from 'crypto' import express, {type ErrorRequestHandler, type RequestHandler} from 'express' import {OpenAPIV3} from 'openapi-types' import {withMonitoringContext} from 'shared/monitoring/context' import {log} from 'shared/monitoring/log' import {metrics} from 'shared/monitoring/metrics' import swaggerUi from 'swagger-ui-express' import {z, ZodFirstPartyTypeKind, ZodTypeAny} from 'zod' import {git} from './../metadata.json' import {version as pkgVersion} from './../package.json' import {banUser} from './ban-user' import {blockUser, unblockUser} from './block-user' import {cancelEvent} from './cancel-event' import {cancelRsvp} from './cancel-rsvp' import {getCompatibleProfilesHandler} from './compatible-profiles' import {createBookmarkedSearch} from './create-bookmarked-search' import {createComment} from './create-comment' import {createCompatibilityQuestion} from './create-compatibility-question' import {createEvent} from './create-event' import {createPrivateUserMessage} from './create-private-user-message' import {createPrivateUserMessageChannel} from './create-private-user-message-channel' import {createUserAndProfile} from './create-user-and-profile' import {deleteBookmarkedSearch} from './delete-bookmarked-search' import {deleteCompatibilityAnswer} from './delete-compatibility-answer' import {deleteMe} from './delete-me' import {getCompatibilityQuestions} from './get-compatibililty-questions' import {getConnectionInterestsEndpoint} from './get-connection-interests' import {getCurrentPrivateUser} from './get-current-private-user' import {getEvents} from './get-events' import {getLikesAndShips} from './get-likes-and-ships' import {getMe} from './get-me' import {getNotifications} from './get-notifications' import {getProfileAnswers} from './get-profile-answers' import {getProfiles} from './get-profiles' import {getSupabaseToken} from './get-supabase-token' import {getUserAndProfileHandler} from './get-user-and-profile' import {getUserDataExport} from './get-user-data-export' import {getUserJourneys} from './get-user-journeys' import {hasFreeLike} from './has-free-like' import {health} from './health' import {type APIHandler, typedEndpoint} from './helpers/endpoint' import {hideComment} from './hide-comment' import {leavePrivateUserMessageChannel} from './leave-private-user-message-channel' import {likeProfile} from './like-profile' import {llmExtractProfileEndpoint} from './llm-extract-profile' import {markAllNotifsRead} from './mark-all-notifications-read' import {removePinnedPhoto} from './remove-pinned-photo' import {report} from './report' import {rsvpEvent} from './rsvp-event' import {searchLocationEndpoint} from './search-location' import {searchNearCity} from './search-near-city' import {searchUsers} from './search-users' import {setCompatibilityAnswer} from './set-compatibility-answer' import {setLastOnlineTime} from './set-last-online-time' import {shipProfiles} from './ship-profiles' import {starProfile} from './star-profile' import {stats} from './stats' import {unsubscribe} from './unsubscribe' import {updateEvent} from './update-event' import {updateMe} from './update-me' import {updateNotifSettings} from './update-notif-setting' import {updatePrivateUserMessageChannel} from './update-private-user-message-channel' import {updateProfileEndpoint} from './update-profile' import {updateUserLocale} from './update-user-locale' import {validateUsernameEndpoint} from './validate-username' // const corsOptions: CorsOptions = { // origin: ['*'], // Only allow requests from this domain // methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], // allowedHeaders: ['Content-Type', 'Authorization'], // credentials: true, // if you use cookies or auth headers // }; const allowCorsUnrestricted: RequestHandler = cors({}) function cacheController(policy?: string): RequestHandler { return (_req, res, next) => { if (policy) res.appendHeader('Cache-Control', policy) next() } } const requestMonitoring: RequestHandler = (req, _res, next) => { const traceContext = req.get('X-Cloud-Trace-Context') const traceId = traceContext ? traceContext.split('/')[0] : crypto.randomUUID() const context = {endpoint: req.path, traceId} withMonitoringContext(context, () => { const startTs = hrtime.bigint() log(`${req.method} ${req.url}`) metrics.inc('http/request_count', {endpoint: req.path}) next() // There's a bug worth flagging in that middleware. The timing/cleanup code after next() won't work as you expect: // next() is synchronous — it just hands off to the next middleware. The response hasn't been sent by the time endTs is captured. To measure actual latency you'd want to hook into res.on('finish', ...) const endTs = hrtime.bigint() const latencyMs = Number(endTs - startTs) / 1e6 metrics.push('http/request_latency', latencyMs, {endpoint: req.path}) }) } const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => { if (error instanceof APIError) { log.info(error) if (!res.headersSent) { res.status(error.code).json(error.toJSON()) } } else { log.error(error) if (!res.headersSent) { const apiError = APIErrors.internalServerError(error.message || 'Internal server error', { originalError: error.toString(), context: 'Unhandled exception in request processing', }) res.status(500).json(apiError.toJSON()) } } } export const app = express() app.use(requestMonitoring) const schemaCache = new WeakMap() export function zodToOpenApiSchema(zodObj: ZodTypeAny): any { if (schemaCache.has(zodObj)) { return schemaCache.get(zodObj) } const def: any = (zodObj as any)._def const typeName = def.typeName as ZodFirstPartyTypeKind // Placeholder so recursive references can point here const placeholder: any = {} schemaCache.set(zodObj, placeholder) let schema: any switch (typeName) { case 'ZodString': schema = {type: 'string'} break case 'ZodNumber': schema = {type: 'number'} break case 'ZodBoolean': schema = {type: 'boolean'} break case 'ZodEnum': schema = {type: 'string', enum: def.values} break case 'ZodArray': schema = {type: 'array', items: zodToOpenApiSchema(def.type)} break case 'ZodObject': { const shape = def.shape() const properties: Record = {} const required: string[] = [] for (const key in shape) { const child = shape[key] properties[key] = zodToOpenApiSchema(child) if (!child.isOptional()) required.push(key) } schema = { type: 'object', properties, ...(required.length ? {required} : {}), } break } case 'ZodRecord': schema = { type: 'object', additionalProperties: zodToOpenApiSchema(def.valueType), } break case 'ZodIntersection': { const left = zodToOpenApiSchema(def.left) const right = zodToOpenApiSchema(def.right) schema = {allOf: [left, right]} break } case 'ZodLazy': schema = {type: 'object', description: 'Lazy schema - details omitted'} break case 'ZodUnion': schema = { oneOf: def.options.map((opt: ZodTypeAny) => zodToOpenApiSchema(opt)), } break default: schema = {type: 'string'} // fallback for unhandled } Object.assign(placeholder, schema) return schema } function inferTag(route: string) { let tag = 'General' if (route.includes('user') || route.includes('profile')) tag = 'Profiles' if (route.includes('auth') || route.includes('login') || route === 'me') tag = 'Authentication' if (route.includes('search') || route.includes('location')) tag = 'Search' if (route.includes('message') || route.includes('channel')) tag = 'Messaging' if (route.includes('compatibility') || route.includes('question')) tag = 'Compatibility' if ( route.includes('like') || route.includes('ship') || route.includes('star') || route.includes('block') ) tag = 'Relations' if (route.includes('event') || route.includes('rsvp')) tag = 'Events' if (route.includes('notification') || route.includes('notif')) tag = 'Notifications' if (route.includes('comment')) tag = 'Comments' if (route.includes('report') || route.includes('ban')) tag = 'Moderation' if (route.includes('option') || route.includes('locale')) tag = 'Utilities' return tag } function generateSwaggerPaths(api: typeof API) { const paths: Record = {} for (const [route, config] of Object.entries(api)) { const pathKey = '/' + route.replace(/_/g, '-') // optional: convert underscores to dashes const method = config.method.toLowerCase() const summary = (config as any).summary ?? route const tag = (config as any).tag ?? inferTag(route) const operation: any = { summary, description: (config as any).description ?? '', tags: [tag], responses: { 200: { description: 'Success', content: { 'application/json': { schema: {type: 'object'}, example: (config as any).exampleResponse ?? {}, }, }, }, 400: { description: 'Bad Request - Invalid input or malformed request', content: { 'application/json': { schema: { type: 'object', properties: { message: {type: 'string'}, details: {type: 'object'}, }, }, }, }, }, 401: { description: 'Unauthorized - Authentication required', content: { 'application/json': { schema: { type: 'object', properties: { message: {type: 'string'}, }, }, }, }, }, 403: { description: 'Forbidden - Insufficient permissions', content: { 'application/json': { schema: { type: 'object', properties: { message: {type: 'string'}, }, }, }, }, }, 404: { description: 'Not Found - Resource does not exist', content: { 'application/json': { schema: { type: 'object', properties: { message: {type: 'string'}, }, }, }, }, }, 500: { description: 'Internal Server Error - Something went wrong on our end', content: { 'application/json': { schema: { type: 'object', properties: { message: {type: 'string'}, }, }, }, }, }, }, } // Include props in request body for POST/PUT if (config.props && ['post', 'put', 'patch'].includes(method)) { operation.requestBody = { required: true, content: { 'application/json': { schema: zodToOpenApiSchema(config.props), example: (config as any).exampleRequest ?? {}, }, }, } } // Include props as query parameters for GET/DELETE if (config.props && ['get', 'delete'].includes(method)) { const shape = (config.props as z.ZodObject)._def.shape() operation.parameters = Object.entries(shape).map(([key, zodType]) => { const typeMap: Record = { ZodString: 'string', ZodNumber: 'number', ZodBoolean: 'boolean', ZodArray: 'array', } const t = zodType as z.ZodTypeAny // assert type to ZodTypeAny const typeName = t._def.typeName return { name: key, in: 'query', description: (config as any).paramDescriptions?.[key] ?? '', required: !(t.isOptional ?? false), schema: {type: typeMap[typeName] ?? 'string'}, } }) } paths[pathKey] = { [method]: operation, } if (config.authed) { operation.security = [{BearerAuth: []}] } } return paths } const apiKey = process.env.NEXT_PUBLIC_FIREBASE_API_KEY ?? 'API_KEY' const swaggerDocument: OpenAPIV3.Document = { openapi: '3.0.0', info: { title: 'Compass API', description: ` Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. Our API provides programmatic access to core platform features including user profiles, messaging, compatibility scoring, and community features. ## Access Tiers ### Tier 1 — Public Access (no authentication required) Some endpoints are publicly accessible without authentication, such as events and server health. These are marked as **public** in the endpoint documentation. ### Tier 2 — User Access (Firebase authentication required) Most endpoints require a valid Firebase JWT token. This gives you access to your own user data, profile, messages, and all interactive features. To obtain a token: **In your browser console while logged in (CTRL+SHIFT+C, then select the Console tab):** \`\`\`js const db = await new Promise((res, rej) => { const req = indexedDB.open('firebaseLocalStorageDb') req.onsuccess = () => res(req.result) req.onerror = rej }) const req = db.transaction('firebaseLocalStorage', 'readonly').objectStore('firebaseLocalStorage').getAll() req.onsuccess = () => { const id_token = req.result[0].value.stsTokenManager.accessToken console.log('YOUR_FIREBASE_JWT_TOKEN is the string below:') console.log(id_token) copy(id_token) } \`\`\` **For testing (REST):** \`\`\`bash curl 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${apiKey}' \\ -H 'Content-Type: application/json' \\ --data '{"email":"you@example.com","password":"yourpassword","returnSecureToken":true}' # Use the returned idToken as your Bearer token \`\`\` Tokens expire after **1 hour**. Refresh by calling \`getIdToken(true)\`. Pass the token in the Authorization header for all authenticated requests: \`\`\` Authorization: Bearer YOUR_FIREBASE_JWT_TOKEN \`\`\` In the API docs, authenticate through the green button at the bottom right of this section. **Don't have an account?** [Register on Compass](${DEPLOYED_WEB_URL}/register) to get started. ## Rate Limiting API requests are subject to rate limiting to ensure fair usage and platform stability. Exceeding limits will result in a \`429 Too Many Requests\` response. Rate limits are applied per authenticated user. Unauthenticated requests are limited by IP. ## Versioning This documentation reflects API version ${pkgVersion}. Endpoints marked as **deprecated** will include a \`Sunset\` header indicating when they will be removed, and a \`Link\` header pointing to the replacement endpoint. Breaking changes are avoided where possible. ## Error Handling All API responses follow a consistent error format: \`\`\`json { "message": "Human-readable error description", "details": { /* Optional additional context */ } } \`\`\` Common HTTP status codes: - \`200\` Success - \`400\` Bad Request — invalid or missing input - \`401\` Unauthorized — missing or expired token - \`403\` Forbidden — valid token but insufficient permissions - \`404\` Not Found — resource does not exist - \`429\` Too Many Requests — rate limit exceeded - \`500\` Internal Server Error ## Open Source Compass is open source. Contributions, bug reports, and feature requests are welcome on [GitHub](https://github.com/CompassConnections/Compass). ## Git Information Commit: ${git.revision} (${git.commitDate})`, version: pkgVersion, contact: { name: 'Compass Team', email: 'hello@compassmeet.com', url: 'https://compassmeet.com', }, }, paths: generateSwaggerPaths(API), components: { securitySchemes: { BearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'Firebase JWT token obtained through authentication', }, ApiKeyAuth: { type: 'apiKey', in: 'header', name: 'x-api-key', description: 'API key for internal/non-user endpoints', }, }, }, tags: filterDefined([ { name: 'General', description: 'General endpoints and health checks', }, { name: 'Authentication', description: 'User authentication and account management endpoints', }, { name: 'Users', description: 'User accounts', }, { name: 'Profiles', description: 'User profile creation, retrieval, updating, and deletion', }, { name: 'Search', description: 'User discovery and search functionality', }, { name: 'Messages', description: 'Direct messaging between users', }, { name: 'Compatibility', description: 'Compatibility questions, answers, and scoring', }, { name: 'Relations', description: 'User relationships (likes, ships, blocks, comments)', }, { name: 'Notifications', description: 'User notifications and preferences', }, { name: 'Events', description: 'Community events and RSVP management', }, { name: 'Votes', description: 'Voting system for user content and polls', }, { name: 'Moderation', description: 'Report system and user moderation', }, { name: 'Admin', description: 'Administrative functions including user bans and moderation', }, { name: 'Contact', description: 'Contact form and support requests', }, { name: 'Utilities', description: 'Helper functions and utilities', }, { name: 'Internal', description: 'Internal API endpoints for system operations', }, IS_LOCAL && { name: 'Local', description: 'Local development and testing endpoints', }, ]), } as OpenAPIV3.Document // Triggers Missing parameter name at index 3: *; visit https://git.new/pathToRegexpError for info // May not be necessary // app.options('*', allowCorsUnrestricted) const handlers: {[k in APIPath]: APIHandler} = { 'ban-user': banUser, 'compatible-profiles': getCompatibleProfilesHandler, contact: contact, 'create-bookmarked-search': createBookmarkedSearch, 'create-comment': createComment, 'create-compatibility-question': createCompatibilityQuestion, 'create-private-user-message': createPrivateUserMessage, 'create-private-user-message-channel': createPrivateUserMessageChannel, 'create-user-and-profile': createUserAndProfile, 'create-vote': createVote, 'delete-bookmarked-search': deleteBookmarkedSearch, 'delete-compatibility-answer': deleteCompatibilityAnswer, 'delete-message': deleteMessage, 'edit-message': editMessage, 'get-channel-memberships': getChannelMemberships, 'get-channel-messages': getChannelMessagesEndpoint, 'get-channel-seen-time': getLastSeenChannelTime, 'get-last-messages': getLastMessages, 'get-compatibility-questions': getCompatibilityQuestions, 'get-likes-and-ships': getLikesAndShips, 'get-messages-count': getMessagesCountEndpoint, 'get-notifications': getNotifications, 'get-options': getOptionsEndpoint, 'get-profile-answers': getProfileAnswers, 'get-profiles': getProfiles, 'get-supabase-token': getSupabaseToken, 'get-user-journeys': getUserJourneys, 'has-free-like': hasFreeLike, 'hide-comment': hideComment, 'hide-profile': hideProfile, 'unhide-profile': unhideProfile, 'get-hidden-profiles': getHiddenProfiles, 'leave-private-user-message-channel': leavePrivateUserMessageChannel, 'like-profile': likeProfile, 'mark-all-notifs-read': markAllNotifsRead, 'me/delete': deleteMe, 'me/data': getUserDataExport, 'me/private': getCurrentPrivateUser, 'me/update': updateMe, 'react-to-message': reactToMessage, 'remove-pinned-photo': removePinnedPhoto, 'save-subscription': saveSubscription, 'save-subscription-mobile': saveSubscriptionMobile, 'search-location': searchLocationEndpoint, 'search-near-city': searchNearCity, 'search-users': searchUsers, 'set-channel-seen-time': setChannelLastSeenTime, 'set-compatibility-answer': setCompatibilityAnswer, 'set-last-online-time': setLastOnlineTime, 'ship-profiles': shipProfiles, 'star-profile': starProfile, 'update-notif-settings': updateNotifSettings, 'update-options': updateOptions, 'update-user-locale': updateUserLocale, 'update-private-user-message-channel': updatePrivateUserMessageChannel, 'update-profile': updateProfileEndpoint, 'update-compatibility-question-pin': updateCompatibilityQuestionPin, 'get-pinned-compatibility-questions': getPinnedCompatibilityQuestions, 'get-connection-interests': getConnectionInterestsEndpoint, 'update-connection-interest': updateConnectionInterests, 'user/by-id/:id': getUser, 'user/by-id/:id/block': blockUser, 'user/by-id/:id/unblock': unblockUser, vote: vote, 'validate-username': validateUsernameEndpoint, 'llm-extract-profile': llmExtractProfileEndpoint, // 'user/:username': getUser, // 'user/:username/lite': getDisplayUser, // 'user/by-id/:id/lite': getDisplayUser, 'cancel-event': cancelEvent, 'cancel-rsvp': cancelRsvp, 'create-event': createEvent, 'get-events': getEvents, 'rsvp-event': rsvpEvent, 'update-event': updateEvent, health: health, stats: stats, 'unsubscribe/:token': unsubscribe, me: getMe, 'get-user-and-profile': getUserAndProfileHandler, report: report, } Object.entries(handlers).forEach(([path, handler]) => { const api = API[path as APIPath] const cache = cacheController((api as any).cache) const url = pathWithPrefix(('/' + path) as APIPath) const apiRoute = [ url, express.json({limit: '1mb'}), allowCorsUnrestricted, cache, typedEndpoint(path as any, handler as any), apiErrorHandler, ] as const if (api.method === 'POST') { app.post(...apiRoute) } else if (api.method === 'GET') { app.get(...apiRoute) // } else if (api.method === 'PUT') { // app.put(...apiRoute) } else { throw new Error('Unsupported API method') } }) // Internal Endpoints app.post(pathWithPrefix('/internal/send-search-notifications'), async (req, res) => { const apiKey = req.header('x-api-key') if (apiKey !== process.env.COMPASS_API_KEY) { return res.status(401).json({error: 'Unauthorized'}) } try { const result = await sendSearchNotifications() return res.status(200).json(result) } catch (err) { console.error('Failed to send notifications:', err) await sendDiscordMessage( 'Failed to send [daily notifications](https://console.cloud.google.com/cloudscheduler?project=compass-130ba) for bookmarked searches...', 'health', ) return res.status(500).json({error: 'Internal server error'}) } }) const responses = { 200: { description: 'Request successful', content: { 'application/json': { schema: { type: 'object', properties: { status: {type: 'string', example: 'success'}, }, }, }, }, }, 401: { description: 'Unauthorized (e.g., invalid or missing API key)', content: { 'application/json': { schema: { type: 'object', properties: { error: {type: 'string', example: 'Unauthorized'}, }, }, }, }, }, 500: { description: 'Internal server error during request processing', content: { 'application/json': { schema: { type: 'object', properties: { error: {type: 'string', example: 'Internal server error'}, }, }, }, }, }, } swaggerDocument.paths['/internal/send-search-notifications'] = { post: { summary: 'Trigger daily search notifications', description: 'Internal endpoint used by Compass schedulers to send daily notifications for bookmarked searches. Requires a valid `x-api-key` header.', tags: ['Internal'], security: [ { ApiKeyAuth: [], }, ], requestBody: { required: false, }, responses: responses, }, } as any // Local Endpoints if (IS_LOCAL) { app.post(pathWithPrefix('/local/send-test-email'), async (req, res) => { if (!IS_LOCAL) { return res.status(401).json({error: 'Unauthorized'}) } try { const result = await localSendTestEmail() return res.status(200).json(result) } catch (err) { return res.status(500).json({error: err}) } }) swaggerDocument.paths['/local/send-test-email'] = { post: { summary: 'Send a test email', description: 'Local endpoint to send a test email.', tags: ['Local'], requestBody: { required: false, }, responses: responses, }, } as any } const rootPath = pathWithPrefix('/') app.get( rootPath, swaggerUi.setup(swaggerDocument, { customSiteTitle: 'Compass API Docs', customCssUrl: '/swagger.css', customJs: ` const meta = document.createElement('meta'); meta.name = 'viewport'; meta.content = 'width=device-width, initial-scale=1'; document.head.appendChild(meta); `, }), ) app.use(rootPath, swaggerUi.serve) app.use(express.static(path.join(__dirname, 'public'))) app.use(allowCorsUnrestricted, (req, res) => { if (req.method === 'OPTIONS') { res.status(200).send() } else { res .status(404) .set('Content-Type', 'application/json') .json({ message: `This is the Compass API, but the requested route '${req.path}' does not exist. Please check your URL for any misspellings, the docs at https://api.compassmeet.com, or simply refer to app.ts on GitHub`, }) } })