diff --git a/packages/plugins/apps/src/backend/extract-connections.test.ts b/packages/plugins/apps/src/backend/extract-connections.test.ts new file mode 100644 index 000000000..76e2ef4b9 --- /dev/null +++ b/packages/plugins/apps/src/backend/extract-connections.test.ts @@ -0,0 +1,384 @@ +// 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 { + extractConnectionIds, + findConnectionsFile, +} from '@dd/apps-plugin/backend/extract-connections'; +import type { + ExportNamedDeclaration, + ObjectExpression, + Program, + Property, + SpreadElement, +} from 'estree'; +import { promises as fsp } from 'fs'; +import os from 'os'; +import path from 'path'; + +/** + * Build a minimal ESTree Program node containing the given top-level statements. + */ +function program(body: Program['body']): Program { + return { type: 'Program', sourceType: 'module', body }; +} + +/** + * Build an `export const CONNECTIONS = ` declaration. + */ +function exportConnections(properties: ObjectExpression['properties']): ExportNamedDeclaration { + return { + type: 'ExportNamedDeclaration', + declaration: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'CONNECTIONS' }, + init: { type: 'ObjectExpression', properties }, + }, + ], + }, + specifiers: [], + source: null, + attributes: [], + }; +} + +/** + * Build a `KEY: 'value'` ObjectExpression property whose value is a string literal. + */ +function stringProperty(key: string, value: string): Property { + return { + type: 'Property', + key: { type: 'Identifier', name: key }, + value: { type: 'Literal', value }, + kind: 'init', + method: false, + shorthand: false, + computed: false, + }; +} + +const filePath = '/project/connections.ts'; + +describe('extract-connections - extractConnectionIds', () => { + const acceptedCases = [ + { + description: 'single literal value', + ast: program([exportConnections([stringProperty('OPEN_AI', 'uuid-1')])]), + expected: ['uuid-1'], + }, + { + description: 'multiple values, sorted and deduplicated', + ast: program([ + exportConnections([ + stringProperty('A', 'uuid-z'), + stringProperty('B', 'uuid-a'), + stringProperty('C', 'uuid-z'), + ]), + ]), + expected: ['uuid-a', 'uuid-z'], + }, + { + description: 'string-literal keys', + ast: program([ + exportConnections([ + { + type: 'Property', + key: { type: 'Literal', value: 'open-ai' }, + value: { type: 'Literal', value: 'uuid-1' }, + kind: 'init', + method: false, + shorthand: false, + computed: false, + }, + ]), + ]), + expected: ['uuid-1'], + }, + { + description: 'template literal value with no interpolation', + ast: program([ + exportConnections([ + { + type: 'Property', + key: { type: 'Identifier', name: 'OPEN_AI' }, + value: { + type: 'TemplateLiteral', + expressions: [], + quasis: [ + { + type: 'TemplateElement', + value: { cooked: 'uuid-tmpl', raw: 'uuid-tmpl' }, + tail: true, + }, + ], + }, + kind: 'init', + method: false, + shorthand: false, + computed: false, + }, + ]), + ]), + expected: ['uuid-tmpl'], + }, + { + description: 'empty object', + ast: program([exportConnections([])]), + expected: [], + }, + ]; + + test.each(acceptedCases)('Should accept $description', ({ ast, expected }) => { + expect(extractConnectionIds(ast, filePath, '')).toEqual(expected); + }); + + test('Should throw when no "export const CONNECTIONS" is present', () => { + const ast = program([ + { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'CONNECTIONS' }, + init: { type: 'ObjectExpression', properties: [] }, + }, + ], + }, + ]); + expect(() => extractConnectionIds(ast, filePath, '')).toThrow( + 'connections file must define "export const CONNECTIONS" = { ... }', + ); + }); + + test('Should throw when default-exported instead of named export', () => { + const ast = program([ + { + type: 'ExportDefaultDeclaration', + declaration: { type: 'ObjectExpression', properties: [] }, + }, + ]); + expect(() => extractConnectionIds(ast, filePath, '')).toThrow( + 'connections file must define "export const CONNECTIONS" = { ... }', + ); + }); + + test('Should throw when initialized with a non-object expression', () => { + const ast = program([ + { + type: 'ExportNamedDeclaration', + declaration: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'CONNECTIONS' }, + init: { type: 'Literal', value: 'oops' }, + }, + ], + }, + specifiers: [], + source: null, + attributes: [], + }, + ]); + expect(() => extractConnectionIds(ast, filePath, '')).toThrow( + '"export const CONNECTIONS" must be initialized with an object literal', + ); + }); + + test('Should throw on multiple "export const CONNECTIONS" declarations', () => { + const ast = program([ + exportConnections([stringProperty('A', 'uuid-1')]), + exportConnections([stringProperty('B', 'uuid-2')]), + ]); + expect(() => extractConnectionIds(ast, filePath, '')).toThrow( + 'multiple top-level "export const CONNECTIONS" declarations are not allowed', + ); + }); + + test('Should throw on computed keys', () => { + const ast = program([ + exportConnections([ + { + type: 'Property', + key: { type: 'Identifier', name: 'KEY' }, + value: { type: 'Literal', value: 'uuid' }, + kind: 'init', + method: false, + shorthand: false, + computed: true, + }, + ]), + ]); + expect(() => extractConnectionIds(ast, filePath, '')).toThrow('computed keys'); + }); + + test('Should throw on spread elements', () => { + const ast = program([ + exportConnections([ + { + type: 'SpreadElement', + argument: { type: 'Identifier', name: 'other' }, + } satisfies SpreadElement, + ]), + ]); + expect(() => extractConnectionIds(ast, filePath, '')).toThrow('spread elements'); + }); + + const rejectedValueCases: Array<{ + description: string; + value: Property['value']; + reasonContains: string; + }> = [ + { + description: 'identifier reference', + value: { type: 'Identifier', name: 'someConst' }, + reasonContains: 'must be a string literal', + }, + { + description: 'env var (member expression)', + value: { + type: 'MemberExpression', + object: { + type: 'MemberExpression', + object: { type: 'Identifier', name: 'process' }, + property: { type: 'Identifier', name: 'env' }, + computed: false, + optional: false, + }, + property: { type: 'Identifier', name: 'OPEN_AI_ID' }, + computed: false, + optional: false, + }, + reasonContains: 'must be a string literal', + }, + { + description: 'binary expression (concatenation)', + value: { + type: 'BinaryExpression', + operator: '+', + left: { type: 'Literal', value: 'a-' }, + right: { type: 'Literal', value: 'b' }, + }, + reasonContains: 'must be a string literal', + }, + { + description: 'function call', + value: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'getId' }, + arguments: [], + optional: false, + }, + reasonContains: 'must be a string literal', + }, + { + description: 'template literal with interpolation', + value: { + type: 'TemplateLiteral', + expressions: [{ type: 'Identifier', name: 'suffix' }], + quasis: [ + { + type: 'TemplateElement', + value: { cooked: 'pre-', raw: 'pre-' }, + tail: false, + }, + { + type: 'TemplateElement', + value: { cooked: '', raw: '' }, + tail: true, + }, + ], + }, + reasonContains: 'template literals with interpolations', + }, + { + description: 'numeric literal', + value: { type: 'Literal', value: 42 }, + reasonContains: 'must be a string literal', + }, + ]; + + test.each(rejectedValueCases)( + 'Should throw on non-literal value: $description', + ({ value, reasonContains }) => { + const property: Property = { + type: 'Property', + key: { type: 'Identifier', name: 'BAD' }, + value, + kind: 'init', + method: false, + shorthand: false, + computed: false, + }; + const ast = program([exportConnections([property])]); + expect(() => extractConnectionIds(ast, filePath, '')).toThrow(reasonContains); + }, + ); +}); + +describe('extract-connections - findConnectionsFile', () => { + let buildRoot: string; + + beforeEach(async () => { + buildRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'connections-test-')); + }); + + afterEach(async () => { + await fsp.rm(buildRoot, { recursive: true, force: true }); + }); + + test('Should return undefined when no connections file exists', async () => { + await expect(findConnectionsFile(buildRoot)).resolves.toBeUndefined(); + }); + + test.each([ + { ext: '.ts' as const }, + { ext: '.tsx' as const }, + { ext: '.js' as const }, + { ext: '.jsx' as const }, + ])('Should find connections$ext when it exists', async ({ ext }) => { + const expected = path.join(buildRoot, `connections${ext}`); + await fsp.writeFile(expected, 'export const connections = {} as const;'); + await expect(findConnectionsFile(buildRoot)).resolves.toBe(expected); + }); + + test('Should prefer .ts over other extensions', async () => { + await fsp.writeFile( + path.join(buildRoot, 'connections.ts'), + 'export const connections = {} as const;', + ); + await fsp.writeFile( + path.join(buildRoot, 'connections.tsx'), + 'export const connections = {} as const;', + ); + await fsp.writeFile( + path.join(buildRoot, 'connections.js'), + 'export const connections = {};', + ); + await expect(findConnectionsFile(buildRoot)).resolves.toBe( + path.join(buildRoot, 'connections.ts'), + ); + }); + + test('Should prefer .tsx over .js when .ts is absent', async () => { + await fsp.writeFile( + path.join(buildRoot, 'connections.tsx'), + 'export const connections = {} as const;', + ); + await fsp.writeFile( + path.join(buildRoot, 'connections.js'), + 'export const connections = {};', + ); + await expect(findConnectionsFile(buildRoot)).resolves.toBe( + path.join(buildRoot, 'connections.tsx'), + ); + }); +}); diff --git a/packages/plugins/apps/src/backend/extract-connections.ts b/packages/plugins/apps/src/backend/extract-connections.ts new file mode 100644 index 000000000..f73f35403 --- /dev/null +++ b/packages/plugins/apps/src/backend/extract-connections.ts @@ -0,0 +1,179 @@ +// 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 { Node, ObjectExpression, Program, Property } from 'estree'; +import { promises as fsp } from 'fs'; +import path from 'path'; + +const CONNECTIONS_FILE_BASENAME = 'connections'; +const CONNECTIONS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'] as const; +const CONNECTIONS_EXPORT_NAME = 'CONNECTIONS'; +const EXPECTED_EXPORT_DESCRIPTION = `"export const ${CONNECTIONS_EXPORT_NAME}"`; + +/** + * Locate the project's connections file. Looks for `connections.{ts,tsx,js,jsx}` + * at `buildRoot` and returns the absolute path of the first match in priority + * order, or `undefined` when none exists. + */ +export async function findConnectionsFile(buildRoot: string): Promise { + for (const ext of CONNECTIONS_EXTENSIONS) { + const candidate = path.join(buildRoot, `${CONNECTIONS_FILE_BASENAME}${ext}`); + try { + await fsp.access(candidate); + return candidate; + } catch { + // not found at this extension — try the next. + } + } + return undefined; +} + +type WithOffset = Node & { start?: number }; + +/** + * Extract connection IDs from a parsed connections-file AST. + * + * The file must contain exactly one top-level export of the form: + * + * export const CONNECTIONS = { + * NAME_A: 'uuid-a', + * NAME_B: 'uuid-b', + * } as const; + * + * Values must be plain string literals or interpolation-free template literals. + * Anything else (identifiers, env vars, concatenation, function calls, computed + * keys, spread elements, …) throws with a framed source location so the caller + * can surface a build-time error. `code` is the original source text used to + * resolve `node.start` offsets to line:col coordinates. + * + * Returns the union of values, deduplicated and sorted lexicographically for + * deterministic manifests. + */ +export function extractConnectionIds(ast: Program, filePath: string, code: string): string[] { + if (ast.type !== 'Program') { + throw new Error( + `Expected a Program node from this.parse() for ${filePath}, got ${(ast as Node).type}`, + ); + } + + const fail = (node: WithOffset | null | undefined, reason: string): Error => { + const where = + node?.start != null ? `${filePath}:${formatLineCol(code, node.start)}` : filePath; + return new Error(`[connections] ${reason} (at ${where})`); + }; + + let connectionsObject: ObjectExpression | undefined; + + // Find: export const CONNECTIONS = {}; + for (const node of ast.body) { + if (node.type !== 'ExportNamedDeclaration' || !node.declaration) { + continue; + } + const decl = node.declaration; + if (decl.type !== 'VariableDeclaration') { + continue; + } + for (const d of decl.declarations) { + if (d.id.type !== 'Identifier' || d.id.name !== CONNECTIONS_EXPORT_NAME) { + continue; + } + if (connectionsObject) { + throw fail( + d, + `multiple top-level ${EXPECTED_EXPORT_DESCRIPTION} declarations are not allowed`, + ); + } + if (!d.init || d.init.type !== 'ObjectExpression') { + throw fail( + d.init ?? d, + `${EXPECTED_EXPORT_DESCRIPTION} must be initialized with an object literal`, + ); + } + connectionsObject = d.init; + } + } + + if (!connectionsObject) { + throw fail(null, `connections file must define ${EXPECTED_EXPORT_DESCRIPTION} = { ... }`); + } + + const ids = new Set(); + // Validate and extract the CONNECTIONS object data + for (const property of connectionsObject.properties) { + if (property.type === 'SpreadElement') { + throw fail( + property, + `spread elements are not supported inside ${EXPECTED_EXPORT_DESCRIPTION}`, + ); + } + if (property.computed) { + throw fail( + property, + `computed keys are not supported inside ${EXPECTED_EXPORT_DESCRIPTION}`, + ); + } + const keyName = readKeyName(property); + const value = extractStaticString(property.value, keyName, fail); + ids.add(value); + } + + return [...ids].sort(); +} + +/** + * Resolve a property value node to its static string. Accepts string literals + * and interpolation-free template literals; throws on anything else. + * + * This return the value of literal string ('HELLO') and template literals with no expressions: (`World`). + * It will throw on everything else. + * + */ +function extractStaticString( + value: Property['value'], + keyName: string, + fail: (node: WithOffset | null | undefined, reason: string) => Error, +): string { + if (value.type === 'Literal' && typeof value.value === 'string') { + return value.value; + } + if (value.type === 'TemplateLiteral') { + if (value.expressions.length > 0) { + throw fail( + value, + `value for "${keyName}" must be a static string — template literals with interpolations are not allowed`, + ); + } + const quasi = value.quasis[0]; + return quasi.value.cooked ?? quasi.value.raw; + } + throw fail(value, `value for "${keyName}" must be a string literal; got ${value.type}`); +} + +/** + * Read a property's key name as a string. Computed keys are rejected upstream, + * so this only handles `Identifier` (e.g. `OPEN_AI: '...'`) and string + * `Literal` (`'open-ai': '...'`) forms. + */ +function readKeyName(property: Property): string { + if (property.key.type === 'Identifier') { + return property.key.name; + } + if (property.key.type === 'Literal') { + return String(property.key.value); + } + return ''; +} + +/** + * Convert a 0-based byte offset into a `line:column` string (1-based, like + * editor jump-to-line targets). + */ +function formatLineCol(code: string, offset: number): string { + const before = code.slice(0, offset); + const newlineCount = (before.match(/\n/g) ?? []).length; + const lastNewline = before.lastIndexOf('\n'); + const line = newlineCount + 1; + const column = offset - (lastNewline + 1) + 1; + return `${line}:${column}`; +} diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 13af8ff23..ea3a5b2d5 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -6,6 +6,9 @@ import { rm } from '@dd/core/helpers/fs'; import type { GetPlugins } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; import chalk from 'chalk'; +import type { Program } from 'estree'; +import fsp from 'fs/promises'; +import os from 'os'; import path from 'path'; import { createArchive } from './archive'; @@ -14,6 +17,7 @@ import { collectAssets } from './assets'; import type { BackendFunction } from './backend/discovery'; import { extractExportedFunctions } from './backend/discovery'; import { encodeQueryName } from './backend/encodeQueryName'; +import { extractConnectionIds, findConnectionsFile } from './backend/extract-connections'; import { generateProxyModule } from './backend/proxy-codegen'; import { BACKEND_FILE_RE, CONFIG_KEY, PLUGIN_NAME } from './constants'; import { resolveIdentifier } from './identifier'; @@ -71,6 +75,42 @@ function createBackendFunctionRegistry() { }; } +export interface ConnectionIdsRegistry { + getConnectionIds(): string[]; + clearConnectionIds(): void; + loadAndSetConnectionIds( + load: (filePath: string) => Promise, + ): Promise<{ filePath: string | null; connectionIds: string[] }>; +} + +function createConnectionIdsRegistry(opts: { + getBuildRoot: () => string; + parse: (code: string) => Program; +}): ConnectionIdsRegistry { + let connectionIds: string[] = []; + return { + getConnectionIds() { + return connectionIds; + }, + clearConnectionIds() { + connectionIds = []; + }, + async loadAndSetConnectionIds(load) { + const filePath = await findConnectionsFile(opts.getBuildRoot()); + if (!filePath) { + connectionIds = []; + return { filePath: null, connectionIds }; + } + const code = await load(filePath); + if (code == null) { + throw new Error(`connections file '${filePath}' produced no code when loaded`); + } + connectionIds = extractConnectionIds(opts.parse(code), filePath, code); + return { filePath, connectionIds }; + }, + }; +} + export type types = { // Add the types you'd like to expose here. AppsOptions: AppsOptions; @@ -106,9 +146,15 @@ export const getPlugins: GetPlugins = ({ options, context, bundler }) => { const { setBackendFunctions, getBackendFunctions } = createBackendFunctionRegistry(); + const connectionRegistry = createConnectionIdsRegistry({ + getBuildRoot: () => context.buildRoot, + parse: (code) => bundler.parseAst(code) as Program, + }); + const handleUpload = async (backendOutputs: Map) => { const handleTimer = log.time('handle assets'); let archiveDir: string | undefined; + let manifestDir: string | undefined; try { const identifierTimer = log.time('resolve identifier'); @@ -158,6 +204,31 @@ Either: }); } + // Emit manifest.json at the zip root with the per-function allowed + // connection IDs so the server-side actions runtime can allowlist + // the connections each function uses. The same union list (from + // connections.ts) is applied to every function — the server + // supports distinct lists, but the RFC explicitly accepts a flat + // union as the chosen design. + if (backendOutputs.size > 0) { + const allowedConnectionIds = connectionRegistry.getConnectionIds(); + const functions: Record = {}; + for (const bundleName of backendOutputs.keys()) { + functions[bundleName] = { allowedConnectionIds }; + } + const manifest = { backend: { functions } }; + manifestDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dd-apps-manifest-')); + const manifestPath = path.join(manifestDir, 'manifest.json'); + await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + allAssets.push({ + absolutePath: manifestPath, + relativePath: 'manifest.json', + }); + log.debug( + `Emitted manifest.json with ${allowedConnectionIds.length} connection ID(s)`, + ); + } + const archiveTimer = log.time('archive assets'); const archive = await createArchive(allAssets); archiveTimer.end(); @@ -198,10 +269,13 @@ Either: log.error(`${red('Failed to upload assets:')}\n${error?.message || error}`); } - // Clean temporary directory + // Clean temporary directories if (archiveDir) { await rm(archiveDir); } + if (manifestDir) { + await rm(manifestDir); + } handleTimer.end(); if (toThrow) { @@ -254,6 +328,7 @@ Either: viteBuild: bundler.build, buildRoot: context.buildRoot, getBackendFunctions, + connectionRegistry, handleUpload, log, auth: context.auth, diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 2c7b203a0..488229337 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -94,13 +94,14 @@ describe('Dev Server Middleware', () => { }); describe('createDevServerMiddleware routing', () => { - const middleware = createDevServerMiddleware( - mockViteBuild, - () => mockFunctions, - mockAuth, - '/project', - mockLog, - ); + const middleware = createDevServerMiddleware({ + viteBuild: mockViteBuild, + getBackendFunctions: () => mockFunctions, + getConnectionIds: () => [], + auth: mockAuth, + projectRoot: '/project', + log: mockLog, + }); test('Should call next() for non-POST requests', () => { const req = { method: 'GET', url: '/__dd/debugBundle' } as unknown as IncomingMessage; @@ -181,13 +182,14 @@ describe('Dev Server Middleware', () => { }); describe('debugBundle handler', () => { - const middleware = createDevServerMiddleware( - mockViteBuild, - () => mockFunctions, - mockAuth, - '/project', - mockLog, - ); + const middleware = createDevServerMiddleware({ + viteBuild: mockViteBuild, + getBackendFunctions: () => mockFunctions, + getConnectionIds: () => [], + auth: mockAuth, + projectRoot: '/project', + log: mockLog, + }); test('Should return 400 for missing functionRef', async () => { const req = createMockRequest('/__dd/debugBundle', {}); @@ -256,13 +258,14 @@ describe('Dev Server Middleware', () => { }); describe('executeAction handler', () => { - const middleware = createDevServerMiddleware( - mockViteBuild, - () => mockFunctions, - mockAuth, - '/project', - mockLog, - ); + const middleware = createDevServerMiddleware({ + viteBuild: mockViteBuild, + getBackendFunctions: () => mockFunctions, + getConnectionIds: () => [], + auth: mockAuth, + projectRoot: '/project', + log: mockLog, + }); test('Should return 400 for missing functionRef', async () => { const req = createMockRequest('/__dd/executeAction', {}); @@ -375,6 +378,58 @@ describe('Dev Server Middleware', () => { expect(body.error).toContain('Script threw an error'); }); + test('Should include allowedConnectionIds inside inputs of the preview-async body', async () => { + mockViteBuild.mockResolvedValue(mockBuildResult('// code')); + + const middlewareWithIds = createDevServerMiddleware({ + viteBuild: mockViteBuild, + getBackendFunctions: () => mockFunctions, + getConnectionIds: () => ['uuid-1', 'uuid-2'], + auth: mockAuth, + projectRoot: '/project', + log: mockLog, + }); + + type PreviewAsyncBody = { + data: { + attributes: { + query: { + properties: { + spec: { inputs: { allowedConnectionIds: string[] } }; + }; + }; + }; + }; + }; + let capturedBody: PreviewAsyncBody | undefined; + const apiScope = nock(`https://${DD_SITE}`) + .post('/api/v2/app-builder/queries/preview-async', (body) => { + capturedBody = body as PreviewAsyncBody; + return true; + }) + .reply(200, { data: { id: 'receipt-conn' } }) + .get('/api/v2/app-builder/queries/execution-long-polling/receipt-conn') + .reply(200, { + data: { attributes: { done: true, outputs: { data: { ok: true } } } }, + }); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [], + }); + const res = createMockResponse(); + + middlewareWithIds(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(200); + expect(apiScope.isDone()).toBe(true); + + expect( + capturedBody?.data.attributes.query.properties.spec.inputs.allowedConnectionIds, + ).toEqual(['uuid-1', 'uuid-2']); + }); + test('Should retry when long-poll returns done: false', async () => { mockViteBuild.mockResolvedValue(mockBuildResult('// code')); @@ -408,13 +463,14 @@ describe('Dev Server Middleware', () => { describe('dynamic discovery', () => { test('Should not find stale function after re-transform (HMR)', async () => { let currentFunctions: BackendFunction[] = [...mockFunctions]; - const middleware = createDevServerMiddleware( - mockViteBuild, - () => currentFunctions, - mockAuth, - '/project', - mockLog, - ); + const middleware = createDevServerMiddleware({ + viteBuild: mockViteBuild, + getBackendFunctions: () => currentFunctions, + getConnectionIds: () => [], + auth: mockAuth, + projectRoot: '/project', + log: mockLog, + }); // Simulate HMR: greet is renamed to greetV2 in the same file. currentFunctions = [ diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index f2ecbb43b..9f54d87f6 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -111,10 +111,16 @@ async function bundleBackendFunction( /** * Execute a script via Datadog's app-builder queries API. + * + * `allowedConnectionIds` is forwarded inside the action's `inputs` so the + * server-side actions runtime allowlists the connections this preview can use. + * Empty list means no connection-using actions are permitted (same effect as + * not declaring any connections). */ async function executeScriptViaDatadog( scriptBody: string, displayName: string, + allowedConnectionIds: string[], auth: AuthConfig, log: Logger, ): Promise { @@ -133,7 +139,7 @@ async function executeScriptViaDatadog( properties: { spec: { fqn: 'com.datadoghq.datatransformation.jsFunctionWithActions', - inputs: { script: scriptBody }, + inputs: { script: scriptBody, allowedConnectionIds }, }, onlyTriggerManually: true, }, @@ -294,6 +300,7 @@ async function handleExecuteAction( res: ServerResponse, functionsByName: Map, bundle: BundleFn, + allowedConnectionIds: string[], auth: AuthConfig, log: Logger, ): Promise { @@ -302,7 +309,13 @@ async function handleExecuteAction( log.debug(`Executing action: ${displayName} with args`); - const result = await executeScriptViaDatadog(code, displayName, auth, log); + const result = await executeScriptViaDatadog( + code, + displayName, + allowedConnectionIds, + auth, + log, + ); res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); @@ -322,6 +335,15 @@ function buildFunctionMap(backendFunctions: BackendFunction[]): Map [encodeQueryName(f), f])); } +export interface DevServerMiddlewareOptions { + viteBuild: typeof build; + getBackendFunctions: () => BackendFunction[]; + getConnectionIds: () => string[]; + auth: AuthOptionsWithDefaults; + projectRoot: string; + log: Logger; +} + /** * Create a Connect-compatible middleware for the Vite dev server. * Intercepts backend function requests and handles them via Datadog API. @@ -330,13 +352,18 @@ function buildFunctionMap(backendFunctions: BackendFunction[]): Map BackendFunction[], - auth: AuthOptionsWithDefaults, - projectRoot: string, - log: Logger, -): (req: IncomingMessage, res: ServerResponse, next: () => void) => void { +export function createDevServerMiddleware({ + viteBuild, + getBackendFunctions, + getConnectionIds, + auth, + projectRoot, + log, +}: DevServerMiddlewareOptions): ( + req: IncomingMessage, + res: ServerResponse, + next: () => void, +) => void { const bundle = (func: BackendFunction, args: unknown[]) => bundleBackendFunction(viteBuild, func, args, projectRoot, log); @@ -381,7 +408,15 @@ export function createDevServerMiddleware( ); return; } - handleExecuteAction(req, res, functionsByName, bundle, fullAuth, log).catch(() => { + handleExecuteAction( + req, + res, + functionsByName, + bundle, + getConnectionIds(), + fullAuth, + log, + ).catch(() => { sendError(res, 500, 'Unexpected error'); }); } else { diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 5d923f8a7..3743b1fd1 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -38,6 +38,11 @@ const defaultOptions = { viteBuild: mockViteBuild, buildRoot: '/build', getBackendFunctions: () => functions, + connectionRegistry: { + getConnectionIds: () => [], + clearConnectionIds: jest.fn(), + loadAndSetConnectionIds: jest.fn().mockResolvedValue({ filePath: null, connectionIds: [] }), + }, handleUpload: mockHandleUpload, log, auth: { site: 'datadoghq.com' }, diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index b35658349..069028c3b 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -4,9 +4,11 @@ import { rm } from '@dd/core/helpers/fs'; import type { AuthOptionsWithDefaults, Logger, PluginOptions } from '@dd/core/types'; -import type { build } from 'vite'; +import path from 'path'; +import type { build, ViteDevServer } from 'vite'; import type { BackendFunction } from '../backend/discovery'; +import type { ConnectionIdsRegistry } from '../index'; import { buildBackendFunctions } from './build-backend-functions'; import { createDevServerMiddleware } from './dev-server'; @@ -15,48 +17,147 @@ export interface VitePluginOptions { viteBuild: typeof build; buildRoot: string; getBackendFunctions: () => BackendFunction[]; + connectionRegistry: ConnectionIdsRegistry; handleUpload: (backendOutputs: Map) => Promise; log: Logger; auth: AuthOptionsWithDefaults; } /** - * Returns the Vite-specific plugin hooks for the apps plugin. + * Vite-specific hooks for the apps plugin. Lifecycle: * - * Production (closeBundle): builds backend functions (if any) then uploads - * all assets sequentially. - * - * Dev (configureServer): registers middleware for local backend function - * testing when auth credentials are available. + * - `buildStart` (both): primes the connection-IDs registry from `connections.ts`. + * - `closeBundle` (production): builds backend functions and uploads the archive. + * - `configureServer` (dev): registers the local-preview middleware and a + * chokidar watcher that refreshes the registry on connections-file changes. */ export const getVitePlugin = ({ viteBuild, buildRoot, getBackendFunctions, + connectionRegistry, handleUpload, log, auth, -}: VitePluginOptions): PluginOptions['vite'] => ({ - async closeBundle() { - let backendOutDir: string | undefined; - let backendOutputs = new Map(); - const functions = getBackendFunctions(); - if (functions.length > 0) { - const result = await buildBackendFunctions(viteBuild, functions, buildRoot, log); - backendOutDir = result.outDir; - backendOutputs = result.outputs; - } - try { - await handleUpload(backendOutputs); - } finally { - if (backendOutDir) { - await rm(backendOutDir); +}: VitePluginOptions): PluginOptions['vite'] => { + // Captured from configureServer so buildStart can branch on dev vs build. + // In `vite serve`, this.load returns a ModuleInfo whose `code` getter + // throws — code is only resolvable through the dev server's transformRequest. + let devServer: ViteDevServer | undefined; + + return { + // Fires once per production build and once at dev-server start. In + // `vite build --watch` it also re-fires when connections.ts changes + // because addWatchFile registers it as a build dependency. In the dev + // server, addWatchFile only registers chokidar tracking — buildStart + // does NOT re-run on edits there; the dev-server watcher subscriptions + // in configureServer refresh the registry on add/change/unlink (the + // per-request nested viteBuild uses a different config without the + // apps plugin, so its buildStart can't help). + async buildStart() { + try { + const { filePath } = await connectionRegistry.loadAndSetConnectionIds( + async (id) => { + // In `vite serve`, this.load returns a ModuleInfo + // proxy whose `code` getter throws — code is only + // reachable through server.transformRequest. The + // captured devServer doubles as our dev/build + // discriminator: configureServer fires only in + // `vite serve` (and before buildStart), so devServer + // being set means we're in dev. + if (devServer) { + const result = await devServer.transformRequest(id); + return result?.code ?? null; + } + const info = await this.load({ id }); + return info.code ?? null; + }, + ); + if (filePath) { + this.addWatchFile(filePath); + } + } catch (error) { + // Surface the framed error before re-throwing — downstream + // plugins (e.g. error-tracking's sourcemaps upload) may throw + // their own errors during build teardown and mask ours from + // vite's final error report. + const message = error instanceof Error ? error.message : String(error); + log.error(message); + throw error; + } + }, + async closeBundle() { + let backendOutDir: string | undefined; + let backendOutputs = new Map(); + const functions = getBackendFunctions(); + if (functions.length > 0) { + const result = await buildBackendFunctions(viteBuild, functions, buildRoot, log); + backendOutDir = result.outDir; + backendOutputs = result.outputs; + } + try { + await handleUpload(backendOutputs); + } finally { + if (backendOutDir) { + await rm(backendOutDir); + } } - } - }, - configureServer(server) { - server.middlewares.use( - createDevServerMiddleware(viteBuild, getBackendFunctions, auth, buildRoot, log), - ); - }, -}); + }, + configureServer(server) { + devServer = server; + server.middlewares.use( + createDevServerMiddleware({ + viteBuild, + getBackendFunctions, + getConnectionIds: connectionRegistry.getConnectionIds, + auth, + projectRoot: buildRoot, + log, + }), + ); + + // Watch for connections-file lifecycle events. `handleHotUpdate` + // only fires for updates to already-tracked files; it misses + // creates and deletes. We subscribe to the underlying chokidar + // watcher directly so create/change/unlink at the project root + // all refresh (or clear) the registry — important because the + // IDs are an allowlist and stale state could keep removed + // connections allowed mid-session. + const buildRootResolved = path.resolve(buildRoot); + const isConnectionsFile = (filePath: string) => + CONNECTIONS_BASENAME_RE.test(path.basename(filePath)) && + path.resolve(path.dirname(filePath)) === buildRootResolved; + + const refresh = async (filePath: string) => { + if (!isConnectionsFile(filePath)) { + return; + } + try { + const { filePath: resolved, connectionIds } = + await connectionRegistry.loadAndSetConnectionIds(async (id) => { + const result = await server.transformRequest(id); + return result?.code ?? null; + }); + log.debug( + resolved + ? `Refreshed connection IDs from ${resolved} (${connectionIds.length})` + : 'Cleared connection IDs (no connections file present)', + ); + } catch (error) { + // Fail closed: an allowlist that silently retains + // removed UUIDs is more dangerous than one that + // temporarily denies everything until the file is fixed. + connectionRegistry.clearConnectionIds(); + const message = error instanceof Error ? error.message : String(error); + log.error(`Failed to refresh connection IDs (cleared registry): ${message}`); + } + }; + + server.watcher.on('add', refresh); + server.watcher.on('change', refresh); + server.watcher.on('unlink', refresh); + }, + }; +}; + +const CONNECTIONS_BASENAME_RE = /^connections\.(?:ts|tsx|js|jsx)$/; diff --git a/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts b/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts index 3e8687e62..5cca236a9 100644 --- a/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts +++ b/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts @@ -160,14 +160,37 @@ describe('Apps Plugin', () => { const filePaths = Object.keys(zip.files); expect(filePaths.length).toBeGreaterThan(0); - // Every file should be under frontend/ or backend/. + // Every file should be under frontend/ or backend/, or the root + // manifest.json emitted alongside backend functions. for (const filePath of filePaths) { - expect(filePath).toMatch(/^(frontend|backend)\//); + expect(filePath).toMatch(/^(frontend|backend)\/|^manifest\.json$/); } // There should be at least one frontend asset. const frontendFiles = filePaths.filter((f) => f.startsWith('frontend/')); expect(frontendFiles.length).toBeGreaterThan(0); + + // The root manifest.json should describe each backend function with + // an allowedConnectionIds list. + const manifestFile = zip.file('manifest.json'); + expect(manifestFile).not.toBeNull(); + const manifest = JSON.parse(await manifestFile!.async('string')) as { + backend: { functions: Record }; + }; + const backendFiles = filePaths.filter((f) => f.startsWith('backend/') && !zip.files[f].dir); + const expectedKeys = backendFiles.map((f) => + f.replace(/^backend\//, '').replace(/\.js$/, ''), + ); + expect(Object.keys(manifest.backend.functions).sort()).toEqual(expectedKeys.sort()); + // The fixture's connections.js declares two UUIDs; the manifest emits + // the same allowlist (sorted) for every backend function. + const expectedConnectionIds = [ + 'a1111111-1111-1111-1111-111111111111', + 'b2222222-2222-2222-2222-222222222222', + ]; + for (const entry of Object.values(manifest.backend.functions)) { + expect(entry.allowedConnectionIds).toEqual(expectedConnectionIds); + } }); // Backend function injection is only supported for vite. diff --git a/packages/tests/src/e2e/appsPlugin/project/connections.js b/packages/tests/src/e2e/appsPlugin/project/connections.js new file mode 100644 index 000000000..b6b6bdf5e --- /dev/null +++ b/packages/tests/src/e2e/appsPlugin/project/connections.js @@ -0,0 +1,8 @@ +// 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. + +export const CONNECTIONS = { + OPEN_AI: 'a1111111-1111-1111-1111-111111111111', + SLACK: 'b2222222-2222-2222-2222-222222222222', +};