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 {createProfile} from './create-profile' import {createUser} from './create-user' 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 {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 {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() 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-profile': createProfile, 'create-user': createUser, '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, '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, 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(), 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`, }) } })