Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions docs/roadmap/02-foundation.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,25 @@ Each route: convert from `Route` subclass → `defineRoute`, and from Kysely →

**Auth (`services/api/src/routes/auth/`) — 4 routes:**

- [ ] `discord.ts` — `GET /v3/auth/discord`
- [ ] `discordCallback.ts` — `GET /v3/auth/discord/callback`
- [ ] `logout.ts` — `POST /v3/auth/logout`
- [ ] `me.ts` — `GET /v3/auth/me`
- [x] `discord.ts` — `GET /v3/auth/discord`
- [x] `discordCallback.ts` — `GET /v3/auth/discord/callback`
- [x] `logout.ts` — `POST /v3/auth/logout`
- [x] `me.ts` — `GET /v3/auth/me`

Done (#129). None of the 4 auth routes issue their own DB queries — `discord.ts`/`logout.ts` are pure
cookie/redirect/Discord-REST handlers, and `discordCallback.ts`/`me.ts` only reach the DB transitively through
the shared `fetchMe()` helper (`util/me.ts`), which still queries `DashboardGrant` via legacy Kysely
(`getContext().db`). `fetchMe` is shared infrastructure also used by `isAuthed`'s `isGuildManager` check and by
the already-migrated AMA routes (#128) — same precedent as #128, which didn't touch it either. It stays on
Kysely until the guilds routes (#130, which own the `DashboardGrant` table) migrate it, or #132's cleanup.
Same coexistence/frontend-breakage notes as #128 apply: `discord.ts`/`discordCallback.ts`/`logout.ts`/`me.ts`
dropped out of `routes.ts`/`_types/routeTypes.ts`; `apps/website`'s `data/*` hooks for auth are unbridged until
#131. Verified by hand: built `services/api`, ran it against local Postgres/Redis, and curled all 4 routes —
`GET /v3/auth/discord` (no cookies) 302s to the correct Discord OAuth URL with `state` cookie set;
`GET /v3/auth/me` and `POST /v3/auth/logout` (no cookies) both 401; `GET /v3/auth/discord/callback` 400s on
missing query and on state mismatch — all byte-for-byte matching the pre-migration `Route`-class behavior. Full
interactive Discord login (browser click-through) still needs to be exercised manually against a real Discord
application before merge.

**Guilds (`services/api/src/routes/guilds/`) — 4 routes:**

Expand Down
5 changes: 0 additions & 5 deletions services/api/src/routes/_types/routeTypes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
export type * from '../auth/discord.js';
export type * from '../auth/discordCallback.js';
export type * from '../auth/logout.js';
export type * from '../auth/me.js';

export type * from '../guilds/get.js';
export type * from '../guilds/createGrant.js';
export type * from '../guilds/deleteGrant.js';
Expand Down
30 changes: 11 additions & 19 deletions services/api/src/routes/auth/discord.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,22 @@
import { URLSearchParams } from 'node:url';
import { getContext } from '@chatsift/backend-core';
import type { RESTOAuth2AuthorizationQuery } from '@discordjs/core';
import type { NextHandler, Response } from 'polka';
import { unwrapMiddlewareHandle } from '../../core/route.js';
import { defineRoute } from '../../core/route.js';
import { isAuthed } from '../../middleware/isAuthed.js';
import { cookieWithDomain } from '../../util/constants.js';
import { StateCookie } from '../../util/stateCookie.js';
import type { TRequest } from '../route.js';
import { Route, RouteMethod } from '../route.js';

export const DISCORD_AUTH_SCOPES = new Set(['identify', 'email', 'guilds', 'guilds.members.read'] as const);

export default class GetAuthDiscord extends Route<never, never> {
public readonly info = {
method: RouteMethod.get,
path: '/v3/auth/discord',
} as const;

public override readonly middleware = isAuthed({ fallthrough: true, isGlobalAdmin: false }).map(
unwrapMiddlewareHandle,
);

public override async handle(req: TRequest<never>, res: Response, next: NextHandler) {
export default defineRoute({
method: 'get',
path: '/v3/auth/discord',
middleware: isAuthed({ fallthrough: true, isGlobalAdmin: false }),
handler(req, res) {
if (req.tokens) {
res.redirect(getContext().FRONTEND_URL);
return res.end();
res.end();
return;
}

const state = new StateCookie(`${getContext().FRONTEND_URL}/dashboard`).toCookie();
Expand All @@ -49,6 +41,6 @@ export default class GetAuthDiscord extends Route<never, never> {
} satisfies RESTOAuth2AuthorizationQuery;

res.redirect(`https://discord.com/oauth2/authorize?${new URLSearchParams(params).toString()}`);
return res.end();
}
}
res.end();
},
});
41 changes: 17 additions & 24 deletions services/api/src/routes/auth/discordCallback.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,41 @@
import { getContext } from '@chatsift/backend-core';
import { badRequest, forbidden } from '@hapi/boom';
import cookie from 'cookie';
import type { NextHandler, Response } from 'polka';
import z from 'zod';
import { unwrapMiddlewareHandle } from '../../core/route.js';
import { defineRoute } from '../../core/route.js';
import { isAuthed } from '../../middleware/isAuthed.js';
import { cookieWithDomain } from '../../util/constants.js';
import { discordAPIOAuth } from '../../util/discordAPI.js';
import { fetchMe } from '../../util/me.js';
import { setEquals } from '../../util/setEquals.js';
import { StateCookie } from '../../util/stateCookie.js';
import { createAccessToken, createRefreshToken } from '../../util/tokens.js';
import type { TRequest } from '../route.js';
import { Route, RouteMethod } from '../route.js';
import { DISCORD_AUTH_SCOPES } from './discord.js';

const querySchema = z.strictObject({
code: z.string(),
state: z.string(),
});

export default class GetAuthDiscordCallback extends Route<never, typeof querySchema> {
public readonly info = {
method: RouteMethod.get,
path: '/v3/auth/discord/callback',
} as const;

public override readonly queryValidationSchema = querySchema;

public override readonly middleware = isAuthed({ fallthrough: true, isGlobalAdmin: false }).map(
unwrapMiddlewareHandle,
);

public override async handle(req: TRequest<typeof querySchema>, res: Response, next: NextHandler) {
export default defineRoute({
method: 'get',
path: '/v3/auth/discord/callback',
schema: {
query: querySchema,
},
middleware: isAuthed({ fallthrough: true, isGlobalAdmin: false }),
async handler(req, res) {
if (req.tokens) {
res.redirect(getContext().FRONTEND_URL);
return res.end();
res.end();
return;
}

const { code, state: stateQuery } = req.query;

const parsedCookies = cookie.parse(req.headers.cookie ?? '');
if (stateQuery !== parsedCookies['state']) {
return next(badRequest('bad state'));
throw badRequest('bad state');
}

const state = StateCookie.from(stateQuery);
Expand All @@ -56,7 +49,7 @@ export default class GetAuthDiscordCallback extends Route<never, typeof querySch
const stateAge = Date.now() - state.createdAt.getTime();
const MAX_STATE_AGE = 10 * 60 * 1_000; // 10 minutes
if (stateAge > MAX_STATE_AGE) {
return next(badRequest('state expired'));
throw badRequest('state expired');
}

const result = await discordAPIOAuth.oauth2.tokenExchange({
Expand All @@ -72,14 +65,14 @@ export default class GetAuthDiscordCallback extends Route<never, typeof querySch
{ returnedScopes: result.scope, expectedScopes: DISCORD_AUTH_SCOPES },
'miss matched scopes',
);
return next(forbidden('received different scopes than expected'));
throw forbidden('received different scopes than expected');
}

const me = await fetchMe(result.access_token, true);
createAccessToken(res, result, me);
createRefreshToken(res, result, me.id);

res.redirect(state.redirectURI);
return res.end();
}
}
res.end();
},
});
27 changes: 10 additions & 17 deletions services/api/src/routes/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
import { getContext } from '@chatsift/backend-core';
import type { NextHandler, Response } from 'polka';
import { unwrapMiddlewareHandle } from '../../core/route.js';
import { defineRoute } from '../../core/route.js';
import { isAuthed } from '../../middleware/isAuthed.js';
import { discordAPIOAuth } from '../../util/discordAPI.js';
import { noopAccessToken, noopRefreshToken } from '../../util/tokens.js';
import type { TRequest } from '../route.js';
import { Route, RouteMethod } from '../route.js';

export default class PostAuthLogout extends Route<never, never> {
public readonly info = {
method: RouteMethod.post,
path: '/v3/auth/logout',
} as const;

public override readonly middleware = isAuthed({
export default defineRoute({
method: 'post',
path: '/v3/auth/logout',
middleware: isAuthed({
fallthrough: false,
isGlobalAdmin: false,
isGuildManager: false,
}).map(unwrapMiddlewareHandle);

public override async handle(req: TRequest<never>, res: Response, next: NextHandler) {
}),
async handler(req, res) {
await discordAPIOAuth.oauth2.revokeToken(
getContext().env.OAUTH_DISCORD_CLIENT_ID,
getContext().env.OAUTH_DISCORD_CLIENT_SECRET,
Expand All @@ -30,6 +23,6 @@ export default class PostAuthLogout extends Route<never, never> {
noopRefreshToken(res);

res.statusCode = 200;
return res.end();
}
}
res.end();
},
});
40 changes: 14 additions & 26 deletions services/api/src/routes/auth/me.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,27 @@
import type { NextHandler, Response } from 'polka';
import type z from 'zod';
import { unwrapMiddlewareHandle } from '../../core/route.js';
import type { z } from 'zod';
import { defineRoute } from '../../core/route.js';
import { isAuthed } from '../../middleware/isAuthed.js';
import type { Me } from '../../util/me.js';
import { fetchMe } from '../../util/me.js';
import { queryWithFreshSchema } from '../../util/schemas.js';
import type { TRequest } from '../route.js';
import { Route, RouteMethod } from '../route.js';

export type { Me, MeGuild } from '../../util/me.js';

const querySchema = queryWithFreshSchema;
export type GetAuthMeQuery = z.input<typeof querySchema>;

export default class GetAuthMe extends Route<Me, typeof querySchema> {
public readonly info = {
method: RouteMethod.get,
path: '/v3/auth/me',
} as const;

public override readonly queryValidationSchema = querySchema;

public override readonly middleware = isAuthed({
export default defineRoute({
method: 'get',
path: '/v3/auth/me',
schema: {
query: querySchema,
},
middleware: isAuthed({
fallthrough: false,
isGlobalAdmin: false,
isGuildManager: false,
}).map(unwrapMiddlewareHandle);

public override async handle(req: TRequest<typeof querySchema>, res: Response, next: NextHandler) {
const { force_fresh } = req.query;

const result: Me = await fetchMe(req.tokens!.access.discordAccessToken, force_fresh);

res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
return res.end(JSON.stringify(result));
}
}
}),
async handler(req): Promise<Me> {
return fetchMe(req.tokens!.access.discordAccessToken, req.query.force_fresh);
},
});
12 changes: 4 additions & 8 deletions services/api/src/routes/routes.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
// This file should exclusively re-export the default exports from each route file
//
// AMA routes (createAMA/getAMA/getAMAs/updateAMA/repostPrompt) are intentionally absent — they've been migrated to
// `defineRoute` (docs/roadmap/02-foundation.md Part C, #128) and no longer fit this `Route`-subclass reflection
// barrel. Their frontend types are unavailable until #131 replaces apps/website/src/data/* with src/api/*.

export { default as GetAuthDiscord } from './auth/discord.js';
export { default as GetAuthDiscordCallback } from './auth/discordCallback.js';
export { default as PostAuthLogout } from './auth/logout.js';
export { default as GetAuthMe } from './auth/me.js';
// AMA routes (createAMA/getAMA/getAMAs/updateAMA/repostPrompt) and auth routes (discord/discordCallback/logout/me)
// are intentionally absent — they've been migrated to `defineRoute` (docs/roadmap/02-foundation.md Part C, #128
// and #129) and no longer fit this `Route`-subclass reflection barrel. Their frontend types are unavailable until
// #131 replaces apps/website/src/data/* with src/api/*.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export { default as GetGuild } from './guilds/get.js';
export { default as CreateGrant } from './guilds/createGrant.js';
Expand Down
Loading