diff --git a/packages/core/src/helpers/request-auth.test.ts b/packages/core/src/helpers/request-auth.test.ts new file mode 100644 index 000000000..10d99d0e2 --- /dev/null +++ b/packages/core/src/helpers/request-auth.test.ts @@ -0,0 +1,115 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { DEFAULT_SITE } from '@dd/core/constants'; +import { + DEFAULT_API_AUTH_MISSING_AUTH_MESSAGE, + MissingRequestAuthError, + hasValidAppApiKey, + withApiAuth, + withBaseUrl, +} from '@dd/core/helpers/request-auth'; +import type { AuthOptionsWithDefaults } from '@dd/core/types'; +import { getMockLogger, mockLogFn } from '@dd/tests/_jest/helpers/mocks'; + +describe('Core - request auth', () => { + const auth: AuthOptionsWithDefaults = { + apiKey: 'api-key', + appKey: 'app-key', + site: DEFAULT_SITE, + }; + const log = getMockLogger(); + + beforeEach(() => { + mockLogFn.mockClear(); + }); + + test('Should identify valid APP/API key auth', () => { + expect(hasValidAppApiKey(auth)).toBe(true); + expect(hasValidAppApiKey({ apiKey: 'api-key' })).toBe(false); + }); + + test('Should inject API and APP key auth before calling request', async () => { + const request = jest.fn().mockResolvedValue('ok'); + const requestWithAuth = withApiAuth({ auth, log })(request); + expect(() => requestWithAuth.assertAuthConfigured()).not.toThrow(); + + await expect(requestWithAuth({ url: 'https://api.datadoghq.com/test' })).resolves.toBe( + 'ok', + ); + + expect(request).toHaveBeenCalledWith({ + url: 'https://api.datadoghq.com/test', + auth: { apiKey: 'api-key', appKey: 'app-key' }, + }); + }); + + test('Should warn and reject when API key auth is selected without required credentials', async () => { + const request = jest.fn(); + const requestWithAuth = withApiAuth({ + auth: {}, + log, + missingAuthMessage: 'Missing app/api keys.', + })(request); + + expect(mockLogFn).not.toHaveBeenCalled(); + + await expect(requestWithAuth({ url: 'https://api.datadoghq.com/test' })).rejects.toThrow( + MissingRequestAuthError, + ); + expect(mockLogFn).toHaveBeenCalledWith('Missing app/api keys.', 'warn'); + expect(request).not.toHaveBeenCalled(); + }); + + test('Should assert missing API key auth before calling request', () => { + const request = jest.fn(); + const requestWithAuth = withApiAuth({ + auth: {}, + log, + missingAuthMessage: 'Missing app/api keys.', + })(request); + + expect(() => requestWithAuth.assertAuthConfigured()).toThrow(MissingRequestAuthError); + expect(mockLogFn).toHaveBeenCalledWith('Missing app/api keys.', 'warn'); + expect(request).not.toHaveBeenCalled(); + }); + + test('Should use the API auth default missing auth message', async () => { + const request = jest.fn(); + const requestWithAuth = withApiAuth({ + auth: {}, + log, + })(request); + + expect(mockLogFn).not.toHaveBeenCalled(); + + await expect(requestWithAuth({ url: 'https://api.datadoghq.com/test' })).rejects.toThrow( + DEFAULT_API_AUTH_MISSING_AUTH_MESSAGE, + ); + expect(mockLogFn).toHaveBeenCalledWith(DEFAULT_API_AUTH_MISSING_AUTH_MESSAGE, 'warn'); + expect(request).not.toHaveBeenCalled(); + }); + + test('Should prefix relative request URLs with a base URL', async () => { + const request = jest.fn().mockResolvedValue('ok'); + const requestWithBaseUrl = withBaseUrl('https://api.datadoghq.com')(request); + + await expect(requestWithBaseUrl({ url: '/api/v2/test' })).resolves.toBe('ok'); + + expect(request).toHaveBeenCalledWith({ + url: 'https://api.datadoghq.com/api/v2/test', + }); + }); + + test('Should preserve absolute request URLs', async () => { + const request = jest.fn().mockResolvedValue('ok'); + const requestWithBaseUrl = withBaseUrl('https://api.datadoghq.com')(request); + + await expect(requestWithBaseUrl({ url: 'https://custom.apps/upload' })).resolves.toBe('ok'); + + expect(request).toHaveBeenCalledWith({ + url: 'https://custom.apps/upload', + }); + }); +}); diff --git a/packages/core/src/helpers/request-auth.ts b/packages/core/src/helpers/request-auth.ts new file mode 100644 index 000000000..fb89506a5 --- /dev/null +++ b/packages/core/src/helpers/request-auth.ts @@ -0,0 +1,76 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { AuthOptionsWithDefaults, Logger, RequestAuthOptions, RequestOpts } from '../types'; + +export type RequestOptsWithoutAuth = Omit; +export type RequestFunction = (opts: RequestOpts) => Promise; +export type AuthenticatedRequestFunction = ((opts: RequestOptsWithoutAuth) => Promise) & { + assertAuthConfigured: () => void; +}; + +export const DEFAULT_API_AUTH_MISSING_AUTH_MESSAGE = + 'Auth credentials not configured. Set DD_API_KEY and DD_APP_KEY.'; + +export class MissingRequestAuthError extends Error { + constructor(message = DEFAULT_API_AUTH_MISSING_AUTH_MESSAGE) { + super(message); + } +} + +export const hasValidAppApiKey = (auth: Pick) => + Boolean(auth.apiKey && auth.appKey); + +const isAbsoluteUrl = (url: string) => /^https?:\/\//.test(url); + +export const withBaseUrl = + (baseUrl: string) => + (request: RequestFunction): RequestFunction => + async (opts: RequestOpts) => { + const normalizedBaseUrl = baseUrl.replace(/\/$/, ''); + const url = isAbsoluteUrl(opts.url) + ? opts.url + : `${normalizedBaseUrl}${opts.url.startsWith('/') ? '' : '/'}${opts.url}`; + + return request({ ...opts, url }); + }; + +export const withApiAuth = + ({ + auth, + log, + missingAuthMessage = DEFAULT_API_AUTH_MISSING_AUTH_MESSAGE, + }: { + auth: Pick; + log?: Pick; + missingAuthMessage?: string; + }) => + (request: RequestFunction): AuthenticatedRequestFunction => { + const requestAuth: RequestAuthOptions | undefined = hasValidAppApiKey(auth) + ? { apiKey: auth.apiKey, appKey: auth.appKey } + : undefined; + let didWarn = false; + + const assertAuthConfigured = () => { + if (!requestAuth) { + if (!didWarn) { + log?.warn(missingAuthMessage); + didWarn = true; + } + throw new MissingRequestAuthError(missingAuthMessage); + } + }; + + const requestWithAuth = async (opts: RequestOptsWithoutAuth) => { + assertAuthConfigured(); + + return request({ + ...opts, + auth: requestAuth, + }); + }; + + requestWithAuth.assertAuthConfigured = assertAuthConfigured; + return requestWithAuth; + }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1e6c45234..68f85d550 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -281,9 +281,11 @@ export type OptionsWithDefaults = Assign< export type PluginName = `datadog-${Lowercase}-plugin`; type Data = { data?: BodyInit; headers?: Record }; +export type RequestAuthOptions = Pick; + export type RequestOpts = { url: string; - auth?: Pick; + auth?: RequestAuthOptions; method?: string; getData?: () => Promise | Data; type?: 'json' | 'text'; diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 0ef4bc662..bc8515501 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -211,13 +211,12 @@ describe('Apps Plugin - getPlugins', () => { expect(uploader.uploadArchive).toHaveBeenCalledWith( expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), { - apiKey: '123', - appKey: '123', + appBaseUrl: `https://app.${DEFAULT_SITE}`, bundlerName: 'vite', dryRun: true, identifier: 'repo:app', name: 'test-app', - site: DEFAULT_SITE, + request: expect.any(Function), version: 'FAKE_VERSION', }, expect.anything(), diff --git a/packages/plugins/apps/src/upload.test.ts b/packages/plugins/apps/src/upload.test.ts index 58be236c7..2ede4ec99 100644 --- a/packages/plugins/apps/src/upload.test.ts +++ b/packages/plugins/apps/src/upload.test.ts @@ -5,12 +5,8 @@ import { getData, getIntakeUrl, getReleaseUrl, uploadArchive } from '@dd/apps-plugin/upload'; import { getDDEnvValue } from '@dd/core/helpers/env'; import { getFile } from '@dd/core/helpers/fs'; -import { - createRequestData, - doRequest, - getOriginHeaders, - NB_RETRIES, -} from '@dd/core/helpers/request'; +import type { AuthenticatedRequestFunction } from '@dd/core/helpers/request-auth'; +import { createRequestData, getOriginHeaders, NB_RETRIES } from '@dd/core/helpers/request'; import { getMockLogger, mockLogFn } from '@dd/tests/_jest/helpers/mocks'; import stripAnsi from 'strip-ansi'; @@ -31,7 +27,6 @@ jest.mock('@dd/core/helpers/request', () => { return { ...actual, createRequestData: jest.fn(), - doRequest: jest.fn(), getOriginHeaders: jest.fn(), }; }); @@ -39,7 +34,6 @@ jest.mock('@dd/core/helpers/request', () => { const getDDEnvValueMock = jest.mocked(getDDEnvValue); const createRequestDataMock = jest.mocked(createRequestData); const getFileMock = jest.mocked(getFile); -const doRequestMock = jest.mocked(doRequest); const getOriginHeadersMock = jest.mocked(getOriginHeaders); describe('Apps Plugin - upload', () => { @@ -48,19 +42,22 @@ describe('Apps Plugin - upload', () => { assets: [{ absolutePath: '/tmp/a.js', relativePath: 'a.js' }], size: 1234, }; + const requestMock = Object.assign(jest.fn(), { + assertAuthConfigured: jest.fn(), + }) as jest.Mock & AuthenticatedRequestFunction; const context = { - apiKey: 'api-key', - appKey: 'app-key', bundlerName: 'esbuild', dryRun: false, identifier: 'repo:app', name: 'test-app', - site: 'datadoghq.com', + appBaseUrl: 'https://app.datadoghq.com', + request: requestMock, version: '1.0.0', }; const logger = getMockLogger(); beforeEach(() => { + requestMock.mockReset(); getOriginHeadersMock.mockReturnValue({ 'DD-EVP-ORIGIN': 'origin', 'DD-EVP-ORIGIN-VERSION': '0.0.0', @@ -70,45 +67,21 @@ describe('Apps Plugin - upload', () => { describe('getIntakeUrl', () => { test('Should use environment override when present', () => { getDDEnvValueMock.mockReturnValue('https://custom.apps'); - expect(getIntakeUrl('datadoghq.com', 'my-app')).toBe('https://custom.apps'); + expect(getIntakeUrl('my-app')).toBe('https://custom.apps'); }); - test('Should prefix for all Datadog sites', () => { + test('Should return Apps upload API path', () => { getDDEnvValueMock.mockReturnValue(undefined); - expect(getIntakeUrl('datadoghq.com', 'my-app')).toBe( - 'https://api.datadoghq.com/api/unstable/app-builder-code/apps/my-app/upload', - ); - expect(getIntakeUrl('datadoghq.eu', 'my-app')).toBe( - 'https://api.datadoghq.eu/api/unstable/app-builder-code/apps/my-app/upload', - ); - expect(getIntakeUrl('ddog-gov.com', 'my-app')).toBe( - 'https://api.ddog-gov.com/api/unstable/app-builder-code/apps/my-app/upload', - ); - expect(getIntakeUrl('us5.datadoghq.com', 'my-app')).toBe( - 'https://api.us5.datadoghq.com/api/unstable/app-builder-code/apps/my-app/upload', - ); - expect(getIntakeUrl('dd.datad0g.com', 'my-app')).toBe( - 'https://api.dd.datad0g.com/api/unstable/app-builder-code/apps/my-app/upload', + expect(getIntakeUrl('my-app')).toBe( + '/api/unstable/app-builder-code/apps/my-app/upload', ); }); }); describe('getReleaseUrl', () => { - test('Should prefix for all Datadog sites', () => { - expect(getReleaseUrl('datadoghq.com', 'my-app')).toBe( - 'https://api.datadoghq.com/api/unstable/app-builder-code/apps/my-app/release/live', - ); - expect(getReleaseUrl('datadoghq.eu', 'my-app')).toBe( - 'https://api.datadoghq.eu/api/unstable/app-builder-code/apps/my-app/release/live', - ); - expect(getReleaseUrl('ddog-gov.com', 'my-app')).toBe( - 'https://api.ddog-gov.com/api/unstable/app-builder-code/apps/my-app/release/live', - ); - expect(getReleaseUrl('us5.datadoghq.com', 'my-app')).toBe( - 'https://api.us5.datadoghq.com/api/unstable/app-builder-code/apps/my-app/release/live', - ); - expect(getReleaseUrl('dd.datad0g.com', 'my-app')).toBe( - 'https://api.dd.datad0g.com/api/unstable/app-builder-code/apps/my-app/release/live', + test('Should return Apps release API path', () => { + expect(getReleaseUrl('my-app')).toBe( + '/api/unstable/app-builder-code/apps/my-app/release/live', ); }); }); @@ -186,10 +159,10 @@ describe('Apps Plugin - upload', () => { }); describe('uploadArchive', () => { - test('Should fail when missing apiKey', async () => { + test('Should fail when missing request auth handler', async () => { const { errors, warnings } = await uploadArchive( archive, - { ...context, apiKey: undefined }, + { ...context, request: undefined }, logger, ); expect(errors).toHaveLength(1); @@ -197,7 +170,19 @@ describe('Apps Plugin - upload', () => { 'Missing authentication token, need both app and api keys.', ); expect(warnings).toHaveLength(0); - expect(doRequestMock).not.toHaveBeenCalled(); + expect(requestMock).not.toHaveBeenCalled(); + }); + + test('Should not require authentication for dry runs', async () => { + const { errors, warnings } = await uploadArchive( + archive, + { ...context, request: undefined, dryRun: true }, + logger, + ); + + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + expect(requestMock).not.toHaveBeenCalled(); }); test('Should fail when missing identifier', async () => { @@ -209,7 +194,7 @@ describe('Apps Plugin - upload', () => { expect(errors).toHaveLength(1); expect(errors[0].message).toBe('No app identifier provided'); expect(warnings).toHaveLength(0); - expect(doRequestMock).not.toHaveBeenCalled(); + expect(requestMock).not.toHaveBeenCalled(); }); test('Should log configuration and skip request on dryRun', async () => { @@ -221,7 +206,7 @@ describe('Apps Plugin - upload', () => { expect(errors).toHaveLength(0); expect(warnings).toHaveLength(0); - expect(doRequestMock).not.toHaveBeenCalled(); + expect(requestMock).not.toHaveBeenCalled(); expect(mockLogFn).toHaveBeenCalledWith( expect.stringContaining('Dry run enabled'), 'error', @@ -229,7 +214,7 @@ describe('Apps Plugin - upload', () => { }); test('Should upload archive and log summary', async () => { - doRequestMock.mockResolvedValue({ + requestMock.mockResolvedValue({ version_id: 'v123', application_id: 'app123', app_builder_id: 'builder123', @@ -244,9 +229,8 @@ describe('Apps Plugin - upload', () => { plugin: 'apps', version: '1.0.0', }); - expect(doRequestMock).toHaveBeenCalledWith({ - auth: { apiKey: 'api-key', appKey: 'app-key' }, - url: 'https://api.datadoghq.com/api/unstable/app-builder-code/apps/repo:app/upload', + expect(requestMock).toHaveBeenCalledWith({ + url: '/api/unstable/app-builder-code/apps/repo:app/upload', method: 'POST', type: 'json', getData: expect.any(Function), @@ -259,7 +243,7 @@ describe('Apps Plugin - upload', () => { }); test('Should make PUT request to release version after successful upload', async () => { - doRequestMock + requestMock .mockResolvedValueOnce({ version_id: 'v123', application_id: 'app123', @@ -271,10 +255,9 @@ describe('Apps Plugin - upload', () => { expect(errors).toHaveLength(0); expect(warnings).toHaveLength(0); - expect(doRequestMock).toHaveBeenCalledTimes(2); - expect(doRequestMock).toHaveBeenNthCalledWith(2, { - auth: { apiKey: 'api-key', appKey: 'app-key' }, - url: 'https://api.datadoghq.com/api/unstable/app-builder-code/apps/repo:app/release/live', + expect(requestMock).toHaveBeenCalledTimes(2); + expect(requestMock).toHaveBeenNthCalledWith(2, { + url: '/api/unstable/app-builder-code/apps/repo:app/release/live', method: 'PUT', type: 'json', getData: expect.any(Function), @@ -287,7 +270,7 @@ describe('Apps Plugin - upload', () => { }); test('Should collect warnings on retries', async () => { - doRequestMock.mockImplementation(async (opts) => { + requestMock.mockImplementation(async (opts) => { opts.onRetry?.(new Error('network'), 2); }); @@ -304,7 +287,7 @@ describe('Apps Plugin - upload', () => { }); test('Should return errors when upload fails', async () => { - doRequestMock.mockRejectedValue(new Error('boom')); + requestMock.mockRejectedValue(new Error('boom')); const { errors } = await uploadArchive(archive, context, logger); diff --git a/packages/plugins/apps/src/upload.ts b/packages/plugins/apps/src/upload.ts index f0ca4e32a..5c65b2074 100644 --- a/packages/plugins/apps/src/upload.ts +++ b/packages/plugins/apps/src/upload.ts @@ -4,12 +4,8 @@ import { getDDEnvValue } from '@dd/core/helpers/env'; import { getFile } from '@dd/core/helpers/fs'; -import { - createRequestData, - doRequest, - getOriginHeaders, - NB_RETRIES, -} from '@dd/core/helpers/request'; +import type { AuthenticatedRequestFunction } from '@dd/core/helpers/request-auth'; +import { createRequestData, getOriginHeaders, NB_RETRIES } from '@dd/core/helpers/request'; import { prettyObject } from '@dd/core/helpers/strings'; import type { Logger } from '@dd/core/types'; import chalk from 'chalk'; @@ -22,13 +18,12 @@ import { APPS_API_PATH, ARCHIVE_FILENAME } from './constants'; type DataResponse = Awaited>; export type UploadContext = { - apiKey?: string; - appKey?: string; bundlerName: string; dryRun: boolean; identifier: string; name: string; - site: string; + appBaseUrl: string; + request?: AuthenticatedRequestFunction; version: string; }; @@ -37,13 +32,13 @@ const yellow = chalk.yellow.bold; const cyan = chalk.cyan.bold; const bold = chalk.bold; -export const getIntakeUrl = (site: string, appId: string) => { +export const getIntakeUrl = (appId: string) => { const envIntake = getDDEnvValue('APPS_INTAKE_URL'); - return envIntake || `https://api.${site}/${APPS_API_PATH}/${appId}/upload`; + return envIntake || `/${APPS_API_PATH}/${appId}/upload`; }; -export const getReleaseUrl = (site: string, appId: string) => { - return `https://api.${site}/${APPS_API_PATH}/${appId}/release/live`; +export const getReleaseUrl = (appId: string) => { + return `/${APPS_API_PATH}/${appId}/release/live`; }; export const getData = @@ -74,17 +69,12 @@ export const uploadArchive = async (archive: Archive, context: UploadContext, lo const errors: Error[] = []; const warnings: string[] = []; - if (!context.apiKey || !context.appKey) { - errors.push(new Error('Missing authentication token, need both app and api keys.')); - return { errors, warnings }; - } - if (!context.identifier) { errors.push(new Error('No app identifier provided')); return { errors, warnings }; } - const intakeUrl = getIntakeUrl(context.site, context.identifier); + const intakeUrl = getIntakeUrl(context.identifier); const defaultHeaders = getOriginHeaders({ bundler: context.bundlerName, plugin: 'apps', @@ -113,9 +103,13 @@ Would have uploaded ${summary}`, return { errors, warnings }; } + if (!context.request) { + errors.push(new Error('Missing authentication token, need both app and api keys.')); + return { errors, warnings }; + } + try { - const response: any = await doRequest({ - auth: { apiKey: context.apiKey, appKey: context.appKey }, + const response: any = await context.request({ url: intakeUrl, method: 'POST', type: 'json', @@ -132,15 +126,14 @@ Would have uploaded ${summary}`, log.debug(`Uploaded ${summary}\n`); if (response.app_builder_id) { - const appBuilderUrl = `https://app.${context.site}/app-builder/apps/${response.app_builder_id}`; + const appBuilderUrl = `${context.appBaseUrl}/app-builder/apps/${response.app_builder_id}`; log.info(`Your application is available at:\n ${cyan(appBuilderUrl)}`); } if (response.version_id) { - const releaseUrl = getReleaseUrl(context.site, context.identifier); - await doRequest({ - auth: { apiKey: context.apiKey, appKey: context.appKey }, + const releaseUrl = getReleaseUrl(context.identifier); + await context.request({ url: releaseUrl, method: 'PUT', type: 'json', diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index efe8ad944..441cae9ed 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -3,6 +3,8 @@ // Copyright 2019-Present Datadog, Inc. import { createDevServerMiddleware } from '@dd/apps-plugin/vite/dev-server'; +import { withApiAuth, withBaseUrl } from '@dd/core/helpers/request-auth'; +import { doRequest } from '@dd/core/helpers/request'; import type { AuthOptionsWithDefaults } from '@dd/core/types'; import { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; import { EventEmitter } from 'events'; @@ -39,6 +41,10 @@ const mockAuth: AuthOptionsWithDefaults = { }; const mockLog = getMockLogger(); +const mockRequest = withApiAuth({ + auth: mockAuth, + log: mockLog, +})(withBaseUrl(DD_API_ORIGIN)(doRequest)); /** * Create a mock IncomingMessage with a JSON body. @@ -129,7 +135,7 @@ describe('Dev Server Middleware', () => { const middleware = createDevServerMiddleware( mockViteBuild, () => mockFunctions, - mockAuth, + mockRequest, '/project', mockLog, ); @@ -216,7 +222,7 @@ describe('Dev Server Middleware', () => { const middleware = createDevServerMiddleware( mockViteBuild, () => mockFunctions, - mockAuth, + mockRequest, '/project', mockLog, ); @@ -291,11 +297,37 @@ describe('Dev Server Middleware', () => { const middleware = createDevServerMiddleware( mockViteBuild, () => mockFunctions, - mockAuth, + mockRequest, '/project', mockLog, ); + test('Should return 403 before bundling when auth is not configured', async () => { + mockViteBuild.mockClear(); + const requestWithoutAuth = withApiAuth({ + auth: {}, + log: mockLog, + })(withBaseUrl(DD_API_ORIGIN)(doRequest)); + const middlewareWithoutAuth = createDevServerMiddleware( + mockViteBuild, + () => mockFunctions, + requestWithoutAuth, + '/project', + mockLog, + ); + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + }); + const res = createMockResponse(); + + middlewareWithoutAuth(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.getBody()).error).toContain('Auth credentials not configured'); + expect(mockViteBuild).not.toHaveBeenCalled(); + }); + test('Should return 400 for missing functionRef', async () => { const req = createMockRequest('/__dd/executeAction', {}); const res = createMockResponse(); @@ -473,7 +505,7 @@ describe('Dev Server Middleware', () => { const middlewareWithAllowlist = createDevServerMiddleware( mockViteBuild, () => functionsWithAllowlist, - mockAuth, + mockRequest, '/project', mockLog, ); @@ -634,7 +666,7 @@ describe('Dev Server Middleware', () => { const middleware = createDevServerMiddleware( mockViteBuild, () => currentFunctions, - mockAuth, + mockRequest, '/project', mockLog, ); diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index de8304b43..3a8c903f5 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -4,8 +4,9 @@ /* eslint-disable no-await-in-loop */ -import { doRequest } from '@dd/core/helpers/request'; -import type { AuthOptionsWithDefaults, Logger } from '@dd/core/types'; +import type { AuthenticatedRequestFunction } from '@dd/core/helpers/request-auth'; +import { MissingRequestAuthError } from '@dd/core/helpers/request-auth'; +import type { Logger } from '@dd/core/types'; import { randomUUID } from 'crypto'; import type { IncomingMessage, ServerResponse } from 'http'; import type { build } from 'vite'; @@ -27,7 +28,9 @@ type BundleFn = (func: BackendFunction) => Promise; const DEV_VIRTUAL_PREFIX = 'virtual:dd-backend-dev:'; -type AuthConfig = Required; +type AuthConfig = { + request: AuthenticatedRequestFunction; +}; /** Shape of the `outputs` field in a Datadog app-builder query response — * the API wraps a JS action's return value as `{ data: }`. @@ -133,7 +136,7 @@ async function executeScriptViaDatadog( auth: AuthConfig, log: Logger, ): Promise { - const endpoint = `https://api.${auth.site}/api/v2/app-builder/queries/preview-async`; + const endpoint = '/api/v2/app-builder/queries/preview-async'; const displayName = formatRef(func); log.debug(`Calling Datadog API: ${endpoint}`); @@ -163,9 +166,8 @@ async function executeScriptViaDatadog( }, }); - const initialResult = await doRequest<{ data?: { id?: string } }>({ + const initialResult = await auth.request<{ data?: { id?: string } }>({ url: endpoint, - auth, method: 'POST', type: 'json', getData: () => ({ @@ -195,7 +197,7 @@ async function pollQueryExecution( auth: AuthConfig, log: Logger, ): Promise { - const endpoint = `https://api.${auth.site}/api/v2/app-builder/queries/execution-long-polling/${receiptId}`; + const endpoint = `/api/v2/app-builder/queries/execution-long-polling/${receiptId}`; const maxRetries = 10; /* @@ -214,9 +216,8 @@ async function pollQueryExecution( for (let attempt = 0; attempt < maxRetries; attempt++) { log.debug(`Long-poll attempt ${attempt + 1}/${maxRetries}...`); - const result = await doRequest({ + const result = await auth.request({ url: endpoint, - auth, type: 'json', }); @@ -329,7 +330,12 @@ async function handleExecuteAction( res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ success: true, result } satisfies ExecuteActionResponse)); } catch (error: unknown) { - const statusCode = error instanceof HttpError ? error.statusCode : 500; + const statusCode = + error instanceof HttpError + ? error.statusCode + : error instanceof MissingRequestAuthError + ? 403 + : 500; const message = error instanceof Error ? error.message : 'Internal server error'; log.debug(`Error handling executeAction: ${message}`); sendError(res, statusCode, message); @@ -354,7 +360,7 @@ function buildFunctionMap(backendFunctions: BackendFunction[]): Map BackendFunction[], - auth: AuthOptionsWithDefaults, + request: AuthenticatedRequestFunction, projectRoot: string, log: Logger, ): (req: IncomingMessage, res: ServerResponse, next: () => void) => void { @@ -368,20 +374,7 @@ export function createDevServerMiddleware( ); } - // Narrow auth once — executeAction needs all three fields present. - const fullAuth: AuthConfig | undefined = - auth.apiKey && auth.appKey - ? { apiKey: auth.apiKey, appKey: auth.appKey, site: auth.site } - : undefined; - - if (!fullAuth) { - log.warn( - 'Auth credentials not configured. The /__dd/executeAction endpoint will be unavailable. ' + - 'Set DD_API_KEY and DD_APP_KEY to enable remote execution.', - ); - } - - return (req: IncomingMessage, res: ServerResponse, next: () => void) => { + return async (req: IncomingMessage, res: ServerResponse, next: () => void) => { if (req.method !== 'POST') { next(); return; @@ -389,24 +382,26 @@ export function createDevServerMiddleware( const functionsByName = buildFunctionMap(getBackendFunctions()); - if (req.url === '/__dd/debugBundle') { - handleDebugBundle(req, res, functionsByName, bundle).catch(() => { - sendError(res, 500, 'Unexpected error'); - }); - } else if (req.url === '/__dd/executeAction') { - if (!fullAuth) { - sendError( - res, - 403, - 'Auth credentials not configured. Set DD_API_KEY and DD_APP_KEY to enable remote execution.', - ); - return; + try { + if (req.url === '/__dd/debugBundle') { + await handleDebugBundle(req, res, functionsByName, bundle); + } else if (req.url === '/__dd/executeAction') { + try { + request.assertAuthConfigured(); + } catch (error) { + if (error instanceof MissingRequestAuthError) { + sendError(res, 403, error.message); + return; + } + throw error; + } + + await handleExecuteAction(req, res, functionsByName, bundle, { request }, log); + } else { + next(); } - handleExecuteAction(req, res, functionsByName, bundle, fullAuth, log).catch(() => { - sendError(res, 500, 'Unexpected error'); - }); - } else { - next(); + } catch { + sendError(res, 500, 'Unexpected error'); } }; } diff --git a/packages/plugins/apps/src/vite/handle-upload.ts b/packages/plugins/apps/src/vite/handle-upload.ts index 9519f363a..a7bcbff4e 100644 --- a/packages/plugins/apps/src/vite/handle-upload.ts +++ b/packages/plugins/apps/src/vite/handle-upload.ts @@ -3,6 +3,7 @@ // Copyright 2019-Present Datadog, Inc. import { rm } from '@dd/core/helpers/fs'; +import type { AuthenticatedRequestFunction } from '@dd/core/helpers/request-auth'; import type { GlobalContext } from '@dd/core/types'; import chalk from 'chalk'; import fsp from 'fs/promises'; @@ -28,6 +29,7 @@ export interface HandleUploadOptions { backendFunctions: BackendFunction[]; context: GlobalContext; options: AppsOptionsWithDefaults; + request: AuthenticatedRequestFunction; } function buildManifest(backendFunctions: BackendFunction[]): AppsManifest { @@ -66,6 +68,7 @@ export const handleUpload = async ({ backendFunctions, context, options, + request, }: HandleUploadOptions) => { const log = context.getLogger(PLUGIN_NAME); const { @@ -142,13 +145,12 @@ Either: const { errors: uploadErrors, warnings: uploadWarnings } = await uploadArchive( archive, { - apiKey: auth.apiKey, - appKey: auth.appKey, bundlerName, dryRun: options.dryRun, identifier, name, - site: auth.site, + appBaseUrl: `https://app.${auth.site}`, + request, version, }, log, diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index fbf2b9626..709d33c9a 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -3,6 +3,8 @@ // Copyright 2019-Present Datadog, Inc. import { rm } from '@dd/core/helpers/fs'; +import { withApiAuth, withBaseUrl } from '@dd/core/helpers/request-auth'; +import { doRequest } from '@dd/core/helpers/request'; import type { GlobalContext, PluginOptions } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; import path from 'path'; @@ -100,6 +102,12 @@ export const getVitePlugin = ({ const log = context.getLogger(PLUGIN_NAME); const { auth, buildRoot } = context; + const doApiRequest = withApiAuth({ + auth, + log, + missingAuthMessage: 'Auth credentials not configured. Set DD_API_KEY and DD_APP_KEY.', + })(withBaseUrl(`https://api.${auth.site}`)(doRequest)); + context.inject({ type: 'file', position: InjectPosition.MIDDLE, @@ -161,6 +169,7 @@ export const getVitePlugin = ({ backendFunctions, context, options, + request: doApiRequest, }); } finally { if (backendOutDir) { @@ -170,7 +179,13 @@ export const getVitePlugin = ({ }, configureServer(server) { server.middlewares.use( - createDevServerMiddleware(bundler.build, getBackendFunctions, auth, buildRoot, log), + createDevServerMiddleware( + bundler.build, + getBackendFunctions, + doApiRequest, + buildRoot, + log, + ), ); }, };