mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-24 19:39:11 +00:00
feat(api): add tests
This commit is contained in:
parent
688954a2e0
commit
480987aa90
26 changed files with 541 additions and 91 deletions
16
packages/api/jest.config.js
Normal file
16
packages/api/jest.config.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
module.exports = {
|
||||
preset: 'ts-jest/presets/default-esm',
|
||||
name: 'api',
|
||||
rootDir: '../../',
|
||||
testEnvironment: 'miniflare',
|
||||
testEnvironmentOptions: {
|
||||
wranglerConfigPath: 'packages/api/wrangler.toml',
|
||||
envPath: '.env',
|
||||
},
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/packages/api/tsconfig.test.json',
|
||||
useESM: true,
|
||||
},
|
||||
},
|
||||
};
|
|
@ -9,20 +9,18 @@
|
|||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^2.2.2",
|
||||
"@cloudflare/workers-types": "^3.3.1",
|
||||
"@roleypoly/misc-utils": "*",
|
||||
"@roleypoly/types": "*",
|
||||
"@roleypoly/worker-emulator": "*",
|
||||
"@roleypoly/worker-utils": "*",
|
||||
"@types/deep-equal": "^1.0.1",
|
||||
"deep-equal": "^2.0.5",
|
||||
"esbuild": "^0.14.13",
|
||||
"exponential-backoff": "^3.1.0",
|
||||
"esbuild": "^0.14.14",
|
||||
"itty-router": "^2.4.10",
|
||||
"ksuid": "^2.0.0",
|
||||
"jest-environment-miniflare": "^2.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"miniflare": "^2.2.0",
|
||||
"ts-loader": "^8.3.0",
|
||||
"ts-jest": "^27.1.3",
|
||||
"ulid-workers": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { WrappedKVNamespace } from '@roleypoly/api/src/kv';
|
||||
import { WrappedKVNamespace } from './kv';
|
||||
|
||||
export type Environment = {
|
||||
BOT_CLIENT_ID: string;
|
||||
|
@ -35,6 +35,10 @@ export type Config = {
|
|||
guilds: WrappedKVNamespace;
|
||||
guildData: WrappedKVNamespace;
|
||||
};
|
||||
retention: {
|
||||
session: number;
|
||||
sessionState: number;
|
||||
};
|
||||
_raw: Environment;
|
||||
};
|
||||
|
||||
|
@ -60,5 +64,9 @@ export const parseEnvironment = (env: Environment): Config => {
|
|||
guilds: new WrappedKVNamespace(env.KV_GUILDS),
|
||||
guildData: new WrappedKVNamespace(env.KV_GUILD_DATA),
|
||||
},
|
||||
retention: {
|
||||
session: 60 * 60 * 6, // 6 hours
|
||||
sessionState: 60 * 5, // 5 minutes
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,12 +1,20 @@
|
|||
// @ts-ignore
|
||||
import { authBounce } from '@roleypoly/api/src/routes/auth/bounce';
|
||||
import { json, notFound } from '@roleypoly/api/src/utils/response';
|
||||
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 { Router } from 'itty-router';
|
||||
import { Config, Environment, parseEnvironment } from './config';
|
||||
import { authBounce } from './routes/auth/bounce';
|
||||
import { Context } from './utils/context';
|
||||
import { json, notFound } from './utils/response';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.all('*', withAuthMode);
|
||||
|
||||
router.get('/auth/bot', authBot);
|
||||
router.get('/auth/bounce', authBounce);
|
||||
router.get('/auth/callback', authCallback);
|
||||
|
||||
router.get('/', (request: Request, config: Config) =>
|
||||
json({
|
||||
|
@ -25,8 +33,17 @@ router.get('/', (request: Request, config: Config) =>
|
|||
router.get('*', () => notFound());
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Environment, ctx: FetchEvent) {
|
||||
async fetch(request: Request, env: Environment, event: Context['fetchContext']) {
|
||||
const config = parseEnvironment(env);
|
||||
return router.handle(request, config, ctx);
|
||||
const context: Context = {
|
||||
config,
|
||||
fetchContext: {
|
||||
waitUntil: event.waitUntil,
|
||||
},
|
||||
authMode: {
|
||||
type: 'anonymous',
|
||||
},
|
||||
};
|
||||
return router.handle(request, context);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
export class WrappedKVNamespace {
|
||||
constructor(private kvNamespace: KVNamespace) {}
|
||||
constructor(private kvNamespace: KVNamespace) {
|
||||
this.getRaw = kvNamespace.get.bind(kvNamespace);
|
||||
this.putRaw = kvNamespace.put.bind(kvNamespace);
|
||||
this.delete = kvNamespace.delete.bind(kvNamespace);
|
||||
this.list = kvNamespace.list.bind(kvNamespace);
|
||||
this.getWithMetadata = kvNamespace.getWithMetadata.bind(kvNamespace);
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const data = await this.kvNamespace.get(key, 'text');
|
||||
|
@ -16,8 +22,19 @@ export class WrappedKVNamespace {
|
|||
});
|
||||
}
|
||||
|
||||
getRaw = this.kvNamespace.get;
|
||||
list = this.kvNamespace.list;
|
||||
getWithMetadata = this.kvNamespace.getWithMetadata;
|
||||
delete = this.kvNamespace.delete;
|
||||
public getRaw: (
|
||||
...args: Parameters<KVNamespace['get']>
|
||||
) => ReturnType<KVNamespace['get']>;
|
||||
public putRaw: (
|
||||
...args: Parameters<KVNamespace['put']>
|
||||
) => ReturnType<KVNamespace['put']>;
|
||||
public list: (
|
||||
...args: Parameters<KVNamespace['list']>
|
||||
) => ReturnType<KVNamespace['list']>;
|
||||
public getWithMetadata: (
|
||||
...args: Parameters<KVNamespace['getWithMetadata']>
|
||||
) => ReturnType<KVNamespace['getWithMetadata']>;
|
||||
public delete: (
|
||||
...args: Parameters<KVNamespace['delete']>
|
||||
) => ReturnType<KVNamespace['delete']>;
|
||||
}
|
||||
|
|
25
packages/api/src/routes/auth/bot.spec.ts
Normal file
25
packages/api/src/routes/auth/bot.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
describe('GET /auth/bot', () => {
|
||||
it('redirects to a Discord OAuth bot flow url', async () => {
|
||||
const response = await makeRequest('GET', '/auth/bot', undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'https://discord.com/api/oauth2/authorize?client_id=test123&scope=bot%20applications.commands&permissions=268435456'
|
||||
);
|
||||
});
|
||||
|
||||
it('redirects to a Discord OAuth bot flow url, forcing a guild when set', async () => {
|
||||
const response = await makeRequest('GET', '/auth/bot?guild=123456', undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'https://discord.com/api/oauth2/authorize?client_id=test123&scope=bot%20applications.commands&permissions=268435456&guild_id=123456&disable_guild_select=true'
|
||||
);
|
||||
});
|
||||
});
|
40
packages/api/src/routes/auth/bot.ts
Normal file
40
packages/api/src/routes/auth/bot.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { Context } from '@roleypoly/api/src/utils/context';
|
||||
import { seeOther } from '@roleypoly/api/src/utils/response';
|
||||
|
||||
const validGuildID = /^[0-9]+$/;
|
||||
|
||||
type URLParams = {
|
||||
clientID: string;
|
||||
permissions: number;
|
||||
guildID?: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
const buildURL = (params: URLParams) => {
|
||||
let url = `https://discord.com/api/oauth2/authorize?client_id=${
|
||||
params.clientID
|
||||
}&scope=${params.scopes.join('%20')}&permissions=${params.permissions}`;
|
||||
|
||||
if (params.guildID) {
|
||||
url += `&guild_id=${params.guildID}&disable_guild_select=true`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export const authBot = (request: Request, { config }: Context): Response => {
|
||||
let guildID = new URL(request.url).searchParams.get('guild') || '';
|
||||
|
||||
if (guildID && !validGuildID.test(guildID)) {
|
||||
guildID = '';
|
||||
}
|
||||
|
||||
return seeOther(
|
||||
buildURL({
|
||||
clientID: config.botClientID,
|
||||
permissions: 268435456, // Send messages + manage roles
|
||||
guildID,
|
||||
scopes: ['bot', 'applications.commands'],
|
||||
})
|
||||
);
|
||||
};
|
54
packages/api/src/routes/auth/bounce.spec.ts
Normal file
54
packages/api/src/routes/auth/bounce.spec.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { StateSession } from '@roleypoly/types';
|
||||
import { getBindings, makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
describe('GET /auth/bounce', () => {
|
||||
it('should return a redirect to Discord OAuth', async () => {
|
||||
const response = await makeRequest('GET', '/auth/bounce', undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
});
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'https://discord.com/api/oauth2/authorize?client_id=test123&response_type=code&scope=identify%20guilds&prompt=none&redirect_uri=http%3A%2F%2Ftest.local%2Fauth%2Fcallback&state='
|
||||
);
|
||||
});
|
||||
|
||||
it('should store a state-session', async () => {
|
||||
const response = await makeRequest('GET', '/auth/bounce', undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
});
|
||||
expect(response.status).toBe(303);
|
||||
const url = new URL(response.headers.get('Location') || '');
|
||||
const state = url.searchParams.get('state');
|
||||
|
||||
const environment = getBindings();
|
||||
const session = await environment.KV_SESSIONS.get(`state_${state}`, 'json');
|
||||
expect(session).not.toBeUndefined();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['http://web.test.local', 'http://web.test.local', 'http://web.test.local'],
|
||||
['http://*.test.local', 'http://web.test.local', 'http://web.test.local'],
|
||||
['http://other.test.local', 'http://web.test.local', undefined],
|
||||
])(
|
||||
'should process callback hosts when set to %s',
|
||||
async (allowlist, input, expected) => {
|
||||
const response = await makeRequest('GET', `/auth/bounce?cbh=${input}`, undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
API_PUBLIC_URI: 'http://api.test.local',
|
||||
ALLOWED_CALLBACK_HOSTS: allowlist,
|
||||
});
|
||||
expect(response.status).toBe(303);
|
||||
const url = new URL(response.headers.get('Location') || '');
|
||||
const state = url.searchParams.get('state');
|
||||
|
||||
const environment = getBindings();
|
||||
const session = (await environment.KV_SESSIONS.get(`state_${state}`, 'json')) as {
|
||||
data: StateSession;
|
||||
};
|
||||
expect(session).not.toBeUndefined();
|
||||
expect(session?.data.callbackHost).toBe(expected);
|
||||
}
|
||||
);
|
||||
});
|
|
@ -1,5 +1,6 @@
|
|||
import { Config } from '@roleypoly/api/src/config';
|
||||
import { setupStateSession } from '@roleypoly/api/src/sessions/state';
|
||||
import { Context } 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';
|
||||
|
@ -17,18 +18,33 @@ export const buildURL = (params: URLParams) =>
|
|||
params.redirectURI
|
||||
)}&state=${params.state}`;
|
||||
|
||||
const hostMatch = (a: string, b: string): boolean => {
|
||||
const aURL = new URL(a);
|
||||
const bURL = new URL(b);
|
||||
|
||||
return aURL.host === bURL.host && aURL.protocol === bURL.protocol;
|
||||
};
|
||||
|
||||
const wildcardMatch = (wildcard: string, host: string): boolean => {
|
||||
const aURL = new URL(wildcard);
|
||||
const bURL = new URL(host);
|
||||
|
||||
const regex = new RegExp(aURL.hostname.replace('*', '[a-z0-9-]+'));
|
||||
return regex.test(bURL.hostname);
|
||||
};
|
||||
|
||||
export const isAllowedCallbackHost = (config: Config, host: string): boolean => {
|
||||
return (
|
||||
host === config.apiPublicURI ||
|
||||
config.allowedCallbackHosts.includes(host) ||
|
||||
config.allowedCallbackHosts
|
||||
.filter((callbackHost) => callbackHost.includes('*'))
|
||||
.find((wildcard) => new RegExp(wildcard.replace('*', '[a-z0-9-]+')).test(host)) !==
|
||||
null
|
||||
hostMatch(host, config.apiPublicURI) ||
|
||||
config.allowedCallbackHosts.some((allowedHost) =>
|
||||
allowedHost.includes('*')
|
||||
? wildcardMatch(allowedHost, host)
|
||||
: hostMatch(allowedHost, host)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const authBounce = async (request: Request, config: Config) => {
|
||||
export const authBounce = async (request: Request, { config }: Context) => {
|
||||
const stateSessionData: StateSession = {};
|
||||
|
||||
const { cbh: callbackHost } = getQuery(request);
|
||||
|
@ -36,9 +52,9 @@ export const authBounce = async (request: Request, config: Config) => {
|
|||
stateSessionData.callbackHost = callbackHost;
|
||||
}
|
||||
|
||||
const state = await setupStateSession(config.kv.sessions, stateSessionData);
|
||||
const state = await setupStateSession(config, stateSessionData);
|
||||
|
||||
const redirectURI = `${config.apiPublicURI}/login-callback`;
|
||||
const redirectURI = `${config.apiPublicURI}/auth/callback`;
|
||||
const clientID = config.botClientID;
|
||||
|
||||
return seeOther(buildURL({ state, redirectURI, clientID }));
|
||||
|
|
97
packages/api/src/routes/auth/callback.spec.ts
Normal file
97
packages/api/src/routes/auth/callback.spec.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
jest.mock('../../utils/discord');
|
||||
jest.mock('../../sessions/create');
|
||||
|
||||
import { parseEnvironment } from '../../config';
|
||||
import { createSession } from '../../sessions/create';
|
||||
import { setupStateSession } from '../../sessions/state';
|
||||
import { discordFetch } from '../../utils/discord';
|
||||
import { getBindings, makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
const mockDiscordFetch = discordFetch as jest.Mock;
|
||||
const mockCreateSession = createSession as jest.Mock;
|
||||
|
||||
describe('GET /auth/callback', () => {
|
||||
beforeEach(() => {
|
||||
mockDiscordFetch.mockClear();
|
||||
});
|
||||
|
||||
it('should ask Discord to trade code for tokens', async () => {
|
||||
const env = getBindings();
|
||||
const config = parseEnvironment(env);
|
||||
const stateID = await setupStateSession(config, {});
|
||||
|
||||
const tokens = {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
expires_in: 3600,
|
||||
scope: 'identify guilds',
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
mockDiscordFetch.mockReturnValueOnce(tokens);
|
||||
|
||||
mockCreateSession.mockReturnValueOnce({
|
||||
sessionID: 'test-session-id',
|
||||
tokens,
|
||||
user: {
|
||||
id: 'test-user-id',
|
||||
username: 'test-username',
|
||||
discriminator: 'test-discriminator',
|
||||
avatar: 'test-avatar',
|
||||
bot: false,
|
||||
},
|
||||
guilds: [],
|
||||
});
|
||||
|
||||
const response = await makeRequest(
|
||||
'GET',
|
||||
`/auth/callback?state=${stateID}&code=1234`,
|
||||
undefined,
|
||||
{
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
BOT_CLIENT_SECRET: 'test456',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
UI_PUBLIC_URI: 'http://web.test.local/',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(mockDiscordFetch).toBeCalledTimes(1);
|
||||
expect(mockCreateSession).toBeCalledWith(expect.any(Object), tokens);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'http://web.test.local/machinery/new-session/test-session-id'
|
||||
);
|
||||
});
|
||||
|
||||
it('will fail if state is invalid', async () => {
|
||||
const response = await makeRequest(
|
||||
'GET',
|
||||
`/auth/callback?state=invalid-state&code=1234`,
|
||||
undefined,
|
||||
{
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
BOT_CLIENT_SECRET: 'test456',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
UI_PUBLIC_URI: 'http://web.test.local/',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'http://web.test.local/machinery/error?error_code=authFailure&extra=state invalid'
|
||||
);
|
||||
});
|
||||
|
||||
it('will fail if state is missing', async () => {
|
||||
const response = await makeRequest('GET', `/auth/callback?code=1234`, undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
BOT_CLIENT_SECRET: 'test456',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
UI_PUBLIC_URI: 'http://web.test.local/',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'http://web.test.local/machinery/error?error_code=authFailure&extra=state invalid'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -1,12 +1,12 @@
|
|||
import { Config } from '@roleypoly/api/src/config';
|
||||
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 { getQuery, seeOther } from '@roleypoly/api/src/utils';
|
||||
import { Context } from '@roleypoly/api/src/utils/context';
|
||||
import { AuthType, discordAPIBase, discordFetch } from '@roleypoly/api/src/utils/discord';
|
||||
import { formData } from '@roleypoly/api/src/utils/request';
|
||||
import { dateFromID } from '@roleypoly/api/src/utils/id';
|
||||
import { formDataRequest, getQuery } from '@roleypoly/api/src/utils/request';
|
||||
import { seeOther } from '@roleypoly/api/src/utils/response';
|
||||
import { AuthTokenResponse, StateSession } from '@roleypoly/types';
|
||||
import { decodeTime, monotonicFactory } from 'ulid-workers';
|
||||
const ulid = monotonicFactory();
|
||||
|
||||
const authFailure = (uiPublicURI: string, extra?: string) =>
|
||||
seeOther(
|
||||
|
@ -14,7 +14,7 @@ const authFailure = (uiPublicURI: string, extra?: string) =>
|
|||
`/machinery/error?error_code=authFailure${extra ? `&extra=${extra}` : ''}`
|
||||
);
|
||||
|
||||
export const authCallback = async (request: Request, config: Config) => {
|
||||
export const authCallback = async (request: Request, { config }: Context) => {
|
||||
let bounceBaseUrl = config.uiPublicURI;
|
||||
|
||||
const { state: stateValue, code } = getQuery(request);
|
||||
|
@ -24,18 +24,15 @@ export const authCallback = async (request: Request, config: Config) => {
|
|||
}
|
||||
|
||||
try {
|
||||
const stateTime = decodeTime(stateValue);
|
||||
const stateExpiry = stateTime + 1000 * 60 * 5;
|
||||
const stateTime = dateFromID(stateValue);
|
||||
const stateExpiry = stateTime + 1000 * config.retention.session;
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (currentTime > stateExpiry) {
|
||||
return authFailure('state expired');
|
||||
}
|
||||
|
||||
const stateSession = await getStateSession<StateSession>(
|
||||
config.kv.sessions,
|
||||
stateValue
|
||||
);
|
||||
const stateSession = await getStateSession<StateSession>(config, stateValue);
|
||||
if (
|
||||
stateSession?.callbackHost &&
|
||||
isAllowedCallbackHost(config, stateSession.callbackHost)
|
||||
|
@ -43,33 +40,34 @@ export const authCallback = async (request: Request, config: Config) => {
|
|||
bounceBaseUrl = stateSession.callbackHost;
|
||||
}
|
||||
} catch (e) {
|
||||
return authFailure('state invalid');
|
||||
return authFailure(config.uiPublicURI, 'state invalid');
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return authFailure('code missing');
|
||||
return authFailure(config.uiPublicURI, 'code missing');
|
||||
}
|
||||
|
||||
const response = await discordFetch<AuthTokenResponse>(
|
||||
`${discordAPIBase}/oauth2/token`,
|
||||
'',
|
||||
AuthType.None,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData({
|
||||
client_id: config.botClientID,
|
||||
client_secret: config.botClientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.apiPublicURI + '/auth/callback',
|
||||
}),
|
||||
}
|
||||
formDataRequest({
|
||||
client_id: config.botClientID,
|
||||
client_secret: config.botClientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.apiPublicURI + '/auth/callback',
|
||||
})
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
return authFailure('code auth failure');
|
||||
return authFailure(config.uiPublicURI, 'code auth failure');
|
||||
}
|
||||
|
||||
const session = await createSession(config, response);
|
||||
if (!session) {
|
||||
return authFailure(config.uiPublicURI, 'session setup failure');
|
||||
}
|
||||
|
||||
return seeOther(bounceBaseUrl + '/machinery/new-session/' + session.sessionID);
|
||||
};
|
||||
|
|
8
packages/api/src/routes/legacy/import.ts
Normal file
8
packages/api/src/routes/legacy/import.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { notImplemented } from '@roleypoly/api/src/utils/response';
|
||||
|
||||
/**
|
||||
* Full import flow for legacy config.
|
||||
*/
|
||||
export const legacyImport = () => {
|
||||
notImplemented();
|
||||
};
|
8
packages/api/src/routes/legacy/preflight.ts
Normal file
8
packages/api/src/routes/legacy/preflight.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { notImplemented } from '@roleypoly/api/src/utils/response';
|
||||
|
||||
/**
|
||||
* Fetch setup from beta.roleypoly.com to show the admin.
|
||||
*/
|
||||
export const legacyPreflight = () => {
|
||||
notImplemented();
|
||||
};
|
|
@ -1,8 +1,32 @@
|
|||
import { Config } from "@roleypoly/api/src/config";
|
||||
import { AuthTokenResponse } from "@roleypoly/types";
|
||||
import { Config } from '@roleypoly/api/src/config';
|
||||
import { getTokenGuilds, getTokenUser } from '@roleypoly/api/src/utils/discord';
|
||||
import { AuthTokenResponse, SessionData } from '@roleypoly/types';
|
||||
import { monotonicFactory } from 'ulid-workers';
|
||||
const ulid = monotonicFactory();
|
||||
|
||||
export const createSession = async (
|
||||
config: Config,
|
||||
sessionId: string,
|
||||
tokens: AuthTokenResponse
|
||||
)
|
||||
): Promise<SessionData | null> => {
|
||||
const [user, guilds] = await Promise.all([
|
||||
getTokenUser(tokens.access_token),
|
||||
getTokenGuilds(tokens.access_token),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionID = ulid();
|
||||
|
||||
const session: SessionData = {
|
||||
sessionID,
|
||||
user,
|
||||
guilds,
|
||||
tokens,
|
||||
};
|
||||
|
||||
await config.kv.sessions.put(sessionID, session, config.retention.session);
|
||||
|
||||
return session;
|
||||
};
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import { SessionData, SessionFlags } from '@roleypoly/types';
|
||||
|
||||
export const getSessionFlags = async (session: SessionData): Promise<SessionFlags> => {
|
||||
return SessionFlags.None;
|
||||
};
|
46
packages/api/src/sessions/middleware.ts
Normal file
46
packages/api/src/sessions/middleware.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { Context } from '@roleypoly/api/src/utils/context';
|
||||
|
||||
export const withSession = (request: Request, context: Context) => {};
|
||||
|
||||
export const requireSession = (request: Request, context: Context) => {
|
||||
if (context.authMode.type !== 'bearer') {
|
||||
throw new Error('Not authed');
|
||||
}
|
||||
};
|
||||
|
||||
export const withAuthMode = (request: Request, context: Context) => {
|
||||
const auth = extractAuthentication(request);
|
||||
|
||||
if (auth.authType === 'Bearer') {
|
||||
context.authMode = {
|
||||
type: 'bearer',
|
||||
sessionId: auth.token,
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.authType === 'Bot') {
|
||||
context.authMode = {
|
||||
type: 'bot',
|
||||
identity: auth.token,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
context.authMode = {
|
||||
type: 'anonymous',
|
||||
};
|
||||
};
|
||||
|
||||
export const extractAuthentication = (
|
||||
request: Request
|
||||
): { authType: string; token: string } => {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (!authHeader) {
|
||||
return { authType: 'None', token: '' };
|
||||
}
|
||||
|
||||
const [authType, token] = authHeader.split(' ');
|
||||
return { authType, token };
|
||||
};
|
|
@ -1,23 +1,19 @@
|
|||
import { WrappedKVNamespace } from '@roleypoly/api/src/kv';
|
||||
import { monotonicFactory } from 'ulid-workers';
|
||||
const ulid = monotonicFactory();
|
||||
import { Config } from '@roleypoly/api/src/config';
|
||||
import { getID } from '@roleypoly/api/src/utils/id';
|
||||
|
||||
export const setupStateSession = async <T>(
|
||||
Sessions: WrappedKVNamespace,
|
||||
data: T
|
||||
): Promise<string> => {
|
||||
const stateID = ulid();
|
||||
export const setupStateSession = async <T>(config: Config, data: T): Promise<string> => {
|
||||
const stateID = getID();
|
||||
|
||||
await Sessions.put(`state_${stateID}`, { data }, 60 * 5);
|
||||
await config.kv.sessions.put(`state_${stateID}`, { data }, config.retention.session);
|
||||
|
||||
return stateID;
|
||||
};
|
||||
|
||||
export const getStateSession = async <T>(
|
||||
Sessions: WrappedKVNamespace,
|
||||
config: Config,
|
||||
stateID: string
|
||||
): Promise<T | undefined> => {
|
||||
const stateSession = await Sessions.get<{ data: T }>(`state_${stateID}`);
|
||||
const stateSession = await config.kv.sessions.get<{ data: T }>(`state_${stateID}`);
|
||||
|
||||
return stateSession?.data;
|
||||
};
|
||||
|
|
26
packages/api/src/utils/context.ts
Normal file
26
packages/api/src/utils/context.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { Config } from '@roleypoly/api/src/config';
|
||||
import { SessionData } from '@roleypoly/types';
|
||||
|
||||
export type AuthMode =
|
||||
| {
|
||||
type: 'anonymous';
|
||||
}
|
||||
| {
|
||||
type: 'bearer';
|
||||
sessionId: string;
|
||||
}
|
||||
| {
|
||||
type: 'bot';
|
||||
identity: string;
|
||||
};
|
||||
|
||||
export type Context = {
|
||||
config: Config;
|
||||
fetchContext: {
|
||||
waitUntil: FetchEvent['waitUntil'];
|
||||
};
|
||||
authMode: AuthMode;
|
||||
|
||||
// Must include withSession middleware for population
|
||||
session?: SessionData;
|
||||
};
|
|
@ -1,4 +1,3 @@
|
|||
import { Config } from '@roleypoly/api/src/config';
|
||||
import {
|
||||
evaluatePermission,
|
||||
permissions as Permissions,
|
||||
|
@ -13,7 +12,7 @@ import {
|
|||
export const userAgent =
|
||||
'DiscordBot (https://github.com/roleypoly/roleypoly, git-main) (+https://roleypoly.com)';
|
||||
|
||||
export const discordAPIBase = 'https://discordapp.com/api/v9';
|
||||
export const discordAPIBase = 'https://discord.com/api/v9';
|
||||
|
||||
export enum AuthType {
|
||||
Bearer = 'Bearer',
|
||||
|
@ -82,7 +81,7 @@ type UserGuildsPayload = {
|
|||
features: string[];
|
||||
}[];
|
||||
|
||||
export const getTokenGuilds = async (accessToken: string, config: Config) => {
|
||||
export const getTokenGuilds = async (accessToken: string) => {
|
||||
const guilds = await discordFetch<UserGuildsPayload>(
|
||||
'/users/@me/guilds',
|
||||
accessToken,
|
||||
|
@ -97,11 +96,7 @@ export const getTokenGuilds = async (accessToken: string, config: Config) => {
|
|||
id: guild.id,
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
permissionLevel: parsePermissions(
|
||||
BigInt(guild.permissions),
|
||||
guild.owner,
|
||||
config.roleypolyServerID
|
||||
),
|
||||
permissionLevel: parsePermissions(BigInt(guild.permissions), guild.owner),
|
||||
}));
|
||||
|
||||
return guildSlugs;
|
||||
|
|
6
packages/api/src/utils/id.ts
Normal file
6
packages/api/src/utils/id.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { decodeTime, monotonicFactory } from 'ulid-workers';
|
||||
|
||||
const ulid = monotonicFactory();
|
||||
|
||||
export const getID = () => ulid();
|
||||
export const dateFromID = (id: string) => decodeTime(id);
|
|
@ -13,3 +13,18 @@ export const formData = (obj: Record<string, any>): string => {
|
|||
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`)
|
||||
.join('&');
|
||||
};
|
||||
|
||||
export const formDataRequest = (
|
||||
obj: Record<string, any>,
|
||||
init?: RequestInit
|
||||
): RequestInit => {
|
||||
return {
|
||||
method: 'POST', // First, so it can be overridden.
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers || {}),
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData(obj),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -9,8 +9,6 @@ export const json = (obj: any, init?: ResponseInit): Response => {
|
|||
});
|
||||
};
|
||||
|
||||
export const notFound = () => json({ error: 'not found' }, { status: 404 });
|
||||
|
||||
export const seeOther = (url: string) =>
|
||||
new Response(
|
||||
`<!doctype html>If you are not redirected soon, <a href="${url}">click here.</a>`,
|
||||
|
@ -22,3 +20,12 @@ export const seeOther = (url: string) =>
|
|||
},
|
||||
}
|
||||
);
|
||||
|
||||
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 });
|
||||
export const serverError = (error: Error) => {
|
||||
console.error(error);
|
||||
return json({ error: 'internal server error' }, { status: 500 });
|
||||
};
|
||||
export const notImplemented = () => json({ error: 'not implemented' }, { status: 501 });
|
||||
|
|
27
packages/api/src/utils/testHelpers.ts
Normal file
27
packages/api/src/utils/testHelpers.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Environment } from '@roleypoly/api/src/config';
|
||||
import index from '../index';
|
||||
|
||||
export const makeRequest = (
|
||||
method: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
env?: Partial<Environment>
|
||||
): Promise<Response> => {
|
||||
const request = new Request(`https://localhost:22000${path}`, {
|
||||
method,
|
||||
...init,
|
||||
});
|
||||
|
||||
return index.fetch(
|
||||
request,
|
||||
{
|
||||
...getMiniflareBindings(),
|
||||
...env,
|
||||
},
|
||||
{
|
||||
waitUntil: async (promise: Promise<{}>) => await promise,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const getBindings = (): Environment => getMiniflareBindings();
|
8
packages/api/test/miniflare.d.ts
vendored
Normal file
8
packages/api/test/miniflare.d.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
declare global {
|
||||
function getMiniflareBindings(): Environment;
|
||||
function getMiniflareDurableObjectStorage(
|
||||
id: DurableObjectId
|
||||
): Promise<DurableObjectStorage>;
|
||||
}
|
||||
|
||||
export {};
|
|
@ -1,17 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"target": "ES2019",
|
||||
"esModuleInterop": true,
|
||||
"module": "commonjs"
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": [
|
||||
"./*.ts",
|
||||
"./**/*.ts",
|
||||
"../../node_modules/@cloudflare/workers-types/index.d.ts"
|
||||
],
|
||||
"exclude": ["./**/*.spec.ts", "./dist/**"],
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"exclude": ["./**/*.spec.ts"],
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
|
|
6
packages/api/tsconfig.test.json
Normal file
6
packages/api/tsconfig.test.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "@cloudflare/workers-types"]
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue