From 3033ebacb75999d6c3397c47e0c2223e4afa9eba Mon Sep 17 00:00:00 2001 From: Katalina Okano Date: Sat, 29 Jan 2022 01:28:29 -0500 Subject: [PATCH] add majority of routes and datapaths, start on interactions --- packages/api/package.json | 6 +- packages/api/src/guilds/audit-logging.spec.ts | 5 + packages/api/src/guilds/audit-logging.ts | 0 packages/api/src/guilds/getters.spec.ts | 130 ++++++++++++++ packages/api/src/guilds/getters.ts | 149 ++++++++++++++++ packages/api/src/guilds/middleware.spec.ts | 78 +++++++++ packages/api/src/guilds/middleware.ts | 47 +++++ packages/api/src/index.ts | 59 ++++++- packages/api/src/routes/auth/bot.ts | 7 +- packages/api/src/routes/auth/bounce.ts | 7 +- packages/api/src/routes/auth/callback.spec.ts | 4 - packages/api/src/routes/auth/callback.ts | 7 +- .../src/routes/auth/delete-session.spec.ts | 63 +++++++ .../api/src/routes/auth/delete-session.ts | 27 +++ packages/api/src/routes/auth/session.spec.ts | 53 ++++++ packages/api/src/routes/auth/session.ts | 17 ++ packages/api/src/routes/guilds/guild.spec.ts | 161 +++++++++++++++++ packages/api/src/routes/guilds/guild.ts | 42 +++++ .../src/routes/guilds/guilds-patch.spec.ts | 164 ++++++++++++++++++ .../api/src/routes/guilds/guilds-patch.ts | 37 ++++ packages/api/src/routes/guilds/slug.spec.ts | 52 ++++++ packages/api/src/routes/guilds/slug.ts | 23 +++ .../src/routes/interactions/helpers.spec.ts | 123 +++++++++++++ .../api/src/routes/interactions/helpers.ts | 28 +++ .../routes/interactions/interactions.spec.ts | 3 + .../src/routes/interactions/interactions.ts | 28 +++ .../api/src/routes/interactions/responses.ts | 29 ++++ packages/api/src/routes/legacy/import.ts | 5 +- packages/api/src/routes/legacy/preflight.ts | 5 +- .../api/src/routes/~template/template.spec.ts | 5 + packages/api/src/routes/~template/template.ts | 6 + packages/api/src/sessions/middleware.spec.ts | 57 +++--- packages/api/src/sessions/middleware.ts | 14 +- packages/api/src/utils/config.ts | 8 +- packages/api/src/utils/context.ts | 14 ++ packages/api/src/utils/discord.spec.ts | 34 ++++ packages/api/src/utils/discord.ts | 32 ++++ packages/api/src/utils/id.spec.ts | 2 +- packages/api/src/utils/kv.spec.ts | 91 ++++++++++ packages/api/src/utils/kv.ts | 23 +++ packages/api/src/utils/request.ts | 12 ++ packages/api/src/utils/response.ts | 7 + packages/api/src/utils/testHelpers.ts | 36 +++- packages/api/test/miniflare.d.ts | 2 + packages/api/tsconfig.json | 2 +- packages/api/tsconfig.test.json | 2 +- packages/api/wrangler.toml | 2 +- 47 files changed, 1650 insertions(+), 58 deletions(-) create mode 100644 packages/api/src/guilds/audit-logging.spec.ts create mode 100644 packages/api/src/guilds/audit-logging.ts create mode 100644 packages/api/src/guilds/getters.spec.ts create mode 100644 packages/api/src/guilds/getters.ts create mode 100644 packages/api/src/guilds/middleware.spec.ts create mode 100644 packages/api/src/guilds/middleware.ts create mode 100644 packages/api/src/routes/auth/delete-session.spec.ts create mode 100644 packages/api/src/routes/auth/delete-session.ts create mode 100644 packages/api/src/routes/auth/session.spec.ts create mode 100644 packages/api/src/routes/auth/session.ts create mode 100644 packages/api/src/routes/guilds/guild.spec.ts create mode 100644 packages/api/src/routes/guilds/guild.ts create mode 100644 packages/api/src/routes/guilds/guilds-patch.spec.ts create mode 100644 packages/api/src/routes/guilds/guilds-patch.ts create mode 100644 packages/api/src/routes/guilds/slug.spec.ts create mode 100644 packages/api/src/routes/guilds/slug.ts create mode 100644 packages/api/src/routes/interactions/helpers.spec.ts create mode 100644 packages/api/src/routes/interactions/helpers.ts create mode 100644 packages/api/src/routes/interactions/interactions.spec.ts create mode 100644 packages/api/src/routes/interactions/interactions.ts create mode 100644 packages/api/src/routes/interactions/responses.ts create mode 100644 packages/api/src/routes/~template/template.spec.ts create mode 100644 packages/api/src/routes/~template/template.ts create mode 100644 packages/api/src/utils/discord.spec.ts create mode 100644 packages/api/src/utils/kv.spec.ts diff --git a/packages/api/package.json b/packages/api/package.json index 931ffbb..5b80319 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,9 +1,11 @@ { "name": "@roleypoly/api", "version": "0.1.0", + "license": "MIT", "main": "./src/index.ts", "scripts": { - "build": "esbuild --bundle --sourcemap --platform=node --format=esm --outdir=dist --out-extension:.js=.mjs ./src/index.ts", + "build": "yarn build:dev --minify", + "build:dev": "esbuild --bundle --sourcemap --platform=node --format=esm --outdir=dist --out-extension:.js=.mjs ./src/index.ts", "dev": "miniflare --watch --debug", "lint:types": "tsc --noEmit" }, @@ -14,6 +16,7 @@ "@roleypoly/types": "*", "@roleypoly/worker-utils": "*", "@types/deep-equal": "^1.0.1", + "@types/node": "^13.13.0", "deep-equal": "^2.0.5", "esbuild": "^0.14.14", "itty-router": "^2.4.10", @@ -21,6 +24,7 @@ "lodash": "^4.17.21", "miniflare": "^2.2.0", "ts-jest": "^27.1.3", + "tweetnacl": "^1.0.3", "ulid-workers": "^1.1.0" } } diff --git a/packages/api/src/guilds/audit-logging.spec.ts b/packages/api/src/guilds/audit-logging.spec.ts new file mode 100644 index 0000000..b1e5271 --- /dev/null +++ b/packages/api/src/guilds/audit-logging.spec.ts @@ -0,0 +1,5 @@ +it('works', () => { + expect(true).toBeTruthy(); +}); + +export {}; diff --git a/packages/api/src/guilds/audit-logging.ts b/packages/api/src/guilds/audit-logging.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/api/src/guilds/getters.spec.ts b/packages/api/src/guilds/getters.spec.ts new file mode 100644 index 0000000..b606b08 --- /dev/null +++ b/packages/api/src/guilds/getters.spec.ts @@ -0,0 +1,130 @@ +jest.mock('../utils/discord'); + +import { Features, GuildData } from '@roleypoly/types'; +import { APIGuild, discordFetch } from '../utils/discord'; +import { configContext } from '../utils/testHelpers'; +import { getGuild, getGuildData, getGuildMember } from './getters'; + +const mockDiscordFetch = discordFetch as jest.Mock; + +beforeEach(() => { + mockDiscordFetch.mockReset(); +}); + +describe('getGuild', () => { + it('gets a guild from discord', async () => { + const [config] = configContext(); + const guild = { + id: '123', + name: 'test', + icon: 'test', + roles: [], + }; + + mockDiscordFetch.mockReturnValue(guild); + + const result = await getGuild(config, '123'); + + expect(result).toMatchObject(guild); + }); + + it('gets a guild from cache automatically', async () => { + const [config] = configContext(); + + const guild: APIGuild = { + id: '123', + name: 'test', + icon: 'test', + roles: [], + }; + + await config.kv.guilds.put('guilds/123', guild, config.retention.guild); + mockDiscordFetch.mockReturnValue({ ...guild, name: 'test2' }); + + const result = await getGuild(config, '123'); + + expect(result).toMatchObject(guild); + expect(result!.name).toBe('test'); + }); +}); + +describe('getGuildData', () => { + it('gets guild data from store', async () => { + const [config] = configContext(); + + const guildData: GuildData = { + id: '123', + message: 'Hello world!', + categories: [], + features: Features.None, + auditLogWebhook: null, + accessControl: { + allowList: [], + blockList: [], + blockPending: true, + }, + }; + + await config.kv.guildData.put('123', guildData); + + const result = await getGuildData(config, '123'); + + expect(result).toMatchObject(guildData); + }); + + it('adds fields that are missing from the stored data', async () => { + const [config] = configContext(); + + const guildData: Partial = { + id: '123', + message: 'Hello world!', + categories: [], + features: Features.None, + }; + + await config.kv.guildData.put('123', guildData); + const result = await getGuildData(config, '123'); + + expect(result).toMatchObject({ + ...guildData, + auditLogWebhook: null, + accessControl: expect.any(Object), + }); + }); +}); + +describe('getGuildMember', () => { + it('gets a member from discord', async () => { + const [config] = configContext(); + + const member = { + roles: [], + pending: false, + nick: 'test', + }; + + mockDiscordFetch.mockReturnValue(member); + + const result = await getGuildMember(config, '123', '123'); + + expect(result).toMatchObject(member); + }); + + it('gets a member from cache automatically', async () => { + const [config] = configContext(); + + const member = { + roles: [], + pending: false, + nick: 'test2', + }; + + await config.kv.guilds.put('guilds/123/members/123', member, config.retention.guild); + mockDiscordFetch.mockReturnValue({ ...member, nick: 'test' }); + + const result = await getGuildMember(config, '123', '123'); + + expect(result).toMatchObject(member); + expect(result!.nick).toBe('test2'); + }); +}); diff --git a/packages/api/src/guilds/getters.ts b/packages/api/src/guilds/getters.ts new file mode 100644 index 0000000..39f8ee5 --- /dev/null +++ b/packages/api/src/guilds/getters.ts @@ -0,0 +1,149 @@ +import { Config } from '@roleypoly/api/src/utils/config'; +import { + APIGuild, + APIMember, + APIRole, + AuthType, + discordFetch, + getHighestRole, +} from '@roleypoly/api/src/utils/discord'; +import { evaluatePermission, permissions } from '@roleypoly/misc-utils/hasPermission'; +import { + Features, + Guild, + GuildData, + Member, + OwnRoleInfo, + Role, + RoleSafety, +} from '@roleypoly/types'; + +export const getGuild = async ( + config: Config, + id: string, + forceMiss?: boolean +): Promise<(Guild & OwnRoleInfo) | null> => + config.kv.guilds.cacheThrough( + `guilds/${id}`, + async () => { + const guildRaw = await discordFetch( + `/guilds/${id}`, + config.botToken, + AuthType.Bot + ); + + if (!guildRaw) { + return null; + } + + const botMemberRoles = + (await getGuildMember(config, id, config.botClientID))?.roles || []; + + const highestRolePosition = + getHighestRole( + botMemberRoles + .map((r) => guildRaw.roles.find((r2) => r2.id === r)) + .filter((x) => x !== undefined) as APIRole[] + )?.position || -1; + + const roles = guildRaw.roles.map((role) => ({ + id: role.id, + name: role.name, + color: role.color, + managed: role.managed, + position: role.position, + permissions: role.permissions, + safety: RoleSafety.Safe, // TODO: calculate this + })); + + const guild: Guild & OwnRoleInfo = { + id, + name: guildRaw.name, + icon: guildRaw.icon, + roles, + highestRolePosition, + }; + + return guild; + }, + config.retention.guild, + forceMiss + ); + +export const getGuildData = async (config: Config, id: string): Promise => { + const guildData = await config.kv.guildData.get(id); + const empty = { + id, + message: '', + categories: [], + features: Features.None, + auditLogWebhook: null, + accessControl: { + allowList: [], + blockList: [], + blockPending: true, + }, + }; + + if (!guildData) { + return empty; + } + + return { + ...empty, + ...guildData, + }; +}; + +export const getGuildMember = async ( + config: Config, + serverID: string, + userID: string, + forceMiss?: boolean, + overrideRetention?: number // allows for own-member to be cached as long as it's used. +): Promise => + config.kv.guilds.cacheThrough( + `guilds/${serverID}/members/${userID}`, + async () => { + const discordMember = await discordFetch( + `/guilds/${serverID}/members/${userID}`, + config.botToken, + AuthType.Bot + ); + + if (!discordMember) { + return null; + } + + return { + guildid: serverID, + roles: discordMember.roles, + pending: discordMember.pending, + nick: discordMember.nick, + }; + }, + overrideRetention || config.retention.member, + forceMiss + ); + +const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => { + let safety = RoleSafety.Safe; + + if (role.managed) { + safety |= RoleSafety.ManagedRole; + } + + if (role.position > highestBotRolePosition) { + safety |= RoleSafety.HigherThanBot; + } + + const permBigInt = BigInt(role.permissions); + if ( + evaluatePermission(permBigInt, permissions.ADMINISTRATOR) || + evaluatePermission(permBigInt, permissions.MANAGE_ROLES) + ) { + safety |= RoleSafety.DangerousPermissions; + } + + return safety; +}; diff --git a/packages/api/src/guilds/middleware.spec.ts b/packages/api/src/guilds/middleware.spec.ts new file mode 100644 index 0000000..de4a1c9 --- /dev/null +++ b/packages/api/src/guilds/middleware.spec.ts @@ -0,0 +1,78 @@ +import { Router } from 'itty-router'; +import { json } from '../utils/response'; +import { configContext, makeSession } from '../utils/testHelpers'; +import { requireEditor } from './middleware'; + +describe('requireEditor', () => { + it('continues the request when user is an editor', async () => { + const testFn = jest.fn(); + const [config, context] = configContext(); + const session = await makeSession(config); + const router = Router(); + + router.all('*', requireEditor).get('/:guildId', (request, context) => { + testFn(); + return json({}); + }); + + const response = await router.handle( + new Request(`http://test.local/${session.guilds[1].id}`, { + headers: { + authorization: `Bearer ${session.sessionID}`, + }, + }), + { ...context, session, params: { guildId: session.guilds[1].id } } + ); + + expect(response.status).toBe(200); + expect(testFn).toHaveBeenCalledTimes(1); + }); + + it('403s the request when user is not an editor', async () => { + const testFn = jest.fn(); + const [config, context] = configContext(); + const session = await makeSession(config); + const router = Router(); + + router.all('*', requireEditor).get('/:guildId', (request, context) => { + testFn(); + return json({}); + }); + + const response = await router.handle( + new Request(`http://test.local/${session.guilds[0].id}`, { + headers: { + authorization: `Bearer ${session.sessionID}`, + }, + }), + { ...context, session, params: { guildId: session.guilds[0].id } } + ); + + expect(response.status).toBe(403); + expect(testFn).not.toHaveBeenCalled(); + }); + + it('404s the request when the guild isnt in session', async () => { + const testFn = jest.fn(); + const [config, context] = configContext(); + const session = await makeSession(config); + const router = Router(); + + router.all('*', requireEditor).get('/:guildId', (request, context) => { + testFn(); + return json({}); + }); + + const response = await router.handle( + new Request(`http://test.local/invalid-session-id`, { + headers: { + authorization: `Bearer ${session.sessionID}`, + }, + }), + { ...context, session, params: { guildId: 'invalid-session-id' } } + ); + + expect(response.status).toBe(404); + expect(testFn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/guilds/middleware.ts b/packages/api/src/guilds/middleware.ts new file mode 100644 index 0000000..2681398 --- /dev/null +++ b/packages/api/src/guilds/middleware.ts @@ -0,0 +1,47 @@ +import { Context, RoleypolyMiddleware } from '@roleypoly/api/src/utils/context'; +import { + engineeringProblem, + forbidden, + notFound, +} from '@roleypoly/api/src/utils/response'; +import { UserGuildPermissions } from '@roleypoly/types'; + +export const requireEditor: RoleypolyMiddleware = async ( + request: Request, + context: Context +) => { + if (!context.params.guildId) { + return engineeringProblem('params not set up correctly'); + } + + if (!context.session) { + return engineeringProblem('middleware not set up correctly'); + } + + const guild = context.session.guilds.find((g) => g.id === context.params.guildId); + if (!guild) { + return notFound(); // 404 because we don't want enumeration of guilds + } + + if (guild.permissionLevel === UserGuildPermissions.User) { + return forbidden(); + } +}; + +export const requireMember: RoleypolyMiddleware = async ( + request: Request, + context: Context +) => { + if (!context.params.guildId) { + return engineeringProblem('params not set up correctly'); + } + + if (!context.session) { + return engineeringProblem('middleware not set up correctly'); + } + + const guild = context.session.guilds.find((g) => g.id === context.params.guildId); + if (!guild) { + return notFound(); // 404 because we don't want enumeration of guilds + } +}; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b248646..ca3e718 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,12 +1,23 @@ // @ts-ignore +import { requireEditor, requireMember } from '@roleypoly/api/src/guilds/middleware'; import { authBot } from '@roleypoly/api/src/routes/auth/bot'; import { authCallback } from '@roleypoly/api/src/routes/auth/callback'; -import { withAuthMode } from '@roleypoly/api/src/sessions/middleware'; +import { authSessionDelete } from '@roleypoly/api/src/routes/auth/delete-session'; +import { authSession } from '@roleypoly/api/src/routes/auth/session'; +import { guildsGuild } from '@roleypoly/api/src/routes/guilds/guild'; +import { guildsGuildPatch } from '@roleypoly/api/src/routes/guilds/guilds-patch'; +import { guildsSlug } from '@roleypoly/api/src/routes/guilds/slug'; +import { + requireSession, + withAuthMode, + withSession, +} from '@roleypoly/api/src/sessions/middleware'; +import { injectParams } from '@roleypoly/api/src/utils/request'; import { Router } from 'itty-router'; import { authBounce } from './routes/auth/bounce'; -import { Config, Environment, parseEnvironment } from './utils/config'; -import { Context } from './utils/context'; -import { json, notFound } from './utils/response'; +import { Environment, parseEnvironment } from './utils/config'; +import { Context, RoleypolyHandler } from './utils/context'; +import { json, notFound, notImplemented, serverError } from './utils/response'; const router = Router(); @@ -15,8 +26,36 @@ router.all('*', withAuthMode); router.get('/auth/bot', authBot); router.get('/auth/bounce', authBounce); router.get('/auth/callback', authCallback); +router.get('/auth/session', withSession, requireSession, authSession); +router.delete('/auth/session', withSession, requireSession, authSessionDelete); -router.get('/', (request: Request, config: Config) => +const guildsCommon = [injectParams, withSession, requireSession, requireMember]; +router.get('/guilds/:guildId', ...guildsCommon, guildsGuild); +router.patch('/guilds/:guildId', ...guildsCommon, requireEditor, guildsGuildPatch); +router.delete('/guilds/:guildId/cache', ...guildsCommon, requireEditor, notImplemented); +router.put('/guilds/:guildId/roles', ...guildsCommon, notImplemented); + +// Slug is unauthenticated... +router.get('/guilds/slug/:guildId', injectParams, guildsSlug); + +router.post('/interactions', notImplemented); + +router.get( + '/legacy/preflight/:guildId', + injectParams, + withSession, + requireSession, + notImplemented +); +router.put( + '/legacy/import/:guildId', + injectParams, + withSession, + requireSession, + notImplemented +); + +router.get('/', ((request: Request, { config }: Context) => json({ __warning: '🦊', this: 'is', @@ -27,10 +66,9 @@ router.get('/', (request: Request, config: Config) => your: 'surroundings', warning__: '🦊', meta: config.uiPublicURI, - }) -); + })) as RoleypolyHandler); -router.get('*', () => notFound()); +router.any('*', () => notFound()); export default { async fetch(request: Request, env: Environment, event: Context['fetchContext']) { @@ -43,7 +81,10 @@ export default { authMode: { type: 'anonymous', }, + params: {}, }; - return router.handle(request, context); + return router + .handle(request, context) + .catch((e: Error) => (!e ? notFound() : serverError(e))); }, }; diff --git a/packages/api/src/routes/auth/bot.ts b/packages/api/src/routes/auth/bot.ts index ff79ed6..22d2501 100644 --- a/packages/api/src/routes/auth/bot.ts +++ b/packages/api/src/routes/auth/bot.ts @@ -1,4 +1,4 @@ -import { Context } from '@roleypoly/api/src/utils/context'; +import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context'; import { seeOther } from '@roleypoly/api/src/utils/response'; const validGuildID = /^[0-9]+$/; @@ -22,7 +22,10 @@ const buildURL = (params: URLParams) => { return url; }; -export const authBot = (request: Request, { config }: Context): Response => { +export const authBot: RoleypolyHandler = ( + request: Request, + { config }: Context +): Response => { let guildID = new URL(request.url).searchParams.get('guild') || ''; if (guildID && !validGuildID.test(guildID)) { diff --git a/packages/api/src/routes/auth/bounce.ts b/packages/api/src/routes/auth/bounce.ts index 310a2e6..f079e70 100644 --- a/packages/api/src/routes/auth/bounce.ts +++ b/packages/api/src/routes/auth/bounce.ts @@ -1,6 +1,6 @@ import { setupStateSession } from '@roleypoly/api/src/sessions/state'; import { Config } from '@roleypoly/api/src/utils/config'; -import { Context } from '@roleypoly/api/src/utils/context'; +import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context'; import { getQuery } from '@roleypoly/api/src/utils/request'; import { seeOther } from '@roleypoly/api/src/utils/response'; import { StateSession } from '@roleypoly/types'; @@ -44,7 +44,10 @@ export const isAllowedCallbackHost = (config: Config, host: string): boolean => ); }; -export const authBounce = async (request: Request, { config }: Context) => { +export const authBounce: RoleypolyHandler = async ( + request: Request, + { config }: Context +) => { const stateSessionData: StateSession = {}; const { cbh: callbackHost } = getQuery(request); diff --git a/packages/api/src/routes/auth/callback.spec.ts b/packages/api/src/routes/auth/callback.spec.ts index 7219359..4eae487 100644 --- a/packages/api/src/routes/auth/callback.spec.ts +++ b/packages/api/src/routes/auth/callback.spec.ts @@ -11,10 +11,6 @@ const mockDiscordFetch = discordFetch as jest.Mock; const mockCreateSession = createSession as jest.Mock; describe('GET /auth/callback', () => { - beforeEach(() => { - mockDiscordFetch.mockClear(); - }); - it('should ask Discord to trade code for tokens', async () => { const env = getBindings(); const config = parseEnvironment(env); diff --git a/packages/api/src/routes/auth/callback.ts b/packages/api/src/routes/auth/callback.ts index d163d14..00a76d2 100644 --- a/packages/api/src/routes/auth/callback.ts +++ b/packages/api/src/routes/auth/callback.ts @@ -1,7 +1,7 @@ import { isAllowedCallbackHost } from '@roleypoly/api/src/routes/auth/bounce'; import { createSession } from '@roleypoly/api/src/sessions/create'; import { getStateSession } from '@roleypoly/api/src/sessions/state'; -import { Context } from '@roleypoly/api/src/utils/context'; +import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context'; import { AuthType, discordAPIBase, discordFetch } from '@roleypoly/api/src/utils/discord'; import { dateFromID } from '@roleypoly/api/src/utils/id'; import { formDataRequest, getQuery } from '@roleypoly/api/src/utils/request'; @@ -14,7 +14,10 @@ const authFailure = (uiPublicURI: string, extra?: string) => `/machinery/error?error_code=authFailure${extra ? `&extra=${extra}` : ''}` ); -export const authCallback = async (request: Request, { config }: Context) => { +export const authCallback: RoleypolyHandler = async ( + request: Request, + { config }: Context +) => { let bounceBaseUrl = config.uiPublicURI; const { state: stateValue, code } = getQuery(request); diff --git a/packages/api/src/routes/auth/delete-session.spec.ts b/packages/api/src/routes/auth/delete-session.spec.ts new file mode 100644 index 0000000..c652c15 --- /dev/null +++ b/packages/api/src/routes/auth/delete-session.spec.ts @@ -0,0 +1,63 @@ +jest.mock('../../utils/discord'); + +import { SessionData } from '@roleypoly/types'; +import { parseEnvironment } from '../../utils/config'; +import { AuthType, discordFetch } from '../../utils/discord'; +import { formDataRequest } from '../../utils/request'; +import { getBindings, makeRequest } from '../../utils/testHelpers'; + +const mockDiscordFetch = discordFetch as jest.Mock; + +describe('DELETE /auth/session', () => { + it('deletes the current user session when it is valid', async () => { + const config = parseEnvironment(getBindings()); + + const session: SessionData = { + sessionID: 'test-session-id', + user: { + id: 'test-user-id', + username: 'test-username', + discriminator: 'test-discriminator', + avatar: 'test-avatar', + bot: false, + }, + guilds: [], + tokens: { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_in: 3600, + scope: 'identify guilds', + token_type: 'Bearer', + }, + }; + + await config.kv.sessions.put(session.sessionID, session); + + mockDiscordFetch.mockReturnValue( + new Response(null, { + status: 200, + }) + ); + + const response = await makeRequest('DELETE', '/auth/session', { + headers: { + Authorization: `Bearer ${session.sessionID}`, + }, + }); + + expect(response.status).toBe(204); + expect(await config.kv.sessions.get(session.sessionID)).toBeNull(); + expect(mockDiscordFetch).toHaveBeenCalledWith( + '/oauth2/token/revoke', + '', + AuthType.None, + expect.objectContaining( + formDataRequest({ + client_id: config.botClientID, + client_secret: config.botClientSecret, + token: session.tokens.access_token, + }) + ) + ); + }); +}); diff --git a/packages/api/src/routes/auth/delete-session.ts b/packages/api/src/routes/auth/delete-session.ts new file mode 100644 index 0000000..d3bcf40 --- /dev/null +++ b/packages/api/src/routes/auth/delete-session.ts @@ -0,0 +1,27 @@ +import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context'; +import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord'; +import { formDataRequest } from '@roleypoly/api/src/utils/request'; +import { noContent } from '@roleypoly/api/src/utils/response'; + +export const authSessionDelete: RoleypolyHandler = async ( + request: Request, + context: Context +) => { + if (!context.session) { + return noContent(); + } + + await discordFetch( + '/oauth2/token/revoke', + '', + AuthType.None, + formDataRequest({ + client_id: context.config.botClientID, + client_secret: context.config.botClientSecret, + token: context.session.tokens.access_token, + }) + ); + + await context.config.kv.sessions.delete(context.session.sessionID); + return noContent(); +}; diff --git a/packages/api/src/routes/auth/session.spec.ts b/packages/api/src/routes/auth/session.spec.ts new file mode 100644 index 0000000..5805c04 --- /dev/null +++ b/packages/api/src/routes/auth/session.spec.ts @@ -0,0 +1,53 @@ +import { SessionData } from '@roleypoly/types'; +import { parseEnvironment } from '../../utils/config'; +import { getBindings, makeRequest } from '../../utils/testHelpers'; + +describe('GET /auth/session', () => { + it('fetches the current user session when it is valid', async () => { + const config = parseEnvironment(getBindings()); + + const session: SessionData = { + sessionID: 'test-session-id', + user: { + id: 'test-user-id', + username: 'test-username', + discriminator: 'test-discriminator', + avatar: 'test-avatar', + bot: false, + }, + guilds: [], + tokens: { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_in: 3600, + scope: 'identify guilds', + token_type: 'Bearer', + }, + }; + + await config.kv.sessions.put(session.sessionID, session); + + const response = await makeRequest('GET', '/auth/session', { + headers: { + Authorization: `Bearer ${session.sessionID}`, + }, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + sessionID: session.sessionID, + user: session.user, + guilds: session.guilds, + }); + }); + + it('returns 401 when session is not valid', async () => { + const response = await makeRequest('GET', '/auth/session', { + headers: { + Authorization: `Bearer invalid-session-id`, + }, + }); + + expect(response.status).toBe(401); + }); +}); diff --git a/packages/api/src/routes/auth/session.ts b/packages/api/src/routes/auth/session.ts new file mode 100644 index 0000000..8ba3eca --- /dev/null +++ b/packages/api/src/routes/auth/session.ts @@ -0,0 +1,17 @@ +import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context'; +import { json, notFound } from '@roleypoly/api/src/utils/response'; + +export const authSession: RoleypolyHandler = async ( + request: Request, + context: Context +) => { + if (context.session) { + return json({ + user: context.session.user, + guilds: context.session.guilds, + sessionID: context.session.sessionID, + }); + } + + return notFound(); +}; diff --git a/packages/api/src/routes/guilds/guild.spec.ts b/packages/api/src/routes/guilds/guild.spec.ts new file mode 100644 index 0000000..fb57f1a --- /dev/null +++ b/packages/api/src/routes/guilds/guild.spec.ts @@ -0,0 +1,161 @@ +jest.mock('../../guilds/getters'); + +import { Features, GuildData, PresentableGuild } from '@roleypoly/types'; +import { getGuild, getGuildData, getGuildMember } from '../../guilds/getters'; +import { APIGuild, APIMember } from '../../utils/discord'; +import { configContext, makeRequest, makeSession } from '../../utils/testHelpers'; + +const mockGetGuild = getGuild as jest.Mock; +const mockGetGuildMember = getGuildMember as jest.Mock; +const mockGetGuildData = getGuildData as jest.Mock; + +beforeEach(() => { + mockGetGuildData.mockReset(); + mockGetGuild.mockReset(); + mockGetGuildMember.mockReset(); +}); + +describe('GET /guilds/:id', () => { + it('returns a presentable guild', async () => { + const guild: APIGuild = { + id: '123', + name: 'test', + icon: 'test', + roles: [ + { + id: 'role-1', + name: 'Role 1', + color: 0, + position: 17, + permissions: '', + managed: false, + }, + ], + }; + + const member: APIMember = { + roles: ['role-1'], + pending: false, + nick: '', + }; + + const guildData: GuildData = { + id: '123', + message: 'test', + categories: [], + features: Features.None, + auditLogWebhook: null, + accessControl: { + allowList: [], + blockList: [], + blockPending: false, + }, + }; + + mockGetGuild.mockReturnValue(guild); + mockGetGuildMember.mockReturnValue(member); + mockGetGuildData.mockReturnValue(guildData); + + const [config] = configContext(); + + const session = await makeSession(config, { + guilds: [ + { + id: '123', + name: 'test', + icon: 'test', + permissionLevel: 0, + }, + ], + }); + const response = await makeRequest('GET', `/guilds/${guild.id}`, { + headers: { + Authorization: `Bearer ${session.sessionID}`, + }, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + id: guild.id, + guild: session.guilds[0], + member: { + roles: member.roles, + }, + roles: guild.roles, + data: guildData, + } as PresentableGuild); + }); + + it('returns a 404 when the guild is not in session', async () => { + const [config, context] = configContext(); + const session = await makeSession(config); + const response = await makeRequest('GET', `/guilds/123`, { + headers: { + Authorization: `Bearer ${session.sessionID}`, + }, + }); + + expect(response.status).toBe(404); + }); + + it('returns 404 when the guild is not fetchable', async () => { + const [config, context] = configContext(); + const session = await makeSession(config, { + guilds: [ + { + id: '123', + name: 'test', + icon: 'test', + permissionLevel: 0, + }, + ], + }); + const response = await makeRequest('GET', `/guilds/123`, { + headers: { + Authorization: `Bearer ${session.sessionID}`, + }, + }); + + expect(response.status).toBe(404); + }); + + it('returns 404 when the member is no longer in the guild', async () => { + const guild: APIGuild = { + id: '123', + name: 'test', + icon: 'test', + roles: [ + { + id: 'role-1', + name: 'Role 1', + color: 0, + position: 17, + permissions: '', + managed: false, + }, + ], + }; + + mockGetGuild.mockReturnValue(guild); + mockGetGuildMember.mockReturnValue(null); + + const [config, context] = configContext(); + const session = await makeSession(config, { + guilds: [ + { + id: '123', + name: 'test', + icon: 'test', + permissionLevel: 0, + }, + ], + }); + const response = await makeRequest('GET', `/guilds/${guild.id}`, { + headers: { + Authorization: `Bearer ${session.sessionID}`, + }, + }); + + expect(response.status).toBe(404); + }); +}); diff --git a/packages/api/src/routes/guilds/guild.ts b/packages/api/src/routes/guilds/guild.ts new file mode 100644 index 0000000..c1b8607 --- /dev/null +++ b/packages/api/src/routes/guilds/guild.ts @@ -0,0 +1,42 @@ +import { + getGuild, + getGuildData, + getGuildMember, +} from '@roleypoly/api/src/guilds/getters'; +import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context'; +import { json, notFound } from '@roleypoly/api/src/utils/response'; +import { PresentableGuild } from '@roleypoly/types'; + +export const guildsGuild: RoleypolyHandler = async ( + request: Request, + context: Context +) => { + const guild = await getGuild(context.config, context.params!.guildId!); + + if (!guild) { + return notFound(); + } + + const member = await getGuildMember( + context.config, + context.params!.guildId!, + context.session!.user.id + ); + + if (!member) { + return notFound(); + } + + const data = await getGuildData(context.config, guild.id); + const presentableGuild: PresentableGuild = { + id: guild.id, + guild: context.session?.guilds.find((g) => g.id === guild.id)!, + roles: guild.roles, + member: { + roles: member.roles, + }, + data, + }; + + return json(presentableGuild); +}; diff --git a/packages/api/src/routes/guilds/guilds-patch.spec.ts b/packages/api/src/routes/guilds/guilds-patch.spec.ts new file mode 100644 index 0000000..408d71b --- /dev/null +++ b/packages/api/src/routes/guilds/guilds-patch.spec.ts @@ -0,0 +1,164 @@ +jest.mock('../../guilds/getters'); + +import { + Features, + GuildData, + GuildDataUpdate, + UserGuildPermissions, +} from '@roleypoly/types'; +import { getGuildData } from '../../guilds/getters'; +import { configContext, makeRequest, makeSession } from '../../utils/testHelpers'; + +const mockGetGuildData = getGuildData as jest.Mock; + +beforeAll(() => { + jest.resetAllMocks(); +}); + +describe('PATCH /guilds/:id', () => { + it('updates guild data when user is an editor', async () => { + const [config, context] = configContext(); + const session = await makeSession(config, { + guilds: [ + { + id: '123', + name: 'test', + icon: 'test', + permissionLevel: UserGuildPermissions.Manager, + }, + ], + }); + + mockGetGuildData.mockReturnValue({ + id: '123', + message: 'test', + categories: [], + features: Features.None, + auditLogWebhook: null, + accessControl: { + allowList: [], + blockList: [], + blockPending: false, + }, + } as GuildData); + + const response = await makeRequest('PATCH', `/guilds/123`, { + headers: { + Authorization: `Bearer ${session.sessionID}`, + }, + body: JSON.stringify({ + message: 'hello test world!', + } as GuildDataUpdate), + }); + + expect(response.status).toBe(200); + + const newGuildData = await config.kv.guildData.get('123'); + expect(newGuildData).toMatchObject({ + message: 'hello test world!', + }); + }); + + it('ignores extraneous fields sent as updates', async () => { + const [config, context] = configContext(); + const session = await makeSession(config, { + guilds: [ + { + id: '123', + name: 'test', + icon: 'test', + permissionLevel: UserGuildPermissions.Manager, + }, + ], + }); + + mockGetGuildData.mockReturnValue({ + id: '123', + message: 'test', + categories: [], + features: Features.None, + auditLogWebhook: null, + accessControl: { + allowList: [], + blockList: [], + blockPending: false, + }, + } as GuildData); + + const response = await makeRequest('PATCH', `/guilds/123`, { + headers: { + Authorization: `Bearer ${session.sessionID}`, + }, + body: JSON.stringify({ + fifteen: 'foxes', + }), + }); + + expect(response.status).toBe(200); + + const newGuildData = await config.kv.guildData.get('123'); + expect(newGuildData).not.toMatchObject({ + fifteen: 'foxes', + }); + }); + + it('403s when user is not an editor', async () => { + const [config, context] = configContext(); + const session = await makeSession(config, { + guilds: [ + { + id: '123', + name: 'test', + icon: 'test', + permissionLevel: UserGuildPermissions.User, + }, + ], + }); + + mockGetGuildData.mockReturnValue({ + id: '123', + message: 'test', + categories: [], + features: Features.None, + auditLogWebhook: null, + accessControl: { + allowList: [], + blockList: [], + blockPending: false, + }, + } as GuildData); + + const response = await makeRequest('PATCH', `/guilds/123`, { + headers: { + Authorization: `Bearer ${session.sessionID}`, + }, + body: JSON.stringify({ + message: 'hello test world!', + } as GuildDataUpdate), + }); + + expect(response.status).toBe(403); + }); + + it('400s when no body is present', async () => { + const [config, context] = configContext(); + const session = await makeSession(config, { + guilds: [ + { + id: '123', + name: 'test', + icon: 'test', + permissionLevel: UserGuildPermissions.Manager, + }, + ], + }); + + const response = await makeRequest('PATCH', `/guilds/123`, { + headers: { + Authorization: `Bearer ${session.sessionID}`, + }, + }); + + expect(response.status).toBe(400); + }); +}); diff --git a/packages/api/src/routes/guilds/guilds-patch.ts b/packages/api/src/routes/guilds/guilds-patch.ts new file mode 100644 index 0000000..833288a --- /dev/null +++ b/packages/api/src/routes/guilds/guilds-patch.ts @@ -0,0 +1,37 @@ +import { getGuildData } from '@roleypoly/api/src/guilds/getters'; +import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context'; +import { invalid, json, notFound } from '@roleypoly/api/src/utils/response'; +import { GuildData, GuildDataUpdate } from '@roleypoly/types'; + +export const guildsGuildPatch: RoleypolyHandler = async ( + request: Request, + context: Context +) => { + const id = context.params.guildId!; + if (!request.body) { + return invalid(); + } + + const update: GuildDataUpdate = await request.json(); + + const oldGuildData = await getGuildData(context.config, id); + if (!oldGuildData) { + return notFound(); + } + + const newGuildData: GuildData = { + ...oldGuildData, + + // TODO: validation + message: update.message || oldGuildData.message, + categories: update.categories || oldGuildData.categories, + accessControl: update.accessControl || oldGuildData.accessControl, + + // TODO: audit log webhooks + auditLogWebhook: oldGuildData.auditLogWebhook, + }; + + await context.config.kv.guildData.put(id, newGuildData); + + return json(newGuildData); +}; diff --git a/packages/api/src/routes/guilds/slug.spec.ts b/packages/api/src/routes/guilds/slug.spec.ts new file mode 100644 index 0000000..a0823f6 --- /dev/null +++ b/packages/api/src/routes/guilds/slug.spec.ts @@ -0,0 +1,52 @@ +jest.mock('../../guilds/getters'); + +import { GuildSlug, UserGuildPermissions } from '@roleypoly/types'; +import { getGuild } from '../../guilds/getters'; +import { APIGuild } from '../../utils/discord'; +import { makeRequest } from '../../utils/testHelpers'; + +const mockGetGuild = getGuild as jest.Mock; + +beforeEach(() => { + mockGetGuild.mockReset(); +}); + +describe('GET /guilds/slug/:id', () => { + it('returns a valid slug for a given discord server', async () => { + const guild: APIGuild = { + id: '123', + name: 'test', + icon: 'test', + roles: [ + { + id: 'role-1', + name: 'Role 1', + color: 0, + position: 17, + permissions: '', + managed: false, + }, + ], + }; + + mockGetGuild.mockReturnValue(guild); + + const response = await makeRequest('GET', `/guilds/slug/${guild.id}`); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + id: guild.id, + icon: guild.icon, + name: guild.name, + permissionLevel: UserGuildPermissions.User, + } as GuildSlug); + }); + + it('returns a 404 if the guild cannot be fetched', async () => { + mockGetGuild.mockReturnValue(null); + + const response = await makeRequest('GET', `/guilds/slug/123`); + + expect(response.status).toBe(404); + }); +}); diff --git a/packages/api/src/routes/guilds/slug.ts b/packages/api/src/routes/guilds/slug.ts new file mode 100644 index 0000000..a110e95 --- /dev/null +++ b/packages/api/src/routes/guilds/slug.ts @@ -0,0 +1,23 @@ +import { getGuild } from '@roleypoly/api/src/guilds/getters'; +import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context'; +import { json, notFound } from '@roleypoly/api/src/utils/response'; +import { GuildSlug, UserGuildPermissions } from '@roleypoly/types'; + +export const guildsSlug: RoleypolyHandler = async ( + request: Request, + context: Context +) => { + const id = context.params.guildId!; + const guild = await getGuild(context.config, id); + if (!guild) { + return notFound(); + } + + const slug: GuildSlug = { + id, + name: guild.name, + icon: guild.icon, + permissionLevel: UserGuildPermissions.User, + }; + return json(slug); +}; diff --git a/packages/api/src/routes/interactions/helpers.spec.ts b/packages/api/src/routes/interactions/helpers.spec.ts new file mode 100644 index 0000000..f6c21b2 --- /dev/null +++ b/packages/api/src/routes/interactions/helpers.spec.ts @@ -0,0 +1,123 @@ +import { InteractionRequest, InteractionType } from '@roleypoly/types'; +import nacl from 'tweetnacl'; +import { configContext } from '../../utils/testHelpers'; +import { verifyRequest } from './helpers'; + +describe('verifyRequest', () => { + it('validates a successful Discord interactions request', () => { + const [config, context] = configContext(); + + const timestamp = String(Date.now()); + const body: InteractionRequest = { + id: '123', + type: InteractionType.APPLICATION_COMMAND, + application_id: '123', + token: '123', + version: 1, + }; + + const { publicKey, secretKey } = nacl.sign.keyPair(); + const signature = nacl.sign.detached( + Buffer.from(timestamp + JSON.stringify(body)), + secretKey + ); + config.publicKey = Buffer.from(publicKey).toString('hex'); + + const request = new Request('http://local.test', { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'x-signature-timestamp': timestamp, + 'x-signature-ed25519': Buffer.from(signature).toString('hex'), + }, + }); + + expect(verifyRequest(context.config, request, body)).toBe(true); + }); + + it('fails to validate a headerless Discord interactions request', () => { + const [config, context] = configContext(); + + const body: InteractionRequest = { + id: '123', + type: InteractionType.APPLICATION_COMMAND, + application_id: '123', + token: '123', + version: 1, + }; + + const { publicKey, secretKey } = nacl.sign.keyPair(); + config.publicKey = Buffer.from(publicKey).toString('hex'); + + const request = new Request('http://local.test', { + method: 'POST', + body: JSON.stringify(body), + headers: {}, + }); + + expect(verifyRequest(context.config, request, body)).toBe(false); + }); + + it('fails to validate a bad signature from Discord', () => { + const [config, context] = configContext(); + + const timestamp = String(Date.now()); + const body: InteractionRequest = { + id: '123', + type: InteractionType.APPLICATION_COMMAND, + application_id: '123', + token: '123', + version: 1, + }; + + const { publicKey } = nacl.sign.keyPair(); + const { secretKey: otherKey } = nacl.sign.keyPair(); + const signature = nacl.sign.detached( + Buffer.from(timestamp + JSON.stringify(body)), + otherKey + ); + config.publicKey = Buffer.from(publicKey).toString('hex'); + + const request = new Request('http://local.test', { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'x-signature-timestamp': timestamp, + 'x-signature-ed25519': Buffer.from(signature).toString('hex'), + }, + }); + + expect(verifyRequest(context.config, request, body)).toBe(false); + }); + + it('fails to validate when signature differs from data', () => { + const [config, context] = configContext(); + + const timestamp = String(Date.now()); + const body: InteractionRequest = { + id: '123', + type: InteractionType.APPLICATION_COMMAND, + application_id: '123', + token: '123', + version: 1, + }; + + const { publicKey, secretKey } = nacl.sign.keyPair(); + const signature = nacl.sign.detached( + Buffer.from(timestamp + JSON.stringify({ ...body, id: '456' })), + secretKey + ); + config.publicKey = Buffer.from(publicKey).toString('hex'); + + const request = new Request('http://local.test', { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'x-signature-timestamp': timestamp, + 'x-signature-ed25519': Buffer.from(signature).toString('hex'), + }, + }); + + expect(verifyRequest(context.config, request, body)).toBe(false); + }); +}); diff --git a/packages/api/src/routes/interactions/helpers.ts b/packages/api/src/routes/interactions/helpers.ts new file mode 100644 index 0000000..db07616 --- /dev/null +++ b/packages/api/src/routes/interactions/helpers.ts @@ -0,0 +1,28 @@ +import { Config } from '@roleypoly/api/src/utils/config'; +import { InteractionRequest } from '@roleypoly/types'; +import nacl from 'tweetnacl'; + +export const verifyRequest = ( + config: Config, + request: Request, + interaction: InteractionRequest +): boolean => { + const timestamp = request.headers.get('x-signature-timestamp'); + const signature = request.headers.get('x-signature-ed25519'); + + if (!timestamp || !signature) { + return false; + } + + if ( + !nacl.sign.detached.verify( + Buffer.from(timestamp + JSON.stringify(interaction)), + Buffer.from(signature, 'hex'), + Buffer.from(config.publicKey, 'hex') + ) + ) { + return false; + } + + return true; +}; diff --git a/packages/api/src/routes/interactions/interactions.spec.ts b/packages/api/src/routes/interactions/interactions.spec.ts new file mode 100644 index 0000000..24288f2 --- /dev/null +++ b/packages/api/src/routes/interactions/interactions.spec.ts @@ -0,0 +1,3 @@ +describe('interactions validations from Discord', () => { + it('', () => {}); +}); diff --git a/packages/api/src/routes/interactions/interactions.ts b/packages/api/src/routes/interactions/interactions.ts new file mode 100644 index 0000000..bf8bd97 --- /dev/null +++ b/packages/api/src/routes/interactions/interactions.ts @@ -0,0 +1,28 @@ +import { verifyRequest } from '@roleypoly/api/src/routes/interactions/helpers'; +import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context'; +import { invalid, json } from '@roleypoly/api/src/utils/response'; +import { InteractionRequest, InteractionType } from '@roleypoly/types'; + +export const handleInteraction: RoleypolyHandler = async ( + request: Request, + context: Context +) => { + const interaction: InteractionRequest = await request.json(); + if (!interaction) { + return invalid(); + } + + if (!verifyRequest(context.config, request, interaction)) { + return new Response('invalid request signature', { status: 401 }); + } + + if (interaction.type !== InteractionType.APPLICATION_COMMAND) { + return json({ err: 'not implemented' }, { status: 400 }); + } + + if (!interaction.data) { + return json({ err: 'data missing' }, { status: 400 }); + } + + return json({}); +}; diff --git a/packages/api/src/routes/interactions/responses.ts b/packages/api/src/routes/interactions/responses.ts new file mode 100644 index 0000000..f79dccc --- /dev/null +++ b/packages/api/src/routes/interactions/responses.ts @@ -0,0 +1,29 @@ +import { + InteractionCallbackType, + InteractionFlags, + InteractionResponse, +} from '@roleypoly/types'; + +export const mustBeInGuild = (): InteractionResponse => ({ + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: ':x: This command has to be used in a server.', + flags: InteractionFlags.EPHEMERAL, + }, +}); + +export const invalid = (): InteractionResponse => ({ + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: ':x: You filled that command out wrong...', + flags: InteractionFlags.EPHEMERAL, + }, +}); + +export const somethingWentWrong = (): InteractionResponse => ({ + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: ' Something went terribly wrong.', + flags: InteractionFlags.EPHEMERAL, + }, +}); diff --git a/packages/api/src/routes/legacy/import.ts b/packages/api/src/routes/legacy/import.ts index 81c9d9e..c350599 100644 --- a/packages/api/src/routes/legacy/import.ts +++ b/packages/api/src/routes/legacy/import.ts @@ -1,8 +1,9 @@ +import { RoleypolyHandler } from '@roleypoly/api/src/utils/context'; import { notImplemented } from '@roleypoly/api/src/utils/response'; /** * Full import flow for legacy config. */ -export const legacyImport = () => { - notImplemented(); +export const legacyImport: RoleypolyHandler = () => { + return notImplemented(); }; diff --git a/packages/api/src/routes/legacy/preflight.ts b/packages/api/src/routes/legacy/preflight.ts index b36c98f..3487d4e 100644 --- a/packages/api/src/routes/legacy/preflight.ts +++ b/packages/api/src/routes/legacy/preflight.ts @@ -1,8 +1,9 @@ +import { RoleypolyHandler } from '@roleypoly/api/src/utils/context'; import { notImplemented } from '@roleypoly/api/src/utils/response'; /** * Fetch setup from beta.roleypoly.com to show the admin. */ -export const legacyPreflight = () => { - notImplemented(); +export const legacyPreflight: RoleypolyHandler = () => { + return notImplemented(); }; diff --git a/packages/api/src/routes/~template/template.spec.ts b/packages/api/src/routes/~template/template.spec.ts new file mode 100644 index 0000000..77205d0 --- /dev/null +++ b/packages/api/src/routes/~template/template.spec.ts @@ -0,0 +1,5 @@ +describe('GET /~template', () => { + it('returns Not Implemented when called', () => { + expect(true).toBe(true); + }); +}); diff --git a/packages/api/src/routes/~template/template.ts b/packages/api/src/routes/~template/template.ts new file mode 100644 index 0000000..a67a51e --- /dev/null +++ b/packages/api/src/routes/~template/template.ts @@ -0,0 +1,6 @@ +import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context'; +import { notImplemented } from '@roleypoly/api/src/utils/response'; + +export const template: RoleypolyHandler = async (request: Request, context: Context) => { + return notImplemented(); +}; diff --git a/packages/api/src/sessions/middleware.spec.ts b/packages/api/src/sessions/middleware.spec.ts index 8babad3..a43eda9 100644 --- a/packages/api/src/sessions/middleware.spec.ts +++ b/packages/api/src/sessions/middleware.spec.ts @@ -1,43 +1,36 @@ import { Router } from 'itty-router'; -import { Config, parseEnvironment } from '../utils/config'; import { Context } from '../utils/context'; import { json } from '../utils/response'; -import { getBindings, makeSession } from '../utils/testHelpers'; +import { configContext, makeSession } from '../utils/testHelpers'; import { requireSession, withAuthMode, withSession } from './middleware'; -const setup = (): [Config, Context] => { - const config = parseEnvironment(getBindings()); - const context: Context = { - config, - fetchContext: { - waitUntil: () => {}, - }, - authMode: { - type: 'anonymous', - }, - }; - - return [config, context]; -}; - it('detects anonymous auth mode via middleware', async () => { - const [, context] = setup(); + const [, context] = configContext(); const router = Router(); + const testFn = jest.fn(); + router.all('*', withAuthMode).get('/', (request, context) => { expect(context.authMode.type).toBe('anonymous'); + testFn(); + return json({}); }); await router.handle(new Request('http://test.local/'), context); + + expect(testFn).toHaveBeenCalled(); }); it('detects bearer auth mode via middleware', async () => { - const [, context] = setup(); + const [, context] = configContext(); + const testFn = jest.fn(); const token = 'abc123'; const router = Router(); router.all('*', withAuthMode).get('/', (request, context) => { expect(context.authMode.type).toBe('bearer'); expect(context.authMode.sessionId).toBe(token); + testFn(); + return json({}); }); await router.handle( @@ -48,16 +41,21 @@ it('detects bearer auth mode via middleware', async () => { }), context ); + + expect(testFn).toHaveBeenCalled(); }); it('detects bot auth mode via middleware', async () => { - const [, context] = setup(); + const testFn = jest.fn(); + const [, context] = configContext(); const token = 'abc123'; const router = Router(); router.all('*', withAuthMode).get('/', (request, context) => { expect(context.authMode.type).toBe('bot'); expect(context.authMode.identity).toBe(token); + testFn(); + return json({}); }); await router.handle( @@ -68,10 +66,13 @@ it('detects bot auth mode via middleware', async () => { }), context ); + + expect(testFn).toHaveBeenCalled(); }); it('sets Context.session via withSession middleware', async () => { - const [config, context] = setup(); + const testFn = jest.fn(); + const [config, context] = configContext(); const session = await makeSession(config); @@ -79,6 +80,8 @@ it('sets Context.session via withSession middleware', async () => { router.all('*', withAuthMode, withSession).get('/', (request, context: Context) => { expect(context.session).toBeDefined(); expect(context.session!.sessionID).toBe(session.sessionID); + testFn(); + return json({}); }); await router.handle( @@ -89,14 +92,18 @@ it('sets Context.session via withSession middleware', async () => { }), context ); + expect(testFn).toHaveBeenCalledTimes(1); }); it('does not set Context.session when session is invalid', async () => { - const [, context] = setup(); + const testFn = jest.fn(); + const [, context] = configContext(); const router = Router(); router.all('*', withAuthMode, withSession).get('/', (request, context: Context) => { expect(context.session).not.toBeDefined(); + testFn(); + return json({}); }); await router.handle( @@ -107,10 +114,12 @@ it('does not set Context.session when session is invalid', async () => { }), context ); + + expect(testFn).toHaveBeenCalledTimes(1); }); it('errors with 401 when requireSession is coupled with invalid session', async () => { - const [, context] = setup(); + const [, context] = configContext(); const router = Router(); const testFn = jest.fn(); @@ -135,7 +144,7 @@ it('errors with 401 when requireSession is coupled with invalid session', async }); it('passes through when requireSession is coupled with a valid session', async () => { - const [config, context] = setup(); + const [config, context] = configContext(); const session = await makeSession(config); const router = Router(); diff --git a/packages/api/src/sessions/middleware.ts b/packages/api/src/sessions/middleware.ts index f1bf9c3..3c7dfaa 100644 --- a/packages/api/src/sessions/middleware.ts +++ b/packages/api/src/sessions/middleware.ts @@ -1,8 +1,11 @@ -import { Context } from '@roleypoly/api/src/utils/context'; +import { Context, RoleypolyMiddleware } from '@roleypoly/api/src/utils/context'; import { unauthorized } from '@roleypoly/api/src/utils/response'; import { SessionData } from '@roleypoly/types'; -export const withSession = async (request: Request, context: Context) => { +export const withSession: RoleypolyMiddleware = async ( + request: Request, + context: Context +) => { if (context.authMode.type !== 'bearer') { return; } @@ -17,13 +20,16 @@ export const withSession = async (request: Request, context: Context) => { context.session = session; }; -export const requireSession = (request: Request, context: Context) => { +export const requireSession: RoleypolyMiddleware = ( + request: Request, + context: Context +) => { if (context.authMode.type !== 'bearer' || !context.session) { return unauthorized(); } }; -export const withAuthMode = (request: Request, context: Context) => { +export const withAuthMode: RoleypolyMiddleware = (request: Request, context: Context) => { const auth = extractAuthentication(request); if (auth.authType === 'Bearer') { diff --git a/packages/api/src/utils/config.ts b/packages/api/src/utils/config.ts index 1c2aafa..8464eab 100644 --- a/packages/api/src/utils/config.ts +++ b/packages/api/src/utils/config.ts @@ -12,7 +12,7 @@ export type Environment = { INTERACTIONS_SHARED_KEY: string; RP_SERVER_ID: string; RP_HELPER_ROLE_IDS: string; - + DISCORD_PUBLIC_KEY: string; KV_SESSIONS: KVNamespace; KV_GUILDS: KVNamespace; KV_GUILD_DATA: KVNamespace; @@ -22,6 +22,7 @@ export type Config = { botClientID: string; botClientSecret: string; botToken: string; + publicKey: string; uiPublicURI: string; apiPublicURI: string; rootUsers: string[]; @@ -38,6 +39,8 @@ export type Config = { retention: { session: number; sessionState: number; + guild: number; + member: number; }; _raw: Environment; }; @@ -51,6 +54,7 @@ export const parseEnvironment = (env: Environment): Config => { botClientID: env.BOT_CLIENT_ID, botClientSecret: env.BOT_CLIENT_SECRET, botToken: env.BOT_TOKEN, + publicKey: env.DISCORD_PUBLIC_KEY, uiPublicURI: safeURI(env.UI_PUBLIC_URI), apiPublicURI: safeURI(env.API_PUBLIC_URI), rootUsers: toList(env.ROOT_USERS), @@ -67,6 +71,8 @@ export const parseEnvironment = (env: Environment): Config => { retention: { session: 60 * 60 * 6, // 6 hours sessionState: 60 * 5, // 5 minutes + guild: 60 * 60 * 2, // 2 hours + member: 60 * 5, // 5 minutes }, }; }; diff --git a/packages/api/src/utils/context.ts b/packages/api/src/utils/context.ts index 79cc573..5e2b394 100644 --- a/packages/api/src/utils/context.ts +++ b/packages/api/src/utils/context.ts @@ -20,7 +20,21 @@ export type Context = { waitUntil: FetchEvent['waitUntil']; }; authMode: AuthMode; + params: { + guildId?: string; + memberId?: string; + }; // Must include withSession middleware for population session?: SessionData; }; + +export type RoleypolyHandler = ( + request: Request, + context: Context +) => Promise | Response; + +export type RoleypolyMiddleware = ( + request: Request, + context: Context +) => Promise | Response | void; diff --git a/packages/api/src/utils/discord.spec.ts b/packages/api/src/utils/discord.spec.ts new file mode 100644 index 0000000..f4007bf --- /dev/null +++ b/packages/api/src/utils/discord.spec.ts @@ -0,0 +1,34 @@ +import { getHighestRole } from './discord'; + +describe('getHighestRole', () => { + it('returns the highest role', () => { + const roles = [ + { + id: 'role-1', + name: 'Role 1', + color: 0, + position: 17, + permissions: '', + managed: false, + }, + { + id: 'role-2', + name: 'Role 2', + color: 0, + position: 2, + permissions: '', + managed: false, + }, + { + id: 'role-3', + name: 'Role 3', + color: 0, + position: 19, + permissions: '', + managed: false, + }, + ]; + + expect(getHighestRole(roles)).toEqual(roles[2]); + }); +}); diff --git a/packages/api/src/utils/discord.ts b/packages/api/src/utils/discord.ts index 19b414b..5eee083 100644 --- a/packages/api/src/utils/discord.ts +++ b/packages/api/src/utils/discord.ts @@ -6,6 +6,7 @@ import { AuthTokenResponse, DiscordUser, GuildSlug, + Role, UserGuildPermissions, } from '@roleypoly/types'; @@ -102,6 +103,30 @@ export const getTokenGuilds = async (accessToken: string) => { return guildSlugs; }; +export type APIGuild = { + // Only relevant stuff + id: string; + name: string; + icon: string; + roles: APIRole[]; +}; + +export type APIRole = { + id: string; + name: string; + color: number; + position: number; + permissions: string; + managed: boolean; +}; + +export type APIMember = { + // Only relevant stuff, again. + roles: string[]; + pending: boolean; + nick: string; +}; + export const parsePermissions = ( permissions: bigint, owner: boolean = false @@ -116,3 +141,10 @@ export const parsePermissions = ( return UserGuildPermissions.User; }; + +export const getHighestRole = (roles: (Role | APIRole)[]): Role | APIRole => { + return roles.reduce( + (highestRole, role) => (highestRole.position > role.position ? highestRole : role), + roles[0] + ); +}; diff --git a/packages/api/src/utils/id.spec.ts b/packages/api/src/utils/id.spec.ts index cd6b5e5..8da09ab 100644 --- a/packages/api/src/utils/id.spec.ts +++ b/packages/api/src/utils/id.spec.ts @@ -5,5 +5,5 @@ it('returns an id', () => { }); it('outputs a valid millisecond decoded from id', () => { - expect(dateFromID(getID())).toBeCloseTo(Date.now(), Date.now().toString.length - 2); + expect(dateFromID(getID())).toBeCloseTo(Date.now(), Date.now().toString.length - 4); }); diff --git a/packages/api/src/utils/kv.spec.ts b/packages/api/src/utils/kv.spec.ts new file mode 100644 index 0000000..1c054fb --- /dev/null +++ b/packages/api/src/utils/kv.spec.ts @@ -0,0 +1,91 @@ +import { configContext } from './testHelpers'; + +it('serializes data via get and put', async () => { + const [config] = configContext(); + + const data = { + foo: 'bar', + baz: 'qux', + }; + + await config.kv.guilds.put('test-guild-id', data, config.retention.guild); + + const result = await config.kv.guilds.get('test-guild-id'); + + expect(result).toEqual(data); +}); + +describe('cacheThrough', () => { + it('passes through for data on misses', async () => { + const [config] = configContext(); + + const data = { + foo: 'bar', + baz: 'qux', + }; + const testFn = jest.fn(); + const result = await config.kv.guilds.cacheThrough( + 'test-guild-id', + async () => { + testFn(); + return data; + }, + config.retention.guild + ); + + expect(testFn).toHaveBeenCalledTimes(1); + expect(result).toEqual(data); + }); + + it('uses cache data on hits', async () => { + const [config] = configContext(); + + const data = { + foo: 'bar', + baz: 'qux', + }; + const testFn = jest.fn(); + await config.kv.guilds.put('test-guild-id', data, config.retention.guild); + + const result = await config.kv.guilds.cacheThrough( + 'test-guild-id', + async () => { + testFn(); + return data; + }, + config.retention.guild + ); + + expect(testFn).not.toHaveBeenCalled(); + expect(result).toEqual(data); + }); + + it('skips cache when instructed to miss', async () => { + const [config] = configContext(); + + const data = { + foo: 'bar', + baz: 'qux', + }; + const testFn = jest.fn(); + await config.kv.guilds.put('test-guild-id', data, config.retention.guild); + + const run = (skip: boolean) => { + return config.kv.guilds.cacheThrough( + 'test-guild-id', + async () => { + testFn(); + return data; + }, + config.retention.guild, + skip + ); + }; + + await run(true); + await run(true); + await run(false); // use cache this time + + expect(testFn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/api/src/utils/kv.ts b/packages/api/src/utils/kv.ts index aca8d16..5340435 100644 --- a/packages/api/src/utils/kv.ts +++ b/packages/api/src/utils/kv.ts @@ -22,6 +22,29 @@ export class WrappedKVNamespace { }); } + async cacheThrough( + cacheKey: string, + missHandler: () => Promise, + retention?: number, + forceMiss?: boolean + ): Promise { + if (!forceMiss) { + const value = await this.get(cacheKey); + if (value) { + return value; + } + } + + const fallbackValue = await missHandler(); + if (!fallbackValue) { + return null; + } + + await this.put(cacheKey, fallbackValue, retention); + + return fallbackValue; + } + public getRaw: ( ...args: Parameters ) => ReturnType; diff --git a/packages/api/src/utils/request.ts b/packages/api/src/utils/request.ts index 7053bbc..18b6e23 100644 --- a/packages/api/src/utils/request.ts +++ b/packages/api/src/utils/request.ts @@ -1,3 +1,5 @@ +import { Context, RoleypolyMiddleware } from '@roleypoly/api/src/utils/context'; + export const getQuery = (request: Request): { [x: string]: string } => { const output: { [x: string]: string } = {}; @@ -28,3 +30,13 @@ export const formDataRequest = ( body: formData(obj), }; }; + +export const injectParams: RoleypolyMiddleware = ( + request: Request & { params?: Record }, + context: Context +) => { + context.params = { + guildId: request.params?.guildId, + memberId: request.params?.memberId, + }; +}; diff --git a/packages/api/src/utils/response.ts b/packages/api/src/utils/response.ts index 0ecfa6f..bc53e38 100644 --- a/packages/api/src/utils/response.ts +++ b/packages/api/src/utils/response.ts @@ -9,6 +9,7 @@ export const json = (obj: any, init?: ResponseInit): Response => { }); }; +export const noContent = () => new Response(null, { status: 204 }); export const seeOther = (url: string) => new Response( `If you are not redirected soon, click here.`, @@ -21,6 +22,7 @@ export const seeOther = (url: string) => } ); +export const invalid = () => json({ error: 'invalid request' }, { status: 400 }); export const unauthorized = () => json({ error: 'unauthorized' }, { status: 401 }); export const forbidden = () => json({ error: 'forbidden' }, { status: 403 }); export const notFound = () => json({ error: 'not found' }, { status: 404 }); @@ -29,3 +31,8 @@ export const serverError = (error: Error) => { return json({ error: 'internal server error' }, { status: 500 }); }; export const notImplemented = () => json({ error: 'not implemented' }, { status: 501 }); + +// Only used to bully you in particular. +// Maybe make better choices. +export const engineeringProblem = (extra?: string) => + json({ error: 'engineering problem', extra }, { status: 418 }); diff --git a/packages/api/src/utils/testHelpers.ts b/packages/api/src/utils/testHelpers.ts index 571ab13..bf992f5 100644 --- a/packages/api/src/utils/testHelpers.ts +++ b/packages/api/src/utils/testHelpers.ts @@ -1,4 +1,5 @@ -import { Config, Environment } from '@roleypoly/api/src/utils/config'; +import { Config, Environment, parseEnvironment } from '@roleypoly/api/src/utils/config'; +import { Context } from '@roleypoly/api/src/utils/context'; import { getID } from '@roleypoly/api/src/utils/id'; import { SessionData, UserGuildPermissions } from '@roleypoly/types'; import index from '../index'; @@ -57,6 +58,18 @@ export const makeSession = async ( icon: 'test-guild-icon', permissionLevel: UserGuildPermissions.User, }, + { + id: 'test-guild-id-editor', + name: 'test-guild-name', + icon: 'test-guild-icon', + permissionLevel: UserGuildPermissions.Manager, + }, + { + id: 'test-guild-id-admin', + name: 'test-guild-name', + icon: 'test-guild-icon', + permissionLevel: UserGuildPermissions.Manager | UserGuildPermissions.Admin, + }, ], ...data, }; @@ -65,3 +78,24 @@ export const makeSession = async ( return session; }; + +export const configContext = (): [Config, Context] => { + const config = parseEnvironment({ + ...getBindings(), + BOT_CLIENT_SECRET: 'test-client-secret', + BOT_CLIENT_ID: 'test-client-id', + BOT_TOKEN: 'test-bot-token', + }); + const context: Context = { + config, + fetchContext: { + waitUntil: () => {}, + }, + authMode: { + type: 'anonymous', + }, + params: {}, + }; + + return [config, context]; +}; diff --git a/packages/api/test/miniflare.d.ts b/packages/api/test/miniflare.d.ts index 8ed57be..6031827 100644 --- a/packages/api/test/miniflare.d.ts +++ b/packages/api/test/miniflare.d.ts @@ -1,3 +1,5 @@ +import { Environment } from '../src/utils/config'; + declare global { function getMiniflareBindings(): Environment; function getMiniflareDurableObjectStorage( diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index fec6fab..610b77d 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -4,7 +4,7 @@ "target": "esnext", "module": "esnext", "lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"], - "types": ["@cloudflare/workers-types"], + "types": ["@cloudflare/workers-types", "node"], "esModuleInterop": true, "moduleResolution": "node" }, diff --git a/packages/api/tsconfig.test.json b/packages/api/tsconfig.test.json index 5ad7a29..1379b60 100644 --- a/packages/api/tsconfig.test.json +++ b/packages/api/tsconfig.test.json @@ -1,6 +1,6 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "types": ["jest", "@cloudflare/workers-types"] + "types": ["jest", "@cloudflare/workers-types", "node"] } } diff --git a/packages/api/wrangler.toml b/packages/api/wrangler.toml index 8a8751e..855649d 100644 --- a/packages/api/wrangler.toml +++ b/packages/api/wrangler.toml @@ -15,7 +15,7 @@ kv_namespaces = [ ] [build] -command = "yarn build" +command = "yarn build:dev" [build.upload] format = "modules" dir = "dist"