diff --git a/common_knowledge/Discobot.md b/common_knowledge/Discobot.md index 5f2658e745a..a7608678d2c 100644 --- a/common_knowledge/Discobot.md +++ b/common_knowledge/Discobot.md @@ -78,7 +78,7 @@ All redirect URLs that the bot should support need to be inserted/ - `DISCORD_CLIENT_ID`: this is the client ID of the Discord app. - For local test we use the staging Discord app/bot. The client ID can therefore be found on the [developer dashboard](https://discord.com/developers/applications/1027997517964644453/oauth2/general) or by contacting Jake or Timothee. -- `DISCORD_BOT_TOKEN`: this is the same as the `DISCORD_TOKEN` in `/discord-bot/.env` +- `DISCORD_TOKEN`: this is the same as the `DISCORD_TOKEN` in `/discord-bot/.env` - `CW_BOT_KEY`: this is the same as the `CW_BOT_KEY` in `/discord-bot/.env` ### Startup @@ -119,7 +119,7 @@ the `CLOUDAMQP_URL` environment variable in the [`commonwealthapp` Heroku app](h - The client ID can be found on the developer dashboard for the [staging bot](https://discord.com/developers/applications/1027997517964644453/oauth2/general) or the [production bot](https://discord.com/developers/applications/1133050809412763719/oauth2/general). The client ID can also be retrieved by contacting Jake or Timothee. -- `DISCORD_BOT_TOKEN`: this is the same as the `DISCORD_TOKEN` in the associated `Discobot app` above. +- `DISCORD_TOKEN`: this is the same as the `DISCORD_TOKEN` in the associated `Discobot app` above. - `CW_BOT_KEY`: this is the same as the `CW_BOT_KEY` in the associated `Discobot app` above. ## Testing diff --git a/common_knowledge/Environment-Variables.md b/common_knowledge/Environment-Variables.md index d4c75190059..a17139b3e68 100644 --- a/common_knowledge/Environment-Variables.md +++ b/common_knowledge/Environment-Variables.md @@ -32,7 +32,7 @@ If you add a new environment variable, you must add documentation here. Please d - [DD_LOG_LEVEL](#dd_log_level) - [DD_SITE](#dd_site) - [DISABLE_CACHE](#disable_cache) -- [DISCORD_BOT_TOKEN](#discord_bot_token) +- [DISCORD_TOKEN](#discord_token) - [DISCORD_BOT_URL](#discord_bot_url) - [DISCORD_CLIENT_ID](#discord_client_id) - [DISCORD_WEBHOOK_URL_DEV](#discord_webhook_url_dev) @@ -183,7 +183,7 @@ DataDog configuration token in our Heroku pipeline, specifying our DataDog site If `true`, disables Redis caching middleware. -## DISCORD_BOT_TOKEN +## DISCORD_TOKEN This value should mirror the value of `DISCORD_TOKEN` in the Discobot .env file. @@ -227,7 +227,6 @@ Optional. Defaults to 5 minutes (300 seconds). This is number, in seconds. It configures the length of time we will use a community-maintained public endpoint if a given ChainNode fails. After this time, the server will try the original DB endpoint again. - ## FLAG_NEW_CREATE_COMMUNITY Boolean toggle allowing the creation of new communities during local development. diff --git a/libs/adapters/src/trpc/handlers.ts b/libs/adapters/src/trpc/handlers.ts index 79cd3f10444..7c9728d4f65 100644 --- a/libs/adapters/src/trpc/handlers.ts +++ b/libs/adapters/src/trpc/handlers.ts @@ -9,22 +9,10 @@ import { type EventSchemas, type EventsHandlerMetadata, type QueryMetadata, - type User, } from '@hicommonwealth/core'; -import { TRPCError, initTRPC } from '@trpc/server'; -import { Request } from 'express'; -import { type OpenApiMeta } from 'trpc-swagger'; +import { TRPCError } from '@trpc/server'; import { ZodSchema, ZodUndefined, z } from 'zod'; -import { OutputMiddleware, authenticate } from './middleware'; - -export interface Context { - req: Request; -} - -const trpc = initTRPC.meta().context().create(); - -const isSecure = (md: { secure?: boolean; auth: unknown[] }) => - md.secure !== false || md.auth.length > 0; +import { Tag, Track, buildproc, procedure } from './middleware'; const trpcerror = (error: unknown): TRPCError => { if (error instanceof Error) { @@ -50,63 +38,64 @@ const trpcerror = (error: unknown): TRPCError => { }); }; -export enum Tag { - User = 'User', - Community = 'Community', - Thread = 'Thread', - Comment = 'Comment', - Reaction = 'Reaction', - Integration = 'Integration', - Subscription = 'Subscription', - LoadTest = 'LoadTest', - Wallet = 'Wallet', - Webhook = 'Webhook', -} - +/** + * Builds tRPC command POST endpoint + * @param factory command factory + * @param tag command tag used for OpenAPI spec grouping + * @param track analytics tracking metadata as tuple of [event, output mapper] + * @returns tRPC mutation procedure + */ export const command = ( factory: () => CommandMetadata, tag: Tag, - outputMiddleware?: OutputMiddleware>, + track?: Track, ) => { const md = factory(); - return trpc.procedure - .meta({ - openapi: { - method: 'POST', - path: `/${factory.name}`, - tags: [tag], - headers: [ + return buildproc('POST', factory.name, md, tag, track).mutation( + async ({ ctx, input }) => { + try { + return await coreCommand( + md, { - in: 'header', - name: 'address', - required: true, - schema: { type: 'string' }, + actor: ctx.actor, + payload: input!, }, - ], - protect: isSecure(md), - }, - }) - .input(md.input) - .output(md.output) - .mutation(async ({ ctx, input }) => { - // md.secure must explicitly be false if the route requires no authentication - // if we provide any authorization method we force authentication as well - if (isSecure(md)) await authenticate(ctx.req, md.authStrategy); + false, + ); + } catch (error) { + throw trpcerror(error); + } + }, + ); +}; + +/** + * Builds tRPC query GET endpoint + * @param factory query factory + * @param tag query tag used for OpenAPI spec grouping + * @returns tRPC query procedure + */ +export const query = ( + factory: () => QueryMetadata, + tag: Tag, +) => { + const md = factory(); + return buildproc('GET', factory.name, md, tag).query( + async ({ ctx, input }) => { try { - const _ctx = { - actor: { - user: ctx.req.user as User, - address: ctx.req.headers['address'] as string, + return await coreQuery( + md, + { + actor: ctx.actor, + payload: input!, }, - payload: input!, - }; - const result = await coreCommand(md, _ctx, false); - outputMiddleware && (await outputMiddleware(_ctx, result!)); - return result; + false, + ); } catch (error) { throw trpcerror(error); } - }); + }, + ); }; // TODO: add security options (API key, IP range, internal, etc) @@ -118,7 +107,7 @@ export const event = < tag: Tag.Integration, ) => { const md = factory(); - return trpc.procedure + return procedure .meta({ openapi: { method: 'POST', @@ -137,50 +126,3 @@ export const event = < } }); }; - -export const query = ( - factory: () => QueryMetadata, - tag: Tag, -) => { - const md = factory(); - return trpc.procedure - .meta({ - openapi: { - method: 'GET', - path: `/${factory.name}`, - tags: [tag], - headers: [ - { - in: 'header', - name: 'address', - required: false, - schema: { type: 'string' }, - }, - ], - }, - protect: isSecure(md), - }) - .input(md.input) - .output(md.output) - .query(async ({ ctx, input }) => { - // enable secure by default - if (isSecure(md)) await authenticate(ctx.req, md.authStrategy); - try { - return await coreQuery( - md, - { - actor: { - user: ctx.req.user as User, - address: ctx.req.headers['address'] as string, - }, - payload: input!, - }, - false, - ); - } catch (error) { - throw trpcerror(error); - } - }); -}; - -export const router = trpc.router; diff --git a/libs/adapters/src/trpc/middleware.ts b/libs/adapters/src/trpc/middleware.ts index 304c68afca8..330b186426a 100644 --- a/libs/adapters/src/trpc/middleware.ts +++ b/libs/adapters/src/trpc/middleware.ts @@ -1,42 +1,130 @@ import { analytics, logger, + stats, + type Actor, type AuthStrategies, - type CommandContext, type User, } from '@hicommonwealth/core'; -import { TRPCError } from '@trpc/server'; -import { Request } from 'express'; +import { TRPCError, initTRPC } from '@trpc/server'; +import { Request, Response } from 'express'; import passport from 'passport'; -import { ZodSchema } from 'zod'; +import { OpenApiMeta } from 'trpc-swagger'; +import { ZodSchema, z } from 'zod'; import { config } from '../config'; const log = logger(import.meta); -export type OutputMiddleware = ( - ctx: CommandContext, - result: Partial, -) => Promise; +type Metadata = { + readonly input: Input; + readonly output: Output; + auth: unknown[]; + secure?: boolean; + authStrategy?: AuthStrategies; +}; -export function track( - event: string, - mapper?: (result: Partial) => Record, -): OutputMiddleware { - return ({ actor, payload }, result) => { - try { - analytics().track(event, { - userId: actor.user.id, - aggregateId: payload.id, - ...(mapper ? mapper(result) : {}), - }); - } catch (err) { - err instanceof Error && log.error(err.message, err); - } - return Promise.resolve(); - }; +const isSecure = (md: Metadata) => + md.secure !== false || (md.auth ?? []).length > 0; + +export interface Context { + req: Request; + res: Response; + actor: Actor; +} + +const trpc = initTRPC.meta().context().create(); +export const router = trpc.router; +export const procedure = trpc.procedure; + +export enum Tag { + User = 'User', + Community = 'Community', + Thread = 'Thread', + Comment = 'Comment', + Reaction = 'Reaction', + Integration = 'Integration', + Subscription = 'Subscription', + LoadTest = 'LoadTest', + Wallet = 'Wallet', + Webhook = 'Webhook', } -export const authenticate = async ( +export type Track = [ + string, + mapper?: (result: z.infer) => Record, +]; + +/** + * tRPC procedure factory with authentication, traffic stats, and analytics middleware + */ +export const buildproc = ( + method: 'GET' | 'POST', + name: string, + md: Metadata, + tag: Tag, + track?: Track, +) => { + const secure = isSecure(md); + return trpc.procedure + .use(async ({ ctx, next }) => { + if (secure) await authenticate(ctx.req, md.authStrategy); + return next({ + ctx: { + ...ctx, + actor: { + user: ctx.req.user as User, + address: ctx.req.headers['address'] as string, + }, + }, + }); + }) + .use(async ({ ctx, next }) => { + const start = Date.now(); + const result = await next(); + const latency = Date.now() - start; + try { + const path = `${ctx.req.method.toUpperCase()} ${ctx.req.path}`; + stats().increment('cw.path.called', { path }); + stats().histogram(`cw.path.latency`, latency, { + path, + statusCode: ctx.res.statusCode.toString(), + }); + } catch (err) { + err instanceof Error && log.error(err.message, err); + } + if (track && result.ok) { + try { + analytics().track(track[0], { + userId: ctx.actor.user.id, + ...(track[1] ? track[1](result.data) : {}), + }); + } catch (err) { + err instanceof Error && log.error(err.message, err); + } + } + return result; + }) + .meta({ + openapi: { + method, + path: `/${name}`, + tags: [tag], + headers: [ + { + in: 'header', + name: 'address', + required: false, + schema: { type: 'string' }, + }, + ], + protect: secure, + }, + }) + .input(md.input) + .output(md.output); +}; + +const authenticate = async ( req: Request, authStrategy: AuthStrategies = { name: 'jwt' }, ) => { diff --git a/libs/adapters/src/trpc/utils.ts b/libs/adapters/src/trpc/utils.ts index 17ccfbcde9d..d45b591e984 100644 --- a/libs/adapters/src/trpc/utils.ts +++ b/libs/adapters/src/trpc/utils.ts @@ -1,7 +1,7 @@ import { logger } from '@hicommonwealth/core'; import { TRPCError } from '@trpc/server'; import { createExpressMiddleware } from '@trpc/server/adapters/express'; -import { Request, Router } from 'express'; +import { Request, Response, Router } from 'express'; import { OpenAPIV3 } from 'openapi-types'; import swaggerUi from 'swagger-ui-express'; import { @@ -26,7 +26,10 @@ const logError = (path: string | undefined, error: TRPCError) => { export const toExpress = (router: OpenApiRouter) => createExpressMiddleware({ router, - createContext: ({ req }: { req: Request }) => ({ req }), + createContext: ({ req, res }: { req: Request; res: Response }) => ({ + req, + res, + }), onError: ({ path, error }) => logError(path, error), }); @@ -34,7 +37,10 @@ export const toExpress = (router: OpenApiRouter) => const toOpenApiExpress = (router: OpenApiRouter) => createOpenApiExpressMiddleware({ router, - createContext: ({ req }: { req: Request }) => ({ req }), + createContext: ({ req, res }: { req: Request; res: Response }) => ({ + req, + res, + }), onError: ({ path, error }: { path: string; error: TRPCError }) => logError(path, error), responseMeta: undefined, diff --git a/libs/core/src/ports/port.ts b/libs/core/src/ports/port.ts index 8387b2a2a54..de572f410e6 100644 --- a/libs/core/src/ports/port.ts +++ b/libs/core/src/ports/port.ts @@ -129,16 +129,34 @@ export const stats = port(function stats(stats?: Stats) { stats || { name: 'in-memory-stats', dispose: () => Promise.resolve(), - histogram: () => {}, + histogram: (key, value, tags) => { + log.trace('stats.histogram', { key, value, tags }); + }, set: () => {}, - increment: () => {}, - incrementBy: () => {}, - decrement: () => {}, - decrementBy: () => {}, - on: () => {}, - off: () => {}, - gauge: () => {}, - timing: () => {}, + increment: (key, tags) => { + log.trace('stats.increment', { key, tags }); + }, + incrementBy: (key, value, tags) => { + log.trace('stats.incrementBy', { key, value, tags }); + }, + decrement: (key, tags) => { + log.trace('stats.decrement', { key, tags }); + }, + decrementBy: (key, value, tags) => { + log.trace('stats.decrementBy', { key, value, tags }); + }, + on: (key) => { + log.trace('stats.on', { key }); + }, + off: (key) => { + log.trace('stats.off', { key }); + }, + gauge: (key, value) => { + log.trace('stats.gauge', { key, value }); + }, + timing: (key, duration, time) => { + log.trace('stats.timing', { key, duration, time }); + }, } ); }); @@ -177,7 +195,9 @@ export const analytics = port(function analytics(analytics?: Analytics) { analytics || { name: 'in-memory-analytics', dispose: () => Promise.resolve(), - track: () => {}, + track: (event, payload) => { + log.trace('analytics.track', { event, payload }); + }, } ); }); diff --git a/packages/commonwealth/server.ts b/packages/commonwealth/server.ts index 2025ce4a6dd..7a2fcc08522 100644 --- a/packages/commonwealth/server.ts +++ b/packages/commonwealth/server.ts @@ -22,17 +22,16 @@ import { DatabaseCleaner } from './server/util/databaseCleaner'; // handle exceptions thrown in express routes import 'express-async-errors'; -// bootstrap production adapters - -const log = logger(import.meta); +// bootstrap adapters stats(HotShotsStats()); -analytics(MixpanelAnalytics()); blobStorage(S3BlobStorage()); +(config.ANALYTICS.MIXPANEL_DEV_TOKEN || config.ANALYTICS.MIXPANEL_PROD_TOKEN) && + analytics(MixpanelAnalytics()); +config.NOTIFICATIONS.FLAG_KNOCK_INTEGRATION_ENABLED && + notificationsProvider(KnockProvider()); config.CACHE.REDIS_URL && cache(new RedisCache(config.CACHE.REDIS_URL)); -if (config.NOTIFICATIONS.FLAG_KNOCK_INTEGRATION_ENABLED) - notificationsProvider(KnockProvider()); -else notificationsProvider(); +const log = logger(import.meta); let isServiceHealthy = false; startHealthCheckLoop({ diff --git a/packages/commonwealth/server/api/community.ts b/packages/commonwealth/server/api/community.ts index 33ae3b5ad56..bd078686c82 100644 --- a/packages/commonwealth/server/api/community.ts +++ b/packages/commonwealth/server/api/community.ts @@ -6,17 +6,13 @@ import { } from '../../shared/analytics/types'; export const trpcRouter = trpc.router({ - createCommunity: trpc.command( - Community.CreateCommunity, - trpc.Tag.Community, - trpc.track( - MixpanelCommunityCreationEvent.NEW_COMMUNITY_CREATION, - (result) => ({ - chainBase: result.community?.base, - community: result.community?.id, - }), - ), - ), + createCommunity: trpc.command(Community.CreateCommunity, trpc.Tag.Community, [ + MixpanelCommunityCreationEvent.NEW_COMMUNITY_CREATION, + (result) => ({ + chainBase: result.community?.base, + community: result.community?.id, + }), + ]), getCommunities: trpc.query(Community.GetCommunities, trpc.Tag.Community), getCommunity: trpc.query(Community.GetCommunity, trpc.Tag.Community), getStake: trpc.query(Community.GetCommunityStake, trpc.Tag.Community), @@ -29,11 +25,9 @@ export const trpcRouter = trpc.router({ trpc.Tag.Community, ), setStake: trpc.command(Community.SetCommunityStake, trpc.Tag.Community), - createGroup: trpc.command( - Community.CreateGroup, - trpc.Tag.Community, - trpc.track(MixpanelCommunityInteractionEvent.CREATE_GROUP), - ), + createGroup: trpc.command(Community.CreateGroup, trpc.Tag.Community, [ + MixpanelCommunityInteractionEvent.CREATE_GROUP, + ]), getMembers: trpc.query(Community.GetMembers, trpc.Tag.Community), createStakeTransaction: trpc.command( Community.CreateStakeTransaction, diff --git a/packages/commonwealth/server/api/internal-router.ts b/packages/commonwealth/server/api/internal-router.ts index b491577d917..e927704b7b2 100644 --- a/packages/commonwealth/server/api/internal-router.ts +++ b/packages/commonwealth/server/api/internal-router.ts @@ -1,4 +1,4 @@ -import { express, trpc } from '@hicommonwealth/adapters'; +import { trpc } from '@hicommonwealth/adapters'; import cors from 'cors'; import { Router } from 'express'; import { config } from '../config'; @@ -39,7 +39,7 @@ const router = Router(); const trpcRouter = trpc.router(api); export type API = typeof trpcRouter; -router.use('/trpc', express.statsMiddleware, trpc.toExpress(trpcRouter)); +router.use('/trpc', trpc.toExpress(trpcRouter)); if (config.NODE_ENV !== 'production') { router.use(cors()); diff --git a/packages/commonwealth/server/api/threads.ts b/packages/commonwealth/server/api/threads.ts index 1cfa550529a..64f08a56ad8 100644 --- a/packages/commonwealth/server/api/threads.ts +++ b/packages/commonwealth/server/api/threads.ts @@ -3,13 +3,9 @@ import { Thread } from '@hicommonwealth/model'; import { MixpanelCommunityInteractionEvent } from '../../shared/analytics/types'; export const trpcRouter = trpc.router({ - createThread: trpc.command( - Thread.CreateThread, - trpc.Tag.Thread, - trpc.track( - MixpanelCommunityInteractionEvent.CREATE_THREAD, - ({ community_id }) => ({ community: community_id }), - ), - ), + createThread: trpc.command(Thread.CreateThread, trpc.Tag.Thread, [ + MixpanelCommunityInteractionEvent.CREATE_THREAD, + ({ community_id }) => ({ community: community_id }), + ]), getBulkThreads: trpc.query(Thread.GetBulkThreads, trpc.Tag.Thread), }); diff --git a/packages/commonwealth/server/config.ts b/packages/commonwealth/server/config.ts index 35781adb803..2824ea9773f 100644 --- a/packages/commonwealth/server/config.ts +++ b/packages/commonwealth/server/config.ts @@ -214,7 +214,7 @@ export const config = configure( model_config.APP_ENV, ) && !data ), - 'DISCORD_BOT_TOKEN is required in production, frick, beta (QA), and demo', + 'DISCORD_TOKEN is required in production, frick, beta (QA), and demo', ), }), CLOUDFLARE: z.object({