add majority of routes and datapaths, start on interactions

This commit is contained in:
41666 2022-01-29 01:28:29 -05:00
parent bbc0053383
commit 3033ebacb7
47 changed files with 1650 additions and 58 deletions

View file

@ -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"
}
}

View file

@ -0,0 +1,5 @@
it('works', () => {
expect(true).toBeTruthy();
});
export {};

View file

View file

@ -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<GuildData> = {
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');
});
});

View file

@ -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<APIGuild>(
`/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>((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<GuildData> => {
const guildData = await config.kv.guildData.get<GuildData>(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<Member | null> =>
config.kv.guilds.cacheThrough(
`guilds/${serverID}/members/${userID}`,
async () => {
const discordMember = await discordFetch<APIMember>(
`/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;
};

View file

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

View file

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

View file

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

View file

@ -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)) {

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
describe('interactions validations from Discord', () => {
it('', () => {});
});

View file

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

View file

@ -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: '<a:promareFlame:624850108667789333> Something went terribly wrong.',
flags: InteractionFlags.EPHEMERAL,
},
});

View file

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

View file

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

View file

@ -0,0 +1,5 @@
describe('GET /~template', () => {
it('returns Not Implemented when called', () => {
expect(true).toBe(true);
});
});

View file

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

View file

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

View file

@ -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') {

View file

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

View file

@ -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> | Response;
export type RoleypolyMiddleware = (
request: Request,
context: Context
) => Promise<Response | void> | Response | void;

View file

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

View file

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

View file

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

View file

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

View file

@ -22,6 +22,29 @@ export class WrappedKVNamespace {
});
}
async cacheThrough<Data>(
cacheKey: string,
missHandler: () => Promise<Data | null>,
retention?: number,
forceMiss?: boolean
): Promise<Data | null> {
if (!forceMiss) {
const value = await this.get<Data>(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<KVNamespace['get']>
) => ReturnType<KVNamespace['get']>;

View file

@ -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<string, string> },
context: Context
) => {
context.params = {
guildId: request.params?.guildId,
memberId: request.params?.memberId,
};
};

View file

@ -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(
`<!doctype html>If you are not redirected soon, <a href="${url}">click here.</a>`,
@ -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 });

View file

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

View file

@ -1,3 +1,5 @@
import { Environment } from '../src/utils/config';
declare global {
function getMiniflareBindings(): Environment;
function getMiniflareDurableObjectStorage(

View file

@ -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"
},

View file

@ -1,6 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "@cloudflare/workers-types"]
"types": ["jest", "@cloudflare/workers-types", "node"]
}
}

View file

@ -15,7 +15,7 @@ kv_namespaces = [
]
[build]
command = "yarn build"
command = "yarn build:dev"
[build.upload]
format = "modules"
dir = "dist"