mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-24 19:39:11 +00:00
add majority of routes and datapaths, start on interactions
This commit is contained in:
parent
bbc0053383
commit
3033ebacb7
47 changed files with 1650 additions and 58 deletions
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
5
packages/api/src/guilds/audit-logging.spec.ts
Normal file
5
packages/api/src/guilds/audit-logging.spec.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
it('works', () => {
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
export {};
|
0
packages/api/src/guilds/audit-logging.ts
Normal file
0
packages/api/src/guilds/audit-logging.ts
Normal file
130
packages/api/src/guilds/getters.spec.ts
Normal file
130
packages/api/src/guilds/getters.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
149
packages/api/src/guilds/getters.ts
Normal file
149
packages/api/src/guilds/getters.ts
Normal 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;
|
||||
};
|
78
packages/api/src/guilds/middleware.spec.ts
Normal file
78
packages/api/src/guilds/middleware.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
47
packages/api/src/guilds/middleware.ts
Normal file
47
packages/api/src/guilds/middleware.ts
Normal 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
|
||||
}
|
||||
};
|
|
@ -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)));
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
63
packages/api/src/routes/auth/delete-session.spec.ts
Normal file
63
packages/api/src/routes/auth/delete-session.spec.ts
Normal 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,
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
27
packages/api/src/routes/auth/delete-session.ts
Normal file
27
packages/api/src/routes/auth/delete-session.ts
Normal 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();
|
||||
};
|
53
packages/api/src/routes/auth/session.spec.ts
Normal file
53
packages/api/src/routes/auth/session.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
17
packages/api/src/routes/auth/session.ts
Normal file
17
packages/api/src/routes/auth/session.ts
Normal 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();
|
||||
};
|
161
packages/api/src/routes/guilds/guild.spec.ts
Normal file
161
packages/api/src/routes/guilds/guild.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
42
packages/api/src/routes/guilds/guild.ts
Normal file
42
packages/api/src/routes/guilds/guild.ts
Normal 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);
|
||||
};
|
164
packages/api/src/routes/guilds/guilds-patch.spec.ts
Normal file
164
packages/api/src/routes/guilds/guilds-patch.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
37
packages/api/src/routes/guilds/guilds-patch.ts
Normal file
37
packages/api/src/routes/guilds/guilds-patch.ts
Normal 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);
|
||||
};
|
52
packages/api/src/routes/guilds/slug.spec.ts
Normal file
52
packages/api/src/routes/guilds/slug.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
23
packages/api/src/routes/guilds/slug.ts
Normal file
23
packages/api/src/routes/guilds/slug.ts
Normal 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);
|
||||
};
|
123
packages/api/src/routes/interactions/helpers.spec.ts
Normal file
123
packages/api/src/routes/interactions/helpers.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
28
packages/api/src/routes/interactions/helpers.ts
Normal file
28
packages/api/src/routes/interactions/helpers.ts
Normal 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;
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
describe('interactions validations from Discord', () => {
|
||||
it('', () => {});
|
||||
});
|
28
packages/api/src/routes/interactions/interactions.ts
Normal file
28
packages/api/src/routes/interactions/interactions.ts
Normal 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({});
|
||||
};
|
29
packages/api/src/routes/interactions/responses.ts
Normal file
29
packages/api/src/routes/interactions/responses.ts
Normal 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,
|
||||
},
|
||||
});
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
5
packages/api/src/routes/~template/template.spec.ts
Normal file
5
packages/api/src/routes/~template/template.spec.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
describe('GET /~template', () => {
|
||||
it('returns Not Implemented when called', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
6
packages/api/src/routes/~template/template.ts
Normal file
6
packages/api/src/routes/~template/template.ts
Normal 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();
|
||||
};
|
|
@ -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();
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
34
packages/api/src/utils/discord.spec.ts
Normal file
34
packages/api/src/utils/discord.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
91
packages/api/src/utils/kv.spec.ts
Normal file
91
packages/api/src/utils/kv.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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']>;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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];
|
||||
};
|
||||
|
|
2
packages/api/test/miniflare.d.ts
vendored
2
packages/api/test/miniflare.d.ts
vendored
|
@ -1,3 +1,5 @@
|
|||
import { Environment } from '../src/utils/config';
|
||||
|
||||
declare global {
|
||||
function getMiniflareBindings(): Environment;
|
||||
function getMiniflareDurableObjectStorage(
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "@cloudflare/workers-types"]
|
||||
"types": ["jest", "@cloudflare/workers-types", "node"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ kv_namespaces = [
|
|||
]
|
||||
|
||||
[build]
|
||||
command = "yarn build"
|
||||
command = "yarn build:dev"
|
||||
[build.upload]
|
||||
format = "modules"
|
||||
dir = "dist"
|
||||
|
|
Loading…
Add table
Reference in a new issue