big overhaul (#474)

* miniflare init

* feat(api): add tests

* chore: more tests, almost 100%

* add sessions/state spec

* add majority of routes and datapaths, start on interactions

* nevermind, no interactions

* nevermind x2, tweetnacl is bad but SubtleCrypto has what we need apparently

* simplify interactions verify

* add brute force interactions tests

* every primary path API route is refactored!

* automatically import from legacy, or die trying.

* check that we only fetch legacy once, ever

* remove old-src, same some historic pieces

* remove interactions & worker-utils package, update misc/types

* update some packages we don't need specific pinning for anymore

* update web references to API routes since they all changed

* fix all linting issues, upgrade most packages

* fix tests, divorce enzyme where-ever possible

* update web, fix integration issues

* pre-build api

* fix tests

* move api pretest to api package.json instead of CI

* remove interactions from terraform, fix deploy side configs

* update to tf 1.1.4

* prevent double writes to worker in GCS, port to newer GCP auth workflow

* fix api.tf var refs, upgrade node action

* change to curl-based script upload for worker script due to terraform provider limitations

* oh no, cloudflare freaked out :(
This commit is contained in:
41666 2022-01-31 20:35:22 -05:00 committed by GitHub
parent b644a38aa7
commit 3291f9aacc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
183 changed files with 9853 additions and 9924 deletions

View file

@ -0,0 +1,25 @@
import { makeRequest } from '../../utils/testHelpers';
describe('GET /auth/bot', () => {
it('redirects to a Discord OAuth bot flow url', async () => {
const response = await makeRequest('GET', '/auth/bot', undefined, {
BOT_CLIENT_ID: 'test123',
});
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'https://discord.com/api/oauth2/authorize?client_id=test123&scope=bot%20applications.commands&permissions=268435456'
);
});
it('redirects to a Discord OAuth bot flow url, forcing a guild when set', async () => {
const response = await makeRequest('GET', '/auth/bot?guild=123456', undefined, {
BOT_CLIENT_ID: 'test123',
});
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'https://discord.com/api/oauth2/authorize?client_id=test123&scope=bot%20applications.commands&permissions=268435456&guild_id=123456&disable_guild_select=true'
);
});
});

View file

@ -0,0 +1,43 @@
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { seeOther } from '@roleypoly/api/src/utils/response';
const validGuildID = /^[0-9]+$/;
type URLParams = {
clientID: string;
permissions: number;
guildID?: string;
scopes: string[];
};
const buildURL = (params: URLParams) => {
let url = `https://discord.com/api/oauth2/authorize?client_id=${
params.clientID
}&scope=${params.scopes.join('%20')}&permissions=${params.permissions}`;
if (params.guildID) {
url += `&guild_id=${params.guildID}&disable_guild_select=true`;
}
return url;
};
export const authBot: RoleypolyHandler = (
request: Request,
{ config }: Context
): Response => {
let guildID = new URL(request.url).searchParams.get('guild') || '';
if (guildID && !validGuildID.test(guildID)) {
guildID = '';
}
return seeOther(
buildURL({
clientID: config.botClientID,
permissions: 268435456, // Send messages + manage roles
guildID,
scopes: ['bot', 'applications.commands'],
})
);
};

View file

@ -0,0 +1,54 @@
import { StateSession } from '@roleypoly/types';
import { getBindings, makeRequest } from '../../utils/testHelpers';
describe('GET /auth/bounce', () => {
it('should return a redirect to Discord OAuth', async () => {
const response = await makeRequest('GET', '/auth/bounce', undefined, {
BOT_CLIENT_ID: 'test123',
API_PUBLIC_URI: 'http://test.local/',
});
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'https://discord.com/api/oauth2/authorize?client_id=test123&response_type=code&scope=identify%20guilds&prompt=none&redirect_uri=http%3A%2F%2Ftest.local%2Fauth%2Fcallback&state='
);
});
it('should store a state-session', async () => {
const response = await makeRequest('GET', '/auth/bounce', undefined, {
BOT_CLIENT_ID: 'test123',
API_PUBLIC_URI: 'http://test.local/',
});
expect(response.status).toBe(303);
const url = new URL(response.headers.get('Location') || '');
const state = url.searchParams.get('state');
const environment = getBindings();
const session = await environment.KV_SESSIONS.get(`state_${state}`, 'json');
expect(session).not.toBeUndefined();
});
test.each([
['http://web.test.local', 'http://web.test.local', 'http://web.test.local'],
['http://*.test.local', 'http://web.test.local', 'http://web.test.local'],
['http://other.test.local', 'http://web.test.local', undefined],
])(
'should process callback hosts when set to %s',
async (allowlist, input, expected) => {
const response = await makeRequest('GET', `/auth/bounce?cbh=${input}`, undefined, {
BOT_CLIENT_ID: 'test123',
API_PUBLIC_URI: 'http://api.test.local',
ALLOWED_CALLBACK_HOSTS: allowlist,
});
expect(response.status).toBe(303);
const url = new URL(response.headers.get('Location') || '');
const state = url.searchParams.get('state');
const environment = getBindings();
const session = (await environment.KV_SESSIONS.get(`state_${state}`, 'json')) as {
data: StateSession;
};
expect(session).not.toBeUndefined();
expect(session?.data.callbackHost).toBe(expected);
}
);
});

View file

@ -0,0 +1,64 @@
import { setupStateSession } from '@roleypoly/api/src/sessions/state';
import { Config } from '@roleypoly/api/src/utils/config';
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';
type URLParams = {
clientID: string;
redirectURI: string;
state: string;
};
export const buildURL = (params: URLParams) =>
`https://discord.com/api/oauth2/authorize?client_id=${
params.clientID
}&response_type=code&scope=identify%20guilds&prompt=none&redirect_uri=${encodeURIComponent(
params.redirectURI
)}&state=${params.state}`;
const hostMatch = (a: string, b: string): boolean => {
const aURL = new URL(a);
const bURL = new URL(b);
return aURL.host === bURL.host && aURL.protocol === bURL.protocol;
};
const wildcardMatch = (wildcard: string, host: string): boolean => {
const aURL = new URL(wildcard);
const bURL = new URL(host);
const regex = new RegExp(aURL.hostname.replace('*', '[a-z0-9-]+'));
return regex.test(bURL.hostname);
};
export const isAllowedCallbackHost = (config: Config, host: string): boolean => {
return (
hostMatch(host, config.apiPublicURI) ||
config.allowedCallbackHosts.some((allowedHost) =>
allowedHost.includes('*')
? wildcardMatch(allowedHost, host)
: hostMatch(allowedHost, host)
)
);
};
export const authBounce: RoleypolyHandler = async (
request: Request,
{ config }: Context
) => {
const stateSessionData: StateSession = {};
const { cbh: callbackHost } = getQuery(request);
if (callbackHost && isAllowedCallbackHost(config, callbackHost)) {
stateSessionData.callbackHost = callbackHost;
}
const state = await setupStateSession(config, stateSessionData);
const redirectURI = `${config.apiPublicURI}/auth/callback`;
const clientID = config.botClientID;
return seeOther(buildURL({ state, redirectURI, clientID }));
};

View file

@ -0,0 +1,93 @@
jest.mock('../../utils/discord');
jest.mock('../../sessions/create');
import { createSession } from '../../sessions/create';
import { setupStateSession } from '../../sessions/state';
import { parseEnvironment } from '../../utils/config';
import { discordFetch } from '../../utils/discord';
import { getBindings, makeRequest } from '../../utils/testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
const mockCreateSession = createSession as jest.Mock;
describe('GET /auth/callback', () => {
it('should ask Discord to trade code for tokens', async () => {
const env = getBindings();
const config = parseEnvironment(env);
const stateID = await setupStateSession(config, {});
const tokens = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expires_in: 3600,
scope: 'identify guilds',
token_type: 'Bearer',
};
mockDiscordFetch.mockReturnValueOnce(tokens);
mockCreateSession.mockReturnValueOnce({
sessionID: 'test-session-id',
tokens,
user: {
id: 'test-user-id',
username: 'test-username',
discriminator: 'test-discriminator',
avatar: 'test-avatar',
bot: false,
},
guilds: [],
});
const response = await makeRequest(
'GET',
`/auth/callback?state=${stateID}&code=1234`,
undefined,
{
BOT_CLIENT_ID: 'test123',
BOT_CLIENT_SECRET: 'test456',
API_PUBLIC_URI: 'http://test.local/',
UI_PUBLIC_URI: 'http://web.test.local/',
}
);
expect(response.status).toBe(303);
expect(mockDiscordFetch).toBeCalledTimes(1);
expect(mockCreateSession).toBeCalledWith(expect.any(Object), tokens);
expect(response.headers.get('Location')).toContain(
'http://web.test.local/machinery/new-session/test-session-id'
);
});
it('will fail if state is invalid', async () => {
const response = await makeRequest(
'GET',
`/auth/callback?state=invalid-state&code=1234`,
undefined,
{
BOT_CLIENT_ID: 'test123',
BOT_CLIENT_SECRET: 'test456',
API_PUBLIC_URI: 'http://test.local/',
UI_PUBLIC_URI: 'http://web.test.local/',
}
);
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'http://web.test.local/machinery/error?error_code=authFailure&extra=state invalid'
);
});
it('will fail if state is missing', async () => {
const response = await makeRequest('GET', `/auth/callback?code=1234`, undefined, {
BOT_CLIENT_ID: 'test123',
BOT_CLIENT_SECRET: 'test456',
API_PUBLIC_URI: 'http://test.local/',
UI_PUBLIC_URI: 'http://web.test.local/',
});
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'http://web.test.local/machinery/error?error_code=authFailure&extra=state invalid'
);
});
});

View file

@ -0,0 +1,76 @@
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, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
import { dateFromID } from '@roleypoly/api/src/utils/id';
import { formDataRequest, getQuery } from '@roleypoly/api/src/utils/request';
import { seeOther } from '@roleypoly/api/src/utils/response';
import { AuthTokenResponse, StateSession } from '@roleypoly/types';
const authFailure = (uiPublicURI: string, extra?: string) =>
seeOther(
uiPublicURI +
`/machinery/error?error_code=authFailure${extra ? `&extra=${extra}` : ''}`
);
export const authCallback: RoleypolyHandler = async (
request: Request,
{ config }: Context
) => {
let bounceBaseUrl = config.uiPublicURI;
const { state: stateValue, code } = getQuery(request);
if (stateValue === null) {
return authFailure('state missing');
}
try {
const stateTime = dateFromID(stateValue);
const stateExpiry = stateTime + 1000 * config.retention.session;
const currentTime = Date.now();
if (currentTime > stateExpiry) {
return authFailure('state expired');
}
const stateSession = await getStateSession<StateSession>(config, stateValue);
if (
stateSession?.callbackHost &&
isAllowedCallbackHost(config, stateSession.callbackHost)
) {
bounceBaseUrl = stateSession.callbackHost;
}
} catch (e) {
return authFailure(config.uiPublicURI, 'state invalid');
}
if (!code) {
return authFailure(config.uiPublicURI, 'code missing');
}
const response = await discordFetch<AuthTokenResponse>(
`/oauth2/token`,
'',
AuthType.None,
formDataRequest({
client_id: config.botClientID,
client_secret: config.botClientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: config.apiPublicURI + '/auth/callback',
})
);
if (!response) {
return authFailure(config.uiPublicURI, 'code auth failure');
}
const session = await createSession(config, response);
if (!session) {
return authFailure(config.uiPublicURI, 'session setup failure');
}
return seeOther(bounceBaseUrl + '/machinery/new-session/' + session.sessionID);
};

View file

@ -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,
})
)
);
});
});

View file

@ -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();
};

View file

@ -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);
});
});

View file

@ -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();
};

View file

@ -0,0 +1,32 @@
jest.mock('../../guilds/getters');
import { UserGuildPermissions } from '@roleypoly/types';
import { getGuild } from '../../guilds/getters';
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
const mockGetGuild = getGuild as jest.Mock;
describe('DELETE /guilds/:id/cache', () => {
it('calls getGuilds and returns No Content', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: UserGuildPermissions.Admin,
},
],
});
const response = await makeRequest('DELETE', `/guilds/123/cache`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(204);
expect(mockGetGuild).toHaveBeenCalledWith(expect.any(Object), '123', true);
});
});

View file

@ -0,0 +1,12 @@
import { getGuild } from '@roleypoly/api/src/guilds/getters';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { noContent } from '@roleypoly/api/src/utils/response';
export const guildsCacheDelete: RoleypolyHandler = async (
request: Request,
context: Context
) => {
await getGuild(context.config, context.params.guildId!, true);
return noContent();
};

View file

@ -0,0 +1,370 @@
jest.mock('../../guilds/getters');
jest.mock('../../utils/discord');
import {
CategoryType,
Features,
Guild,
GuildData,
Member,
OwnRoleInfo,
RoleSafety,
RoleUpdate,
TransactionType,
} from '@roleypoly/types';
import { getGuild, getGuildData, getGuildMember } from '../../guilds/getters';
import { AuthType, discordFetch } from '../../utils/discord';
import { json } from '../../utils/response';
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
const mockGetGuild = getGuild as jest.Mock;
const mockGetGuildMember = getGuildMember as jest.Mock;
const mockGetGuildData = getGuildData as jest.Mock;
beforeEach(() => {
jest.resetAllMocks();
doMock();
});
describe('PUT /guilds/:id/roles', () => {
it('adds member roles when called with valid roles', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [{ id: 'role-2', action: TransactionType.Add }],
};
mockDiscordFetch.mockReturnValueOnce(
json({
roles: ['role-1', 'role-2'],
})
);
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(200);
expect(mockDiscordFetch).toHaveBeenCalledWith(
`/guilds/123/members/${session.user.id}`,
'test',
AuthType.Bot,
{
body: JSON.stringify({
roles: ['role-1', 'role-2'],
}),
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
},
method: 'PATCH',
}
);
});
it('removes member roles when called with valid roles', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [{ id: 'role-1', action: TransactionType.Remove }],
};
mockDiscordFetch.mockReturnValueOnce(
json({
roles: [],
})
);
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(200);
expect(mockDiscordFetch).toHaveBeenCalledWith(
`/guilds/123/members/${session.user.id}`,
'test',
AuthType.Bot,
{
body: JSON.stringify({
roles: [],
}),
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
},
method: 'PATCH',
}
);
});
it('does not update roles when called only with invalid roles', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [
{ id: 'role-3', action: TransactionType.Add }, // role is in a hidden category
{ id: 'role-5-unsafe', action: TransactionType.Add }, // role is marked unsafe
],
};
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(400);
expect(mockDiscordFetch).not.toHaveBeenCalled();
});
it('filters roles that are invalid while accepting ones that are valid', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [
{ id: 'role-3', action: TransactionType.Add }, // role is in a hidden category
{ id: 'role-2', action: TransactionType.Add }, // role is in a hidden category
],
};
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(200);
expect(mockDiscordFetch).toHaveBeenCalledWith(
`/guilds/123/members/${session.user.id}`,
'test',
AuthType.Bot,
{
body: JSON.stringify({
roles: ['role-1', 'role-2'],
}),
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
},
method: 'PATCH',
}
);
});
it('400s when no transactions are present', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [],
};
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(400);
expect(mockDiscordFetch).not.toHaveBeenCalled();
expect(mockGetGuild).not.toHaveBeenCalled();
expect(mockGetGuildData).not.toHaveBeenCalled();
expect(mockGetGuildMember).not.toHaveBeenCalled();
});
});
const doMock = () => {
const guild: Guild & OwnRoleInfo = {
id: '123',
name: 'test',
icon: 'test',
highestRolePosition: 0,
roles: [
{
id: 'role-1',
name: 'Role 1',
color: 0,
position: 17,
permissions: '',
managed: false,
safety: RoleSafety.Safe,
},
{
id: 'role-2',
name: 'Role 2',
color: 0,
position: 16,
permissions: '',
managed: false,
safety: RoleSafety.Safe,
},
{
id: 'role-3',
name: 'Role 3',
color: 0,
position: 15,
permissions: '',
managed: false,
safety: RoleSafety.Safe,
},
{
id: 'role-4',
name: 'Role 4',
color: 0,
position: 14,
permissions: '',
managed: false,
safety: RoleSafety.Safe,
},
{
id: 'role-5-unsafe',
name: 'Role 5 (Unsafe)',
color: 0,
position: 14,
permissions: '',
managed: false,
safety: RoleSafety.DangerousPermissions,
},
],
};
const member: Member = {
roles: ['role-1'],
pending: false,
nick: '',
};
const guildData: GuildData = {
id: '123',
message: 'test',
categories: [
{
id: 'category-1',
name: 'Category 1',
position: 0,
hidden: false,
type: CategoryType.Multi,
roles: ['role-1', 'role-2'],
},
{
id: 'category-2',
name: 'Category 2',
position: 1,
hidden: true,
type: CategoryType.Multi,
roles: ['role-3'],
},
],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: false,
},
};
mockGetGuild.mockReturnValue(guild);
mockGetGuildMember.mockReturnValue(member);
mockGetGuildData.mockReturnValue(guildData);
mockDiscordFetch.mockReturnValue(json({}));
};

View file

@ -0,0 +1,160 @@
import {
getGuild,
getGuildData,
getGuildMember,
updateGuildMember,
} from '@roleypoly/api/src/guilds/getters';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { APIMember, AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
import {
engineeringProblem,
invalid,
json,
notFound,
serverError,
} from '@roleypoly/api/src/utils/response';
import {
difference,
isIdenticalArray,
keyBy,
union,
} from '@roleypoly/misc-utils/collection-tools';
import {
GuildData,
Member,
Role,
RoleSafety,
RoleTransaction,
RoleUpdate,
TransactionType,
} from '@roleypoly/types';
export const guildsRolesPut: RoleypolyHandler = async (
request: Request,
context: Context
) => {
if (!request.body) {
return invalid();
}
const updateRequest: RoleUpdate = await request.json();
if (updateRequest.transactions.length === 0) {
return invalid();
}
const guildID = context.params.guildId;
if (!guildID) {
return engineeringProblem('params not set up correctly');
}
const userID = context.session!.user.id;
const [member, guildData, guild] = await Promise.all([
getGuildMember(context.config, guildID, userID),
getGuildData(context.config, guildID),
getGuild(context.config, guildID),
]);
if (!guild || !member) {
return notFound();
}
const newRoles = calculateNewRoles({
currentRoles: member.roles,
guildRoles: guild.roles,
guildData,
updateRequest,
});
if (
isIdenticalArray(member.roles, newRoles) ||
isIdenticalArray(updateRequest.knownState, newRoles)
) {
return invalid();
}
const patchMemberRoles = await discordFetch<APIMember>(
`/guilds/${guildID}/members/${userID}`,
context.config.botToken,
AuthType.Bot,
{
method: 'PATCH',
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via ${context.config.uiPublicURI}`,
},
body: JSON.stringify({
roles: newRoles,
}),
}
);
if (!patchMemberRoles) {
return serverError(new Error('discord rejected the request'));
}
context.fetchContext.waitUntil(
updateGuildMember(context.config, guildID, patchMemberRoles)
);
const updatedMember: Member = {
roles: patchMemberRoles.roles,
};
return json(updatedMember);
};
export const calculateNewRoles = ({
currentRoles,
guildData,
guildRoles,
updateRequest,
}: {
currentRoles: string[];
guildRoles: Role[];
guildData: GuildData;
updateRequest: RoleUpdate;
}): string[] => {
const roleMap = keyBy(guildRoles, 'id');
// These roles were ones changed between knownState (role picker page load/cache) and current (fresh from discord).
// We could cause issues, so we'll re-add them later.
// const diffRoles = difference(updateRequest.knownState, currentRoles);
// Only these are safe
const allSafeRoles = guildData.categories.reduce<string[]>(
(categorizedRoles, category) =>
!category.hidden
? [
...categorizedRoles,
...category.roles.filter(
(roleID) => roleMap[roleID]?.safety === RoleSafety.Safe
),
]
: categorizedRoles,
[]
);
const safeTransactions = updateRequest.transactions.filter((tx: RoleTransaction) =>
allSafeRoles.includes(tx.id)
);
const changesByAction = safeTransactions.reduce<
Record<TransactionType, RoleTransaction[]>
>((group, value, _1, _2, key = value.action) => (group[key].push(value), group), {
[TransactionType.Add]: [],
[TransactionType.Remove]: [],
});
const rolesToAdd = (changesByAction[TransactionType.Add] ?? []).map(
(tx: RoleTransaction) => tx.id
);
const rolesToRemove = (changesByAction[TransactionType.Remove] ?? []).map(
(tx: RoleTransaction) => tx.id
);
const final = union(difference(currentRoles, rolesToRemove), rolesToAdd);
return final;
};

View file

@ -0,0 +1,164 @@
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: '',
user: {
id: 'user-1',
},
};
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);
});
});

View file

@ -0,0 +1,45 @@
import {
getGuild,
getGuildData,
getGuildMember,
} from '@roleypoly/api/src/guilds/getters';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { getQuery } from '@roleypoly/api/src/utils/request';
import { json, notFound } from '@roleypoly/api/src/utils/response';
import { PresentableGuild } from '@roleypoly/types';
export const guildsGuild: RoleypolyHandler = async (
request: Request,
context: Context
) => {
const { noCache } = getQuery(request);
const guild = await getGuild(context.config, context.params!.guildId!, !!noCache);
if (!guild) {
return notFound();
}
const member = await getGuildMember(
context.config,
context.params!.guildId!,
context.session!.user.id,
!!noCache
);
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);
};

View file

@ -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);
});
});

View file

@ -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);
};

View file

@ -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/:id/slug', () => {
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/${guild.id}/slug`);
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);
});
});

View file

@ -0,0 +1,31 @@
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 guildInSession = context.session?.guilds.find((guild) => guild.id === id);
if (guildInSession) {
return json<GuildSlug>(guildInSession);
}
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<GuildSlug>(slug);
};

View file

@ -0,0 +1,60 @@
jest.mock('../../../utils/discord');
import { discordFetch } from '../../../utils/discord';
import { configContext } from '../../../utils/testHelpers';
import {
extractInteractionResponse,
isDeferred,
isEphemeral,
makeInteractionsRequest,
mockUpdateCall,
} from '../testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
it('responds with the username when member.nick is missing', async () => {
const [, context] = configContext();
const response = await makeInteractionsRequest(
context,
{
name: 'hello-world',
},
false,
{
member: {
nick: undefined,
roles: [],
},
}
);
expect(response.status).toBe(200);
const interaction = await extractInteractionResponse(response);
expect(isDeferred(interaction)).toBe(true);
expect(isEphemeral(interaction)).toBe(true);
expect(mockDiscordFetch).toBeCalledWith(
...mockUpdateCall(expect, {
content: 'Hey there, test-user',
})
);
});
it('responds with the nickname when member.nick is set', async () => {
const [, context] = configContext();
const response = await makeInteractionsRequest(context, {
name: 'hello-world',
});
expect(response.status).toBe(200);
const interaction = await extractInteractionResponse(response);
expect(isDeferred(interaction)).toBe(true);
expect(isEphemeral(interaction)).toBe(true);
expect(mockDiscordFetch).toBeCalledWith(
...mockUpdateCall(expect, {
content: 'Hey there, test-user-nick',
})
);
});

View file

@ -0,0 +1,22 @@
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
import { Context } from '@roleypoly/api/src/utils/context';
import {
InteractionCallbackType,
InteractionRequest,
InteractionResponse,
} from '@roleypoly/types';
export const helloWorld: InteractionHandler = (
interaction: InteractionRequest,
context: Context
): InteractionResponse => {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `Hey there, ${interaction.member?.nick || interaction.user?.username}`,
},
};
};
helloWorld.ephemeral = true;
helloWorld.deferred = true;

View file

@ -0,0 +1,131 @@
import { InteractionRequest, InteractionType } from '@roleypoly/types';
import nacl from 'tweetnacl';
import { configContext } from '../../utils/testHelpers';
import { verifyRequest } from './helpers';
//
// Q: Why tweetnacl when WebCrypto is available?
// A: Discord uses tweetnacl on their end, thus is also
// used in far more examples of Discord Interactions than WebCrypto.
// We don't actually use it in Workers, as SubtleCrypto using NODE-ED25519
// is better in every way, and still gives us the same effect.
//
describe('verifyRequest', () => {
it('validates a successful Discord interactions request', async () => {
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(await verifyRequest(context.config, request, body)).toBe(true);
});
it('fails to validate a headerless Discord interactions request', async () => {
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(await verifyRequest(context.config, request, body)).toBe(false);
});
it('fails to validate a bad signature from Discord', async () => {
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(await verifyRequest(context.config, request, body)).toBe(false);
});
it('fails to validate when signature differs from data', async () => {
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(await verifyRequest(context.config, request, body)).toBe(false);
});
});

View file

@ -0,0 +1,94 @@
import { Config } from '@roleypoly/api/src/utils/config';
import { Context } from '@roleypoly/api/src/utils/context';
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
import {
InteractionCallbackType,
InteractionFlags,
InteractionRequest,
InteractionResponse,
} from '@roleypoly/types';
export const verifyRequest = async (
config: Config,
request: Request,
interaction: InteractionRequest
): Promise<boolean> => {
const timestamp = request.headers.get('x-signature-timestamp');
const signature = request.headers.get('x-signature-ed25519');
if (!timestamp || !signature) {
return false;
}
const key = await crypto.subtle.importKey(
'raw',
Buffer.from(config.publicKey, 'hex'),
{ name: 'NODE-ED25519', namedCurve: 'NODE-ED25519', public: true } as any,
false,
['verify']
);
return crypto.subtle.verify(
'NODE-ED25519',
key,
Buffer.from(signature, 'hex'),
Buffer.from(timestamp + JSON.stringify(interaction))
);
};
export type InteractionHandler = ((
interaction: InteractionRequest,
context: Context
) => Promise<InteractionResponse> | InteractionResponse) & {
ephemeral?: boolean;
deferred?: boolean;
};
export const runAsync = async (
handler: InteractionHandler,
interaction: InteractionRequest,
context: Context
): Promise<void> => {
const url = `/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`;
try {
const response = await handler(interaction, context);
await discordFetch(url, '', AuthType.None, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: InteractionCallbackType.DEFERRED_UPDATE_MESSAGE,
data: {
flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0,
...response.data,
},
}),
});
} catch (e) {
console.error('/interations runAsync failed', {
e,
interaction: {
data: interaction.data,
user: interaction.user,
guild: interaction.guild_id,
},
});
try {
await discordFetch(url, '', AuthType.None, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: InteractionCallbackType.DEFERRED_UPDATE_MESSAGE,
data: {
content: "I'm sorry, I'm having trouble processing this request.",
flags: InteractionFlags.EPHEMERAL,
},
} as InteractionResponse),
});
} catch (e) {}
}
};

View file

@ -0,0 +1,49 @@
jest.mock('../../utils/discord');
import { InteractionCallbackType, InteractionFlags } from '@roleypoly/types';
import { AuthType, discordFetch } from '../../utils/discord';
import { configContext } from '../../utils/testHelpers';
import { extractInteractionResponse, makeInteractionsRequest } from './testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
it('responds with a simple hello-world!', async () => {
const [config, context] = configContext();
const response = await makeInteractionsRequest(context, {
name: 'hello-world',
});
expect(response.status).toBe(200);
const interaction = await extractInteractionResponse(response);
expect(interaction.type).toEqual(
InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
);
expect(interaction.data).toEqual({
flags: InteractionFlags.EPHEMERAL,
});
expect(mockDiscordFetch).toBeCalledWith(expect.any(String), '', AuthType.None, {
body: JSON.stringify({
type: InteractionCallbackType.DEFERRED_UPDATE_MESSAGE,
data: {
flags: InteractionFlags.EPHEMERAL,
content: 'Hey there, test-user-nick',
},
}),
headers: expect.any(Object),
method: 'PATCH',
});
});
it('does not allow requests that are invalid', async () => {
const [config, context] = configContext();
const response = await makeInteractionsRequest(
context,
{
name: 'hello-world',
},
true
);
expect(response.status).toBe(401);
});

View file

@ -0,0 +1,81 @@
import { helloWorld } from '@roleypoly/api/src/routes/interactions/commands/hello-world';
import {
InteractionHandler,
runAsync,
verifyRequest,
} from '@roleypoly/api/src/routes/interactions/helpers';
import { notImplemented } from '@roleypoly/api/src/routes/interactions/responses';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { invalid, json } from '@roleypoly/api/src/utils/response';
import {
InteractionCallbackType,
InteractionData,
InteractionFlags,
InteractionRequest,
InteractionResponse,
InteractionType,
} from '@roleypoly/types';
const commands: Record<InteractionData['name'], InteractionHandler> = {
'hello-world': helloWorld,
};
export const handleInteraction: RoleypolyHandler = async (
request: Request,
context: Context
) => {
const interaction: InteractionRequest = await request.json();
if (!interaction) {
return invalid();
}
if (!(await verifyRequest(context.config, request, interaction))) {
return new Response('invalid request signature', { status: 401 });
}
if (interaction.type !== InteractionType.APPLICATION_COMMAND) {
if (interaction.type === InteractionType.PING) {
return json({ type: InteractionCallbackType.PONG });
}
return json({ err: 'not implemented' }, { status: 400 });
}
if (!interaction.data) {
return json({ err: 'data missing' }, { status: 400 });
}
const handler = commands[interaction.data.name] || notImplemented;
try {
if (handler.deferred) {
context.fetchContext.waitUntil(runAsync(handler, interaction, context));
return json({
type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
data: {
flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0,
},
} as InteractionResponse);
}
const response = await handler(interaction, context);
return json({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0,
...response.data,
},
});
} catch (e) {
console.error('/interactions error:', {
interaction: {
data: interaction.data,
user: interaction.user,
guild: interaction.guild_id,
},
e,
});
return invalid();
}
};

View file

@ -0,0 +1,38 @@
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
import {
InteractionCallbackType,
InteractionFlags,
InteractionResponse,
} from '@roleypoly/types';
export const mustBeInGuild: InteractionHandler = (): 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: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: ':x: You filled that command out wrong...',
flags: InteractionFlags.EPHEMERAL,
},
});
export const somethingWentWrong: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: '<a:promareFlame:624850108667789333> Something went terribly wrong.',
flags: InteractionFlags.EPHEMERAL,
},
});
export const notImplemented: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: ':x: This command is not implemented yet.',
flags: InteractionFlags.EPHEMERAL,
},
});

View file

@ -0,0 +1,134 @@
import { handleInteraction } from '@roleypoly/api/src/routes/interactions/interactions';
import { Context } from '@roleypoly/api/src/utils/context';
import { AuthType } from '@roleypoly/api/src/utils/discord';
import { getID } from '@roleypoly/api/src/utils/id';
import {
InteractionCallbackData,
InteractionCallbackType,
InteractionData,
InteractionFlags,
InteractionRequest,
InteractionResponse,
InteractionType,
} from '@roleypoly/types';
import nacl from 'tweetnacl';
const { publicKey, secretKey } = nacl.sign.keyPair();
const hexPublicKey = Buffer.from(publicKey).toString('hex');
export const getSignatureHeaders = (
context: Context,
interaction: InteractionRequest
): {
'x-signature-ed25519': string;
'x-signature-timestamp': string;
} => {
const timestamp = Date.now().toString();
const body = JSON.stringify(interaction);
const signature = nacl.sign.detached(Buffer.from(timestamp + body), secretKey);
return {
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
'x-signature-timestamp': timestamp,
};
};
export const makeInteractionsRequest = async (
context: Context,
interactionData: Partial<InteractionData>,
forceInvalid?: boolean,
topLevelMixin?: Partial<InteractionRequest>
): Promise<Response> => {
context.config.publicKey = hexPublicKey;
const interaction: InteractionRequest = {
data: {
id: getID(),
name: 'hello-world',
...interactionData,
} as InteractionData,
id: '123',
type: InteractionType.APPLICATION_COMMAND,
application_id: context.config.botClientID,
token: getID(),
version: 1,
user: {
id: '123',
username: 'test-user',
discriminator: '1234',
bot: false,
avatar: '',
},
member: {
nick: 'test-user-nick',
roles: [],
},
...topLevelMixin,
};
const request = new Request('http://localhost:3000/interactions', {
method: 'POST',
headers: {
'content-type': 'application/json',
...getSignatureHeaders(context, {
...interaction,
...(forceInvalid ? { id: 'invalid-id' } : {}),
}),
},
body: JSON.stringify(interaction),
});
return handleInteraction(request, context);
};
export const extractInteractionResponse = async (
response: Response
): Promise<InteractionResponse> => {
const body = await response.json();
return body as InteractionResponse;
};
export const isDeferred = (response: InteractionResponse): boolean => {
return response.type === InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE;
};
export const isEphemeral = (response: InteractionResponse): boolean => {
return (
(response.data?.flags || 0 & InteractionFlags.EPHEMERAL) ===
InteractionFlags.EPHEMERAL
);
};
export const interactionData = (
response: InteractionResponse
): Omit<InteractionCallbackData, 'flags'> | undefined => {
const { data } = response;
if (!data) return undefined;
delete data.flags;
return response.data;
};
export const mockUpdateCall = (
expect: any,
data: Omit<InteractionCallbackData, 'flags'>
) => {
return [
expect.any(String),
'',
AuthType.None,
{
body: JSON.stringify({
type: InteractionCallbackType.DEFERRED_UPDATE_MESSAGE,
data: {
flags: InteractionFlags.EPHEMERAL,
...data,
},
}),
headers: {
'Content-Type': 'application/json',
},
method: 'PATCH',
},
];
};