diff --git a/packages/web/eslint.config.mjs b/packages/web/eslint.config.mjs index 521781603..8e590ecd2 100644 --- a/packages/web/eslint.config.mjs +++ b/packages/web/eslint.config.mjs @@ -1,11 +1,20 @@ import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'; import tseslint from 'typescript-eslint'; import tanstackQuery from '@tanstack/eslint-plugin-query'; +import authzLocal from './tools/eslint-plugin-local/index.mjs'; const config = [ ...nextCoreWebVitals, ...tseslint.configs.recommended, ...tanstackQuery.configs['flat/recommended'], + { + plugins: { + authz: authzLocal, + }, + rules: { + 'authz/require-auth-wrapper': 'error', + }, + }, { rules: { // New react-hooks v7 rules disabled as too strict for this codebase's existing patterns. diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 0d069ea69..f7350a8d8 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -817,6 +817,7 @@ export const getOrgAccountRequests = async () => sew(() => })); })); +// eslint-disable-next-line authz/require-auth-wrapper -- calls getAuthenticatedUser() directly; runs pre-org-membership so cannot use withAuth export const createAccountRequest = async () => sew(async () => { const authResult = await getAuthenticatedUser(); if (!authResult) { @@ -920,6 +921,7 @@ export const createAccountRequest = async () => sew(async () => { } }); +// eslint-disable-next-line authz/require-auth-wrapper -- public org-config bit consulted on login/signup screens before any session exists export const getMemberApprovalRequired = async (): Promise => sew(async () => { const org = await __unsafePrisma.org.findUnique({ where: { @@ -1181,6 +1183,7 @@ export const getRepoImage = async (repoId: number): Promise => sew(async () => { const org = await __unsafePrisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID }, @@ -1244,6 +1247,7 @@ export const setAnonymousAccessStatus = async (enabled: boolean): Promise sew(async () => { const cookieStore = await cookies(); cookieStore.set(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, dismissed ? "true" : "false", { @@ -1253,6 +1257,7 @@ export const setAgenticSearchTutorialDismissedCookie = async (dismissed: boolean return true; }); +// eslint-disable-next-line authz/require-auth-wrapper -- UI-only preference cookie, no DB access export const dismissMobileUnsupportedSplashScreen = async () => sew(async () => { const cookieStore = await cookies(); cookieStore.set(MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, 'true'); diff --git a/packages/web/src/app/api/(server)/[...slug]/route.ts b/packages/web/src/app/api/(server)/[...slug]/route.ts index f98fdfd13..44bebea56 100644 --- a/packages/web/src/app/api/(server)/[...slug]/route.ts +++ b/packages/web/src/app/api/(server)/[...slug]/route.ts @@ -10,4 +10,5 @@ const handler = () => { }); } +// eslint-disable-next-line authz/require-auth-wrapper -- 404 catch-all for unknown API endpoints, returns no user data export { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE } \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/auth/[...nextauth]/route.ts b/packages/web/src/app/api/(server)/auth/[...nextauth]/route.ts index f5bc5b80c..8910becc1 100644 --- a/packages/web/src/app/api/(server)/auth/[...nextauth]/route.ts +++ b/packages/web/src/app/api/(server)/auth/[...nextauth]/route.ts @@ -1,2 +1,3 @@ import { handlers } from "@/auth"; +// eslint-disable-next-line authz/require-auth-wrapper -- NextAuth's own auth-flow handlers, not user-data endpoints export const { GET, POST } = handlers; \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/blame/route.ts b/packages/web/src/app/api/(server)/blame/route.ts index a7deef2a7..4eafe10f7 100644 --- a/packages/web/src/app/api/(server)/blame/route.ts +++ b/packages/web/src/app/api/(server)/blame/route.ts @@ -7,6 +7,7 @@ import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/se import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to getFileBlame() which calls withOptionalAuth export const GET = apiHandler(async (request: NextRequest) => { const rawParams = Object.fromEntries( Object.keys(fileBlameRequestSchema.shape).map(key => [ diff --git a/packages/web/src/app/api/(server)/chat/blocking/route.ts b/packages/web/src/app/api/(server)/chat/blocking/route.ts index 3f5cc585f..56165f2c6 100644 --- a/packages/web/src/app/api/(server)/chat/blocking/route.ts +++ b/packages/web/src/app/api/(server)/chat/blocking/route.ts @@ -37,6 +37,7 @@ const blockingChatRequestSchema = z.object({ * The chat session is persisted to the database, allowing users to view the full * conversation (including tool calls and reasoning) in the web UI. */ +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to askCodebase() which calls withOptionalAuth export const POST = apiHandler(async (request: NextRequest) => { const requestBody = await request.json(); const parsed = await blockingChatRequestSchema.safeParseAsync(requestBody); diff --git a/packages/web/src/app/api/(server)/commit/route.ts b/packages/web/src/app/api/(server)/commit/route.ts index c5e409ce8..746ad0efe 100644 --- a/packages/web/src/app/api/(server)/commit/route.ts +++ b/packages/web/src/app/api/(server)/commit/route.ts @@ -5,6 +5,7 @@ import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/se import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to getCommit() which calls withOptionalAuth export const GET = apiHandler(async (request: NextRequest): Promise => { const rawParams = Object.fromEntries( Object.keys(getCommitQueryParamsSchema.shape).map(key => [ diff --git a/packages/web/src/app/api/(server)/commits/authors/route.ts b/packages/web/src/app/api/(server)/commits/authors/route.ts index a090f5cb7..d5f2ed02e 100644 --- a/packages/web/src/app/api/(server)/commits/authors/route.ts +++ b/packages/web/src/app/api/(server)/commits/authors/route.ts @@ -6,6 +6,7 @@ import { serviceErrorResponse, queryParamsSchemaValidationError } from "@/lib/se import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to listCommitAuthors() which calls withOptionalAuth export const GET = apiHandler(async (request: NextRequest): Promise => { const rawParams = Object.fromEntries( Object.keys(listCommitAuthorsQueryParamsSchema.shape).map(key => [ diff --git a/packages/web/src/app/api/(server)/commits/route.ts b/packages/web/src/app/api/(server)/commits/route.ts index e45bdea88..fbf2c04f6 100644 --- a/packages/web/src/app/api/(server)/commits/route.ts +++ b/packages/web/src/app/api/(server)/commits/route.ts @@ -6,6 +6,7 @@ import { serviceErrorResponse, queryParamsSchemaValidationError } from "@/lib/se import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to listCommits() which calls withOptionalAuth export const GET = apiHandler(async (request: NextRequest): Promise => { const rawParams = Object.fromEntries( Object.keys(listCommitsQueryParamsSchema.shape).map(key => [ diff --git a/packages/web/src/app/api/(server)/diff/route.ts b/packages/web/src/app/api/(server)/diff/route.ts index 3e537e00d..eee1f30b7 100644 --- a/packages/web/src/app/api/(server)/diff/route.ts +++ b/packages/web/src/app/api/(server)/diff/route.ts @@ -5,6 +5,7 @@ import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/se import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to getDiff() which calls withOptionalAuth export const GET = apiHandler(async (request: NextRequest): Promise => { const rawParams = Object.fromEntries( Object.keys(getDiffRequestSchema.shape).map(key => [ diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts index 89eb29b5a..8f9392d1b 100644 --- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts +++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts @@ -4,6 +4,7 @@ import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants // RFC 8414: OAuth 2.0 Authorization Server Metadata // @see: https://datatracker.ietf.org/doc/html/rfc8414 +// eslint-disable-next-line authz/require-auth-wrapper -- RFC 8414 public metadata endpoint export const GET = oauthApiHandler(async () => { if (!hasEntitlement('oauth')) { return Response.json( diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts index 17f4b843c..2cffed45a 100644 --- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts +++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts @@ -10,6 +10,7 @@ const PROTECTED_RESOURCES = new Set([ 'api/mcp' ]); +// eslint-disable-next-line authz/require-auth-wrapper -- RFC 9728 public metadata endpoint export const GET = oauthApiHandler(async (_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => { if (!hasEntitlement('oauth')) { return Response.json( diff --git a/packages/web/src/app/api/(server)/ee/accountPermissionSyncJobStatus/route.ts b/packages/web/src/app/api/(server)/ee/accountPermissionSyncJobStatus/route.ts index ccfe9575f..3d6bd20ba 100644 --- a/packages/web/src/app/api/(server)/ee/accountPermissionSyncJobStatus/route.ts +++ b/packages/web/src/app/api/(server)/ee/accountPermissionSyncJobStatus/route.ts @@ -10,6 +10,7 @@ const queryParamsSchema = z.object({ jobId: z.string(), }); +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to getAccountSyncStatus() which calls withAuth export const GET = apiHandler(async (request: NextRequest) => { const rawParams = { jobId: request.nextUrl.searchParams.get('jobId') ?? undefined, diff --git a/packages/web/src/app/api/(server)/ee/audit/route.ts b/packages/web/src/app/api/(server)/ee/audit/route.ts index 57491bf79..b17d552c6 100644 --- a/packages/web/src/app/api/(server)/ee/audit/route.ts +++ b/packages/web/src/app/api/(server)/ee/audit/route.ts @@ -23,6 +23,7 @@ const auditQueryParamsSchema = auditQueryParamsBaseSchema.refine( { message: "'since' must be before 'until'", path: ["since"] } ); +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to fetchAuditRecords() which calls withAuth + withMinimumOrgRole(OWNER) export const GET = apiHandler(async (request: NextRequest) => { const entitlements = getEntitlements(); if (!entitlements.includes('audit')) { diff --git a/packages/web/src/app/api/(server)/ee/oauth/register/route.ts b/packages/web/src/app/api/(server)/ee/oauth/register/route.ts index a69f8291e..65d96def8 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/register/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/register/route.ts @@ -14,6 +14,7 @@ const registerRequestSchema = z.object({ logo_uri: z.string().url().nullish(), }); +// eslint-disable-next-line authz/require-auth-wrapper -- RFC 7591 dynamic client registration, intentionally unauthenticated export const POST = oauthApiHandler(async (request: NextRequest) => { if (!hasEntitlement('oauth')) { return Response.json( diff --git a/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts b/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts index 468c6ec78..c406eefa7 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts @@ -7,6 +7,7 @@ import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants // RFC 7009: OAuth 2.0 Token Revocation // Always returns 200 regardless of whether the token existed. // @see: https://datatracker.ietf.org/doc/html/rfc7009 +// eslint-disable-next-line authz/require-auth-wrapper -- RFC 7009 token revocation, no user session required export const POST = oauthApiHandler(async (request: NextRequest) => { if (!hasEntitlement('oauth')) { return Response.json( diff --git a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts index ae93952d4..f5ee3d8cd 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts @@ -7,6 +7,7 @@ import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants // OAuth 2.0 Token Endpoint // Supports grant_type=authorization_code with PKCE (RFC 7636). // @see: https://datatracker.ietf.org/doc/html/rfc6749#section-3.2 +// eslint-disable-next-line authz/require-auth-wrapper -- OAuth token endpoint, authenticated via PKCE code / refresh token, not user session export const POST = oauthApiHandler(async (request: NextRequest) => { if (!hasEntitlement('oauth')) { return Response.json( diff --git a/packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts index c38ef2239..ed78ea086 100644 --- a/packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts +++ b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts @@ -8,6 +8,7 @@ import { getPermissionSyncStatus } from "./api"; * Returns whether a user has a account that has it's permissions * synced for the first time. */ +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to getPermissionSyncStatus() which calls withAuth export const GET = apiHandler(async () => { const result = await getPermissionSyncStatus(); diff --git a/packages/web/src/app/api/(server)/files/route.ts b/packages/web/src/app/api/(server)/files/route.ts index d054e300f..2264677f2 100644 --- a/packages/web/src/app/api/(server)/files/route.ts +++ b/packages/web/src/app/api/(server)/files/route.ts @@ -6,6 +6,7 @@ import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/se import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to getFiles() which calls withOptionalAuth export const POST = apiHandler(async (request: NextRequest) => { const body = await request.json(); const parsed = await getFilesRequestSchema.safeParseAsync(body); diff --git a/packages/web/src/app/api/(server)/find_definitions/route.ts b/packages/web/src/app/api/(server)/find_definitions/route.ts index 4a3d718a3..343189fb1 100644 --- a/packages/web/src/app/api/(server)/find_definitions/route.ts +++ b/packages/web/src/app/api/(server)/find_definitions/route.ts @@ -7,6 +7,7 @@ import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/se import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to findSearchBasedSymbolDefinitions() which calls withOptionalAuth export const POST = apiHandler(async (request: NextRequest) => { const body = await request.json(); const parsed = await findRelatedSymbolsRequestSchema.safeParseAsync(body); diff --git a/packages/web/src/app/api/(server)/find_references/route.ts b/packages/web/src/app/api/(server)/find_references/route.ts index 3fd6a1188..0a9112c71 100644 --- a/packages/web/src/app/api/(server)/find_references/route.ts +++ b/packages/web/src/app/api/(server)/find_references/route.ts @@ -5,6 +5,7 @@ import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/se import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to findSearchBasedSymbolReferences() which calls withOptionalAuth export const POST = apiHandler(async (request: NextRequest) => { const body = await request.json(); const parsed = await findRelatedSymbolsRequestSchema.safeParseAsync(body); diff --git a/packages/web/src/app/api/(server)/health/route.ts b/packages/web/src/app/api/(server)/health/route.ts index 8c787a4e6..df4db2212 100644 --- a/packages/web/src/app/api/(server)/health/route.ts +++ b/packages/web/src/app/api/(server)/health/route.ts @@ -5,6 +5,7 @@ import { createLogger } from "@sourcebot/shared"; const logger = createLogger('health-check'); +// eslint-disable-next-line authz/require-auth-wrapper -- public health check, no user data returned export const GET = apiHandler(async () => { logger.debug('health check'); return Response.json({ status: 'ok' }); diff --git a/packages/web/src/app/api/(server)/mcp/route.ts b/packages/web/src/app/api/(server)/mcp/route.ts index a4225e20e..11a6adeb5 100644 --- a/packages/web/src/app/api/(server)/mcp/route.ts +++ b/packages/web/src/app/api/(server)/mcp/route.ts @@ -133,6 +133,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => { // supported. Per the MCP Streamable HTTP spec, servers that do not offer a GET SSE // stream MUST return 405 Method Not Allowed. // @see: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#listening-for-messages-from-the-server +// eslint-disable-next-line authz/require-auth-wrapper -- MCP spec mandates 405 for GET when SSE stream is unsupported; no user data export const GET = apiHandler(async (_request: NextRequest) => { return new Response(null, { status: StatusCodes.METHOD_NOT_ALLOWED, diff --git a/packages/web/src/app/api/(server)/openapi.json/route.ts b/packages/web/src/app/api/(server)/openapi.json/route.ts index 6d44fd0e6..3d97d5852 100644 --- a/packages/web/src/app/api/(server)/openapi.json/route.ts +++ b/packages/web/src/app/api/(server)/openapi.json/route.ts @@ -9,6 +9,7 @@ async function loadOpenApiDocument() { return JSON.parse(await fs.readFile(openApiPath, 'utf8')); } +// eslint-disable-next-line authz/require-auth-wrapper -- public OpenAPI spec, intentionally unauthenticated export const GET = apiHandler(async () => { const document = await loadOpenApiDocument(); diff --git a/packages/web/src/app/api/(server)/repo-status/[repoId]/route.ts b/packages/web/src/app/api/(server)/repo-status/[repoId]/route.ts index 12b8aa724..4b5389208 100644 --- a/packages/web/src/app/api/(server)/repo-status/[repoId]/route.ts +++ b/packages/web/src/app/api/(server)/repo-status/[repoId]/route.ts @@ -4,6 +4,7 @@ import { serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to getRepoInfo() which calls withOptionalAuth export const GET = apiHandler(async ( _request: NextRequest, { params }: { params: Promise<{ repoId: string }> } diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index cbc0e98bf..ae95b3481 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -6,6 +6,7 @@ import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; import { listRepos } from "./listReposApi"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to listRepos() which calls withOptionalAuth export const GET = apiHandler(async (request: NextRequest) => { const rawParams = Object.fromEntries( Object.keys(listReposQueryParamsSchema.shape).map(key => [ diff --git a/packages/web/src/app/api/(server)/search/route.ts b/packages/web/src/app/api/(server)/search/route.ts index e15184297..18986e1eb 100644 --- a/packages/web/src/app/api/(server)/search/route.ts +++ b/packages/web/src/app/api/(server)/search/route.ts @@ -6,6 +6,7 @@ import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/se import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to search() which calls withOptionalAuth export const POST = apiHandler(async (request: NextRequest) => { const body = await request.json(); const parsed = await searchRequestSchema.safeParseAsync(body); diff --git a/packages/web/src/app/api/(server)/source/route.ts b/packages/web/src/app/api/(server)/source/route.ts index 7c1cf7809..b48a9f410 100644 --- a/packages/web/src/app/api/(server)/source/route.ts +++ b/packages/web/src/app/api/(server)/source/route.ts @@ -7,6 +7,7 @@ import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/se import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to getFileSource() which calls withOptionalAuth export const GET = apiHandler(async (request: NextRequest) => { const rawParams = Object.fromEntries( Object.keys(fileSourceRequestSchema.shape).map(key => [ diff --git a/packages/web/src/app/api/(server)/stream_search/route.ts b/packages/web/src/app/api/(server)/stream_search/route.ts index 5c7ab92cb..3f16aa984 100644 --- a/packages/web/src/app/api/(server)/stream_search/route.ts +++ b/packages/web/src/app/api/(server)/stream_search/route.ts @@ -6,6 +6,7 @@ import { requestBodySchemaValidationError, serviceErrorResponse } from '@/lib/se import { isServiceError } from '@/lib/utils'; import { NextRequest } from 'next/server'; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to streamSearch() which calls withOptionalAuth export const POST = apiHandler(async (request: NextRequest) => { const body = await request.json(); const parsed = await searchRequestSchema.safeParseAsync(body); diff --git a/packages/web/src/app/api/(server)/tree/route.ts b/packages/web/src/app/api/(server)/tree/route.ts index 79ffc5203..542e6537e 100644 --- a/packages/web/src/app/api/(server)/tree/route.ts +++ b/packages/web/src/app/api/(server)/tree/route.ts @@ -7,6 +7,7 @@ import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/se import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to getTree() which calls withOptionalAuth export const POST = apiHandler(async (request: NextRequest) => { const body = await request.json(); const parsed = await getTreeRequestSchema.safeParseAsync(body); diff --git a/packages/web/src/app/api/(server)/version/route.ts b/packages/web/src/app/api/(server)/version/route.ts index 2d87ff3c2..dc02055b5 100644 --- a/packages/web/src/app/api/(server)/version/route.ts +++ b/packages/web/src/app/api/(server)/version/route.ts @@ -9,6 +9,7 @@ import { GetVersionResponse } from "@/lib/types"; // @see: https://nextjs.org/docs/14/app/building-your-application/routing/route-handlers#caching export const dynamic = "force-dynamic"; +// eslint-disable-next-line authz/require-auth-wrapper -- public Sourcebot version string, no user data export const GET = apiHandler(async () => { return Response.json({ version: SOURCEBOT_VERSION, diff --git a/packages/web/src/app/api/(server)/webhook/route.ts b/packages/web/src/app/api/(server)/webhook/route.ts index e7e380b49..4ed57bf5f 100644 --- a/packages/web/src/app/api/(server)/webhook/route.ts +++ b/packages/web/src/app/api/(server)/webhook/route.ts @@ -130,6 +130,7 @@ if (env.GITLAB_REVIEW_AGENT_TOKEN) { } } +// eslint-disable-next-line authz/require-auth-wrapper -- authenticated via GitHub App / GitLab webhook secrets, not user session export const POST = async (request: NextRequest) => { const body = await request.json(); const headers = Object.fromEntries(Array.from(request.headers.entries(), ([key, value]) => [key.toLowerCase(), value])); diff --git a/packages/web/src/app/api/minidenticon/route.ts b/packages/web/src/app/api/minidenticon/route.ts index 139683b19..9d660d405 100644 --- a/packages/web/src/app/api/minidenticon/route.ts +++ b/packages/web/src/app/api/minidenticon/route.ts @@ -7,6 +7,7 @@ import { apiHandler } from '@/lib/apiHandler'; // Generates a minidenticon avatar PNG from an email address. // Used as a fallback avatar in emails where data URIs aren't supported. +// eslint-disable-next-line authz/require-auth-wrapper -- public identicon generator, no user data returned export const GET = apiHandler(async (request: NextRequest) => { const email = request.nextUrl.searchParams.get('email'); if (!email) { diff --git a/packages/web/src/app/api/repos/[repoId]/image/route.ts b/packages/web/src/app/api/repos/[repoId]/image/route.ts index bcc1911ad..0c12c9e44 100644 --- a/packages/web/src/app/api/repos/[repoId]/image/route.ts +++ b/packages/web/src/app/api/repos/[repoId]/image/route.ts @@ -3,6 +3,7 @@ import { apiHandler } from "@/lib/apiHandler"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to getRepoImage() action which calls withOptionalAuth export const GET = apiHandler(async ( _request: NextRequest, { params }: { params: Promise<{ repoId: string }> } diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts index b319b5170..1e10f2397 100644 --- a/packages/web/src/app/invite/actions.ts +++ b/packages/web/src/app/invite/actions.ts @@ -13,6 +13,7 @@ import { getAuditService } from "@/ee/features/audit/factory"; const auditService = getAuditService(); +// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since withAuth requires a user-to-org link this call is establishing export const joinOrganization = async (inviteLinkId?: string) => sew(async () => { const authResult = await getAuthenticatedUser(); if (!authResult) { @@ -71,6 +72,7 @@ export const joinOrganization = async (inviteLinkId?: string) => sew(async () => } }); +// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since withAuth requires a user-to-org link this call is establishing export const redeemInvite = async (inviteId: string): Promise<{ success: boolean; } | ServiceError> => sew(async () => { const authResult = await getAuthenticatedUser(); if (!authResult) { @@ -161,6 +163,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean }); +// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since the invitee is not yet a member export const getInviteInfo = async (inviteId: string) => sew(async () => { const authResult = await getAuthenticatedUser(); if (!authResult) { diff --git a/packages/web/src/ee/features/sso/actions.ts b/packages/web/src/ee/features/sso/actions.ts index 4c71ade14..d79466a6b 100644 --- a/packages/web/src/ee/features/sso/actions.ts +++ b/packages/web/src/ee/features/sso/actions.ts @@ -100,6 +100,7 @@ export const unlinkLinkedAccountProvider = async (provider: string) => sew(() => ) ); +// eslint-disable-next-line authz/require-auth-wrapper -- UI-only preference cookie, no DB access export const skipOptionalProvidersLink = async () => sew(async () => { const cookieStore = await cookies(); cookieStore.set(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME, 'true', { diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts index dac24d155..712a95d01 100644 --- a/packages/web/src/features/chat/actions.ts +++ b/packages/web/src/features/chat/actions.ts @@ -550,6 +550,7 @@ export const submitFeedback = async ({ }) ) +// eslint-disable-next-line authz/require-auth-wrapper -- returns identity provider metadata for the login wall, consulted before auth export const getAskGhLoginWallData = async () => sew(async () => { const isEnabled = env.EXPERIMENT_ASK_GH_ENABLED === 'true'; if (!isEnabled) { diff --git a/packages/web/tools/eslint-plugin-local/index.mjs b/packages/web/tools/eslint-plugin-local/index.mjs new file mode 100644 index 000000000..903889b67 --- /dev/null +++ b/packages/web/tools/eslint-plugin-local/index.mjs @@ -0,0 +1,12 @@ +import requireAuthWrapper from './rules/requireAuthWrapper.mjs'; + +const plugin = { + meta: { + name: 'eslint-plugin-authz-local', + }, + rules: { + 'require-auth-wrapper': requireAuthWrapper, + }, +}; + +export default plugin; diff --git a/packages/web/tools/eslint-plugin-local/rules/requireAuthWrapper.mjs b/packages/web/tools/eslint-plugin-local/rules/requireAuthWrapper.mjs new file mode 100644 index 000000000..13f6073dc --- /dev/null +++ b/packages/web/tools/eslint-plugin-local/rules/requireAuthWrapper.mjs @@ -0,0 +1,251 @@ +// ESLint rule: authz/require-auth-wrapper +// +// Flags API route handlers and server-action exports that don't textually +// reference one of the recognized auth wrappers (`withAuth(` or +// `withOptionalAuth(`) inside the exported function body. This is a +// boundary-only check — it does not trace through helper functions. If a +// handler legitimately delegates auth into a helper, suppress with: +// +// // eslint-disable-next-line authz/require-auth-wrapper -- +// +// `withMinimumOrgRole` is intentionally NOT recognized — it nests inside +// `withAuth` and means nothing on its own. + +const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']); +const AUTH_PATTERN = /\b(?:withAuth|withOptionalAuth)\s*\(/; +const USE_SERVER = 'use server'; + +const isStringLiteral = (node) => + node?.type === 'Literal' && typeof node.value === 'string'; + +const isUseServerDirective = (stmt) => + stmt?.type === 'ExpressionStatement' && + isStringLiteral(stmt.expression) && + stmt.expression.value === USE_SERVER; + +const hasUseServerInDirectivePrologue = (statements) => { + if (!statements) { + return false; + } + for (const stmt of statements) { + if (isUseServerDirective(stmt)) { + return true; + } + const isDirective = + stmt.type === 'ExpressionStatement' && isStringLiteral(stmt.expression); + if (!isDirective) { + return false; + } + } + return false; +}; + +const getFunctionBody = (node) => { + if (!node) { + return null; + } + if ( + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionDeclaration' + ) { + return node.body; + } + return null; +}; + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Require an auth wrapper (withAuth / withOptionalAuth) on API route handlers and server actions', + }, + schema: [], + messages: { + missingWrapperRoute: + 'Route handler "{{ name }}" must call withAuth() or withOptionalAuth() in its body. ' + + 'withMinimumOrgRole alone is not sufficient. If this endpoint is intentionally public, ' + + 'suppress with: // eslint-disable-next-line authz/require-auth-wrapper -- . ' + + 'Example: export const GET = apiHandler(async (req) => withAuth(async ({ user, prisma }) => { ... }));', + missingWrapperAction: + 'Server action "{{ name }}" must call withAuth() or withOptionalAuth() in its body. ' + + 'If this action is intentionally public, suppress with: ' + + '// eslint-disable-next-line authz/require-auth-wrapper -- . ' + + 'Example: export const foo = async () => sew(() => withAuth(async ({ user }) => { ... }));', + }, + }, + + create(context) { + const filename = + (typeof context.filename === 'string' ? context.filename : null) ?? + (typeof context.getFilename === 'function' ? context.getFilename() : ''); + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + const normalized = filename.replace(/\\/g, '/'); + const isRouteFile = /\/app\/api\/.*\/route\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(normalized); + + const program = sourceCode.ast; + const fileLevelUseServer = hasUseServerInDirectivePrologue(program.body); + + // Bail early on files that can't possibly host a route or action. + if ( + !isRouteFile && + !fileLevelUseServer && + !sourceCode.text.includes(USE_SERVER) + ) { + return {}; + } + + const functionLevelUseServer = (fnBody) => { + if (!fnBody || fnBody.type !== 'BlockStatement') { + return false; + } + return hasUseServerInDirectivePrologue(fnBody.body); + }; + + // Resolve a local name (used by `export { handler as GET }`) to the text + // of its initializer / declaration so we can grep it for the wrapper. + const findLocalDeclarationText = (localName) => { + for (const stmt of program.body) { + if (stmt.type === 'VariableDeclaration') { + for (const decl of stmt.declarations) { + if (decl.id.type === 'Identifier' && decl.id.name === localName && decl.init) { + return sourceCode.getText(decl.init); + } + } + } else if ( + stmt.type === 'FunctionDeclaration' && + stmt.id?.name === localName + ) { + return sourceCode.getText(stmt); + } else if ( + stmt.type === 'ExportNamedDeclaration' && + stmt.declaration?.type === 'VariableDeclaration' + ) { + for (const decl of stmt.declaration.declarations) { + if (decl.id.type === 'Identifier' && decl.id.name === localName && decl.init) { + return sourceCode.getText(decl.init); + } + } + } else if ( + stmt.type === 'ExportNamedDeclaration' && + stmt.declaration?.type === 'FunctionDeclaration' && + stmt.declaration.id?.name === localName + ) { + return sourceCode.getText(stmt.declaration); + } + } + return null; + }; + + const reportIfMissing = (reportNode, name, bodyText, kind) => { + if (bodyText && AUTH_PATTERN.test(bodyText)) { + return; + } + context.report({ + node: reportNode, + messageId: kind === 'route' ? 'missingWrapperRoute' : 'missingWrapperAction', + data: { name }, + }); + }; + + const classifyExport = (name, valueNode) => { + if (isRouteFile && HTTP_METHODS.has(name)) { + return 'route'; + } + if (fileLevelUseServer) { + return 'action'; + } + const body = getFunctionBody(valueNode); + if (body && functionLevelUseServer(body)) { + return 'action'; + } + return null; + }; + + return { + ExportNamedDeclaration(node) { + // export const X = ..., export async function X() {} + if (node.declaration?.type === 'VariableDeclaration') { + for (const declarator of node.declaration.declarations) { + if (declarator.id.type === 'Identifier') { + const name = declarator.id.name; + const kind = classifyExport(name, declarator.init); + if (!kind) { + continue; + } + const text = declarator.init + ? sourceCode.getText(declarator.init) + : ''; + reportIfMissing(node, name, text, kind); + } else if (declarator.id.type === 'ObjectPattern') { + // export const { GET, POST } = handlers; + const initText = declarator.init + ? sourceCode.getText(declarator.init) + : ''; + for (const prop of declarator.id.properties) { + if ( + prop.type !== 'Property' || + prop.key.type !== 'Identifier' + ) { + continue; + } + const name = prop.key.name; + const kind = classifyExport(name, declarator.init); + if (!kind) { + continue; + } + reportIfMissing(node, name, initText, kind); + } + } + } + return; + } + + if (node.declaration?.type === 'FunctionDeclaration') { + const id = node.declaration.id; + if (!id) { + return; + } + const kind = classifyExport(id.name, node.declaration); + if (!kind) { + return; + } + const text = sourceCode.getText(node.declaration); + reportIfMissing(node, id.name, text, kind); + return; + } + + // export { handler as GET, ... } + if (node.specifiers?.length && !node.source) { + for (const specifier of node.specifiers) { + if (specifier.type !== 'ExportSpecifier') { + continue; + } + const exportedName = + specifier.exported.type === 'Identifier' + ? specifier.exported.name + : null; + const localName = + specifier.local.type === 'Identifier' + ? specifier.local.name + : null; + if (!exportedName || !localName) { + continue; + } + const kind = classifyExport(exportedName, null); + if (!kind) { + continue; + } + const localText = findLocalDeclarationText(localName) ?? ''; + reportIfMissing(specifier, exportedName, localText, kind); + } + } + }, + }; + }, +}; + +export default rule; diff --git a/packages/web/tools/eslint-plugin-local/rules/requireAuthWrapper.test.mjs b/packages/web/tools/eslint-plugin-local/rules/requireAuthWrapper.test.mjs new file mode 100644 index 000000000..46c5d01ec --- /dev/null +++ b/packages/web/tools/eslint-plugin-local/rules/requireAuthWrapper.test.mjs @@ -0,0 +1,193 @@ +import { describe, it } from 'vitest'; +import { RuleTester } from 'eslint'; +import tsParser from '@typescript-eslint/parser'; +import rule from './requireAuthWrapper.mjs'; + +const ROUTE_FILE = '/repo/packages/web/src/app/api/something/route.ts'; +const ACTION_FILE = '/repo/packages/web/src/features/example/actions.ts'; +const PLAIN_TS_FILE = '/repo/packages/web/src/lib/utils.ts'; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: tsParser, + ecmaVersion: 2022, + sourceType: 'module', + }, +}); + +describe('authz/require-auth-wrapper', () => { + it('runs the rule against fixtures', () => { + ruleTester.run('require-auth-wrapper', rule, { + valid: [ + // 1. Route handler with withAuth → pass + { + filename: ROUTE_FILE, + code: ` + import { withAuth } from '@/middleware/withAuth'; + export const GET = async () => { + return withAuth(async ({ user }) => ({ id: user.id })); + }; + `, + }, + // 2. Route handler with withOptionalAuth → pass + { + filename: ROUTE_FILE, + code: ` + import { withOptionalAuth } from '@/middleware/withAuth'; + export const POST = async (req) => { + return withOptionalAuth(async ({ org }) => ({ orgId: org.id })); + }; + `, + }, + // 5. Route handler with allowlist comment → pass + // RuleTester prefixes the rule ID with `rule-to-test/`; in real + // usage the directive would be `authz/require-auth-wrapper`. + { + filename: ROUTE_FILE, + code: ` + // eslint-disable-next-line rule-to-test/require-auth-wrapper -- public health check + export const GET = async () => new Response('ok'); + `, + }, + // 6. Server action with withAuth → pass + { + filename: ACTION_FILE, + code: ` + 'use server'; + import { withAuth } from '@/middleware/withAuth'; + export const doThing = async () => withAuth(async ({ user }) => user.id); + `, + }, + // 8. Non-route, non-action TS file → not checked + { + filename: PLAIN_TS_FILE, + code: ` + export const helper = () => 42; + export function noop() { return null; } + `, + }, + // Per-function 'use server' WITH withAuth → pass + { + filename: PLAIN_TS_FILE, + code: ` + import { withAuth } from '@/middleware/withAuth'; + export const action = async () => { + 'use server'; + return withAuth(async ({ user }) => user.id); + }; + `, + }, + // Route handler wrapped in apiHandler with nested withAuth → pass + { + filename: ROUTE_FILE, + code: ` + import { apiHandler } from '@/lib/apiHandler'; + import { withAuth } from '@/middleware/withAuth'; + export const PATCH = apiHandler(async (req) => + withAuth(async ({ prisma }) => prisma.user.findMany()) + ); + `, + }, + // 'use server' file with a const that is not exported and is not a function → not flagged + { + filename: ACTION_FILE, + code: ` + 'use server'; + const PRIVATE_CONSTANT = 'foo'; + export const myAction = async () => { + const { withAuth } = await import('@/middleware/withAuth'); + return withAuth(async () => null); + }; + `, + }, + ], + invalid: [ + // 3. Route handler with neither → fail + { + filename: ROUTE_FILE, + code: ` + export const GET = async () => new Response('hello'); + `, + errors: [{ messageId: 'missingWrapperRoute', data: { name: 'GET' } }], + }, + // 4. Route handler with only withMinimumOrgRole (no withAuth) → fail + { + filename: ROUTE_FILE, + code: ` + import { withMinimumOrgRole } from '@/middleware/withMinimumOrgRole'; + export const DELETE = async ({ role }) => + withMinimumOrgRole(role, 'OWNER', async () => null); + `, + errors: [{ messageId: 'missingWrapperRoute', data: { name: 'DELETE' } }], + }, + // 7. Server action without withAuth → fail + { + filename: ACTION_FILE, + code: ` + 'use server'; + export const leakyAction = async () => { + return { everything: 'about everyone' }; + }; + `, + errors: [{ messageId: 'missingWrapperAction', data: { name: 'leakyAction' } }], + }, + // Per-function 'use server' without withAuth → fail + { + filename: PLAIN_TS_FILE, + code: ` + export const action = async () => { + 'use server'; + return { data: 'leak' }; + }; + `, + errors: [{ messageId: 'missingWrapperAction', data: { name: 'action' } }], + }, + // export const { GET, POST } = handlers — both flagged + { + filename: ROUTE_FILE, + code: ` + import { handlers } from '@/auth'; + export const { GET, POST } = handlers; + `, + errors: [ + { messageId: 'missingWrapperRoute', data: { name: 'GET' } }, + { messageId: 'missingWrapperRoute', data: { name: 'POST' } }, + ], + }, + // export { handler as GET, handler as POST } — both flagged when local does not call withAuth + { + filename: ROUTE_FILE, + code: ` + const handler = () => new Response(null, { status: 404 }); + export { handler as GET, handler as POST }; + `, + errors: [ + { messageId: 'missingWrapperRoute', data: { name: 'GET' } }, + { messageId: 'missingWrapperRoute', data: { name: 'POST' } }, + ], + }, + // exported function declaration in a 'use server' file → fail + { + filename: ACTION_FILE, + code: ` + 'use server'; + export async function fetchSecret() { + return 'secret'; + } + `, + errors: [{ messageId: 'missingWrapperAction', data: { name: 'fetchSecret' } }], + }, + // withAuth appears outside the export body (sibling helper) → fail + { + filename: ROUTE_FILE, + code: ` + import { withAuth } from '@/middleware/withAuth'; + const _unused = () => withAuth(async () => null); + export const GET = async () => new Response('leak'); + `, + errors: [{ messageId: 'missingWrapperRoute', data: { name: 'GET' } }], + }, + ], + }); + }); +});