diff --git a/packages/api/jest.config.js b/packages/api/jest.config.js new file mode 100644 index 0000000..a3a4e90 --- /dev/null +++ b/packages/api/jest.config.js @@ -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: '/packages/api/tsconfig.test.json', + useESM: true, + }, + }, +}; diff --git a/packages/api/package.json b/packages/api/package.json index 74c55bd..931ffbb 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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" } } diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts index 03e88c0..1c2aafa 100644 --- a/packages/api/src/config.ts +++ b/packages/api/src/config.ts @@ -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 + }, }; }; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index fa117c2..1857611 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -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); }, }; diff --git a/packages/api/src/kv.ts b/packages/api/src/kv.ts index df4e425..aca8d16 100644 --- a/packages/api/src/kv.ts +++ b/packages/api/src/kv.ts @@ -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(key: string): Promise { 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 + ) => ReturnType; + public putRaw: ( + ...args: Parameters + ) => ReturnType; + public list: ( + ...args: Parameters + ) => ReturnType; + public getWithMetadata: ( + ...args: Parameters + ) => ReturnType; + public delete: ( + ...args: Parameters + ) => ReturnType; } diff --git a/packages/api/src/routes/auth/bot.spec.ts b/packages/api/src/routes/auth/bot.spec.ts new file mode 100644 index 0000000..791e822 --- /dev/null +++ b/packages/api/src/routes/auth/bot.spec.ts @@ -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' + ); + }); +}); diff --git a/packages/api/src/routes/auth/bot.ts b/packages/api/src/routes/auth/bot.ts new file mode 100644 index 0000000..ff79ed6 --- /dev/null +++ b/packages/api/src/routes/auth/bot.ts @@ -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'], + }) + ); +}; diff --git a/packages/api/src/routes/auth/bounce.spec.ts b/packages/api/src/routes/auth/bounce.spec.ts new file mode 100644 index 0000000..42d17ac --- /dev/null +++ b/packages/api/src/routes/auth/bounce.spec.ts @@ -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); + } + ); +}); diff --git a/packages/api/src/routes/auth/bounce.ts b/packages/api/src/routes/auth/bounce.ts index 9257874..efb9f4f 100644 --- a/packages/api/src/routes/auth/bounce.ts +++ b/packages/api/src/routes/auth/bounce.ts @@ -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 })); diff --git a/packages/api/src/routes/auth/callback.spec.ts b/packages/api/src/routes/auth/callback.spec.ts new file mode 100644 index 0000000..4746ece --- /dev/null +++ b/packages/api/src/routes/auth/callback.spec.ts @@ -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' + ); + }); +}); diff --git a/packages/api/src/routes/auth/callback.ts b/packages/api/src/routes/auth/callback.ts index c5eae85..d163d14 100644 --- a/packages/api/src/routes/auth/callback.ts +++ b/packages/api/src/routes/auth/callback.ts @@ -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( - config.kv.sessions, - stateValue - ); + const stateSession = await getStateSession(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( `${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); }; diff --git a/packages/api/src/routes/legacy/import.ts b/packages/api/src/routes/legacy/import.ts new file mode 100644 index 0000000..81c9d9e --- /dev/null +++ b/packages/api/src/routes/legacy/import.ts @@ -0,0 +1,8 @@ +import { notImplemented } from '@roleypoly/api/src/utils/response'; + +/** + * Full import flow for legacy config. + */ +export const legacyImport = () => { + notImplemented(); +}; diff --git a/packages/api/src/routes/legacy/preflight.ts b/packages/api/src/routes/legacy/preflight.ts new file mode 100644 index 0000000..b36c98f --- /dev/null +++ b/packages/api/src/routes/legacy/preflight.ts @@ -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(); +}; diff --git a/packages/api/src/sessions/create.ts b/packages/api/src/sessions/create.ts index c8f6b43..1d6fd31 100644 --- a/packages/api/src/sessions/create.ts +++ b/packages/api/src/sessions/create.ts @@ -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 -) \ No newline at end of file +): Promise => { + 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; +}; diff --git a/packages/api/src/sessions/flags.ts b/packages/api/src/sessions/flags.ts deleted file mode 100644 index 892bd28..0000000 --- a/packages/api/src/sessions/flags.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { SessionData, SessionFlags } from '@roleypoly/types'; - -export const getSessionFlags = async (session: SessionData): Promise => { - return SessionFlags.None; -}; diff --git a/packages/api/src/sessions/middleware.ts b/packages/api/src/sessions/middleware.ts new file mode 100644 index 0000000..b564b86 --- /dev/null +++ b/packages/api/src/sessions/middleware.ts @@ -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 }; +}; diff --git a/packages/api/src/sessions/state.ts b/packages/api/src/sessions/state.ts index ae7854c..60873dc 100644 --- a/packages/api/src/sessions/state.ts +++ b/packages/api/src/sessions/state.ts @@ -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 ( - Sessions: WrappedKVNamespace, - data: T -): Promise => { - const stateID = ulid(); +export const setupStateSession = async (config: Config, data: T): Promise => { + 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 ( - Sessions: WrappedKVNamespace, + config: Config, stateID: string ): Promise => { - const stateSession = await Sessions.get<{ data: T }>(`state_${stateID}`); + const stateSession = await config.kv.sessions.get<{ data: T }>(`state_${stateID}`); return stateSession?.data; }; diff --git a/packages/api/src/utils/context.ts b/packages/api/src/utils/context.ts new file mode 100644 index 0000000..08d264f --- /dev/null +++ b/packages/api/src/utils/context.ts @@ -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; +}; diff --git a/packages/api/src/utils/discord.ts b/packages/api/src/utils/discord.ts index a612d46..19b414b 100644 --- a/packages/api/src/utils/discord.ts +++ b/packages/api/src/utils/discord.ts @@ -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( '/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; diff --git a/packages/api/src/utils/id.ts b/packages/api/src/utils/id.ts new file mode 100644 index 0000000..5a99501 --- /dev/null +++ b/packages/api/src/utils/id.ts @@ -0,0 +1,6 @@ +import { decodeTime, monotonicFactory } from 'ulid-workers'; + +const ulid = monotonicFactory(); + +export const getID = () => ulid(); +export const dateFromID = (id: string) => decodeTime(id); diff --git a/packages/api/src/utils/request.ts b/packages/api/src/utils/request.ts index ed643bf..917636c 100644 --- a/packages/api/src/utils/request.ts +++ b/packages/api/src/utils/request.ts @@ -13,3 +13,18 @@ export const formData = (obj: Record): string => { .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`) .join('&'); }; + +export const formDataRequest = ( + obj: Record, + 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), + }; +}; diff --git a/packages/api/src/utils/response.ts b/packages/api/src/utils/response.ts index 74d8914..0ecfa6f 100644 --- a/packages/api/src/utils/response.ts +++ b/packages/api/src/utils/response.ts @@ -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( `If you are not redirected soon, click here.`, @@ -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 }); diff --git a/packages/api/src/utils/testHelpers.ts b/packages/api/src/utils/testHelpers.ts new file mode 100644 index 0000000..0a079da --- /dev/null +++ b/packages/api/src/utils/testHelpers.ts @@ -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 +): Promise => { + 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(); diff --git a/packages/api/test/miniflare.d.ts b/packages/api/test/miniflare.d.ts new file mode 100644 index 0000000..8ed57be --- /dev/null +++ b/packages/api/test/miniflare.d.ts @@ -0,0 +1,8 @@ +declare global { + function getMiniflareBindings(): Environment; + function getMiniflareDurableObjectStorage( + id: DurableObjectId + ): Promise; +} + +export {}; diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index d5dd82f..fec6fab 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -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" } diff --git a/packages/api/tsconfig.test.json b/packages/api/tsconfig.test.json new file mode 100644 index 0000000..5ad7a29 --- /dev/null +++ b/packages/api/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["jest", "@cloudflare/workers-types"] + } +}