diff --git a/packages/plugins/apps/src/backend/discovery.test.ts b/packages/plugins/apps/src/backend/discovery.test.ts index f521b6716..f0fedae74 100644 --- a/packages/plugins/apps/src/backend/discovery.test.ts +++ b/packages/plugins/apps/src/backend/discovery.test.ts @@ -2,7 +2,10 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { extractExportedFunctions } from '@dd/apps-plugin/backend/discovery'; +import { + enumerateBackendExports, + extractExportedFunctions, +} from '@dd/apps-plugin/backend/discovery'; import type { Program } from 'estree'; import type { AstNode } from 'rollup'; @@ -460,4 +463,139 @@ describe('Backend Functions - extractExportedFunctions', () => { ]); expect(extractExportedFunctions(ast, filePath)).toEqual(['add']); }); + + test('Should expose supported backend exports through enumerateBackendExports', () => { + const ast = program([ + { + type: 'FunctionDeclaration', + id: { type: 'Identifier', name: 'localAdd' }, + params: [], + body: { type: 'BlockStatement', body: [] }, + }, + { + type: 'ImportDeclaration', + specifiers: [ + { + type: 'ImportSpecifier', + local: { type: 'Identifier', name: 'importedHandler' }, + imported: { type: 'Identifier', name: 'handler' }, + }, + ], + source: { type: 'Literal', value: './handler' }, + attributes: [], + }, + { + type: 'ExportNamedDeclaration', + declaration: { + type: 'FunctionDeclaration', + id: { type: 'Identifier', name: 'direct' }, + params: [], + body: { type: 'BlockStatement', body: [] }, + }, + specifiers: [], + source: null, + attributes: [], + }, + { + type: 'ExportNamedDeclaration', + declaration: { + type: 'VariableDeclaration', + kind: 'const' as const, + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'arrowHandler' }, + init: { + type: 'ArrowFunctionExpression', + params: [], + body: { type: 'BlockStatement', body: [] }, + expression: false, + }, + }, + ], + }, + specifiers: [], + source: null, + attributes: [], + }, + { + type: 'ExportNamedDeclaration', + declaration: null, + specifiers: [ + { + type: 'ExportSpecifier', + local: { type: 'Identifier', name: 'localAdd' }, + exported: { type: 'Identifier', name: 'aliasedAdd' }, + }, + { + type: 'ExportSpecifier', + local: { type: 'Identifier', name: 'importedHandler' }, + exported: { type: 'Identifier', name: 'runImported' }, + }, + ], + source: null, + attributes: [], + }, + { + type: 'ExportNamedDeclaration', + declaration: null, + specifiers: [ + { + type: 'ExportSpecifier', + local: { type: 'Identifier', name: 'remoteHandler' }, + exported: { type: 'Identifier', name: 'remoteRun' }, + }, + ], + source: { type: 'Literal', value: './remote' }, + attributes: [], + }, + ]); + + expect(enumerateBackendExports(ast, filePath)).toEqual([ + { name: 'direct', localName: 'direct' }, + { name: 'arrowHandler', localName: 'arrowHandler' }, + { name: 'aliasedAdd', localName: 'localAdd' }, + { name: 'runImported', localName: 'importedHandler' }, + { name: 'remoteRun', localName: 'remoteHandler', source: './remote' }, + ]); + }); + + test('Should allow opaque callable export specifiers', () => { + const ast = program([ + { + type: 'VariableDeclaration', + kind: 'const' as const, + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'handler' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createHandler' }, + arguments: [], + optional: false, + }, + }, + ], + }, + { + type: 'ExportNamedDeclaration', + declaration: null, + specifiers: [ + { + type: 'ExportSpecifier', + local: { type: 'Identifier', name: 'handler' }, + exported: { type: 'Identifier', name: 'handler' }, + }, + ], + source: null, + attributes: [], + }, + ]); + + expect(enumerateBackendExports(ast, filePath)).toEqual([ + { name: 'handler', localName: 'handler' }, + ]); + expect(extractExportedFunctions(ast, filePath)).toEqual(['handler']); + }); }); diff --git a/packages/plugins/apps/src/backend/discovery.ts b/packages/plugins/apps/src/backend/discovery.ts index 5a74719cb..20fa4dcbd 100644 --- a/packages/plugins/apps/src/backend/discovery.ts +++ b/packages/plugins/apps/src/backend/discovery.ts @@ -16,13 +16,23 @@ export interface BackendFunction { allowedConnectionIds: string[]; } +export interface BackendExport { + /** Exported backend function name. */ + name: string; + /** Local binding name when the export points at a local/imported binding. */ + localName?: string; + /** Source specifier for re-exported bindings. */ + source?: string; +} + /** - * Extract exported value (non-type) symbols from an ESTree AST. + * Enumerate exported value (non-type) symbols from an ESTree AST. * Expects plain JavaScript — TypeScript types must already be stripped * (e.g. by Vite's built-in esbuild transform that runs before our hook). * * Throws on invalid exports (e.g. default exports) and unexpected AST shapes. * Returns an empty array when the file has no named exports. + * This is the shared source of truth for supported backend export shapes. * * @param ast - AstNode from `this.parse()` in unplugin's transform hook * @param filePath - Path to the source file (used in error messages) @@ -31,7 +41,7 @@ function isProgramNode(node: AstNode): node is AstNode & Program { return node.type === 'Program'; } -export function extractExportedFunctions(ast: AstNode, filePath: string): string[] { +export function enumerateBackendExports(ast: AstNode, filePath: string): BackendExport[] { if (!isProgramNode(ast)) { throw new Error( `Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`, @@ -41,7 +51,7 @@ export function extractExportedFunctions(ast: AstNode, filePath: string): string // Build a map of top-level declarations so we can validate export specifiers. const declarations = buildDeclarationMap(ast); - const names: string[] = []; + const exports: BackendExport[] = []; for (const node of ast.body) { // handles: export default ... if (node.type === 'ExportDefaultDeclaration') { @@ -61,7 +71,7 @@ export function extractExportedFunctions(ast: AstNode, filePath: string): string // handles: export function add() {} / export const add = ... if (node.declaration) { - names.push(...namesFromDeclaration(node.declaration, filePath)); + exports.push(...exportsFromDeclaration(node.declaration, filePath)); } for (const spec of node.specifiers) { @@ -74,17 +84,29 @@ export function extractExportedFunctions(ast: AstNode, filePath: string): string `Default exports are not supported in .backend.ts files. Use a named export instead: ${filePath}`, ); } - // Validate specifier binding is callable when we can resolve it. - // e.g. `const VERSION = '1.0'; export { VERSION };` — rejected - // e.g. `function add() {}; export { add };` — allowed if (spec.local.type === 'Identifier') { + if (node.source && typeof node.source.value === 'string') { + exports.push({ + name: spec.exported.name, + localName: spec.local.name, + source: node.source.value, + }); + continue; + } + // Validate specifier binding is callable when we can resolve it. + // e.g. `const VERSION = '1.0'; export { VERSION };` — rejected + // e.g. `function add() {}; export { add };` — allowed validateSpecifierBinding(spec.local.name, declarations, filePath); + // handles: export { add, multiply } and aliases + exports.push({ name: spec.exported.name, localName: spec.local.name }); } - // handles: export { add, multiply } - names.push(spec.exported.name); } } - return names; + return exports; +} + +export function extractExportedFunctions(ast: AstNode, filePath: string): string[] { + return enumerateBackendExports(ast, filePath).map((backendExport) => backendExport.name); } /** Init types that are definitively non-callable at runtime. */ @@ -110,10 +132,10 @@ function isNonCallableInit(init: Expression | null | undefined): boolean { * Handles `export function foo()` and `export const foo = ...` forms. * Throws when a variable export has a non-callable initializer. */ -function namesFromDeclaration(decl: Declaration, filePath: string): string[] { +function exportsFromDeclaration(decl: Declaration, filePath: string): BackendExport[] { // export function add(a, b) { return a + b; } if (decl.type === 'FunctionDeclaration' && decl.id) { - return [decl.id.name]; + return [{ name: decl.id.name, localName: decl.id.name }]; } // export class MyClass {} — classes are not callable as RPC endpoints if (decl.type === 'ClassDeclaration') { @@ -122,7 +144,7 @@ function namesFromDeclaration(decl: Declaration, filePath: string): string[] { ); } if (decl.type === 'VariableDeclaration') { - return decl.declarations.flatMap((d) => { + return decl.declarations.flatMap((d): BackendExport[] => { // export const { a, b } = obj; // export const [a, b] = arr; if (d.id.type !== 'Identifier') { @@ -139,7 +161,7 @@ function namesFromDeclaration(decl: Declaration, filePath: string): string[] { } // export const add = (a, b) => a + b; // export const handler = importedFn; — ambiguous, allowed - return [d.id.name]; + return [{ name: d.id.name, localName: d.id.name }]; }); } throw new Error( diff --git a/packages/plugins/apps/src/backend/extract-connection-ids.test.ts b/packages/plugins/apps/src/backend/extract-connection-ids.test.ts new file mode 100644 index 000000000..5bd7797c5 --- /dev/null +++ b/packages/plugins/apps/src/backend/extract-connection-ids.test.ts @@ -0,0 +1,271 @@ +// 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 } from '@dd/apps-plugin/backend/extract-connection-ids'; +import { parse } from 'acorn'; +import type { ImportDeclaration, Program } from 'estree'; +import type { AstNode } from 'rollup'; + +function parseModule(code: string): AstNode & Program { + return parse(code, { + ecmaVersion: 'latest', + sourceType: 'module', + }) as unknown as AstNode & Program; +} + +describe('Backend Functions - extractConnectionIds', () => { + const filePath = '/project/src/backend/run.backend.ts'; + + test('Should extract sorted and deduped inline string literal connectionIds from same-file calls', () => { + const ast = parseModule(` + import { request } from '@datadog/action-catalog/http/http'; + + function helper() { + return request({ connectionId: 'conn-b', inputs: {} }); + } + + export function run() { + helper(); + request({ connectionId: 'conn-a', inputs: {} }); + request({ connectionId: 'conn-b', inputs: {} }); + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual(['conn-a', 'conn-b']); + }); + + test.each([ + { + description: 'named import from package root', + code: ` + import { request } from '@datadog/action-catalog'; + request({ connectionId: 'named-root' }); + `, + expected: ['named-root'], + }, + { + description: 'named import from package subpath', + code: ` + import { request as httpRequest } from '@datadog/action-catalog/http/http'; + httpRequest({ connectionId: 'named-subpath' }); + `, + expected: ['named-subpath'], + }, + { + description: 'default import from package subpath', + code: ` + import request from '@datadog/action-catalog/http/http'; + request({ connectionId: 'default-subpath' }); + `, + expected: ['default-subpath'], + }, + { + description: 'namespace import from package subpath', + code: ` + import * as http from '@datadog/action-catalog/http/http'; + http.request({ connectionId: 'namespace-subpath' }); + `, + expected: ['namespace-subpath'], + }, + ])('Should detect action-catalog $description', ({ code, expected }) => { + expect(extractConnectionIds(parseModule(code), filePath)).toEqual(expected); + }); + + test('Should ignore non-action-catalog calls with connectionId', () => { + const ast = parseModule(` + import { request } from './local-client'; + request({ connectionId: 'not-action-catalog' }); + `); + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); + + test('Should ignore action-catalog object arguments that visibly lack connectionId', () => { + const ast = parseModule(` + import { request } from '@datadog/action-catalog/http/http'; + request({ inputs: { verb: 'GET' } }); + `); + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); + + test('Should ignore type-only action-catalog imports', () => { + const ast = parseModule(` + import { request } from '@datadog/action-catalog/http/http'; + request({ connectionId: 'type-only' }); + `); + (ast.body[0] as ImportDeclaration & { importKind?: string }).importKind = 'type'; + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); + + test('Should ignore type-only action-catalog import specifiers', () => { + const ast = parseModule(` + import { request } from '@datadog/action-catalog/http/http'; + request({ connectionId: 'type-only-specifier' }); + `); + const importDeclaration = ast.body[0] as ImportDeclaration; + ( + importDeclaration.specifiers[0] as ImportDeclaration['specifiers'][number] & { + importKind?: string; + } + ).importKind = 'type'; + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); + + test.each([ + { + description: 'non-object first arguments', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + request(opts); + `, + expected: 'first argument must be an object literal', + }, + { + description: 'spread-composed objects', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + request({ connectionId: 'visible', ...opts }); + `, + expected: 'object spreads can hide connectionId', + }, + { + description: 'computed object keys', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + request({ ['connectionId']: 'computed' }); + `, + expected: 'computed object keys can hide connectionId', + }, + { + description: 'optional-chain calls', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + request?.({ connectionId: 'optional' }); + `, + expected: 'optional chaining cannot be statically analyzed', + }, + { + description: 'action-catalog import aliases', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + const action = request; + action({ connectionId: 'alias' }); + `, + expected: 'action-catalog call aliases cannot be statically analyzed', + }, + { + description: 'action-catalog namespace destructuring aliases', + code: ` + import * as http from '@datadog/action-catalog/http/http'; + const { request: action } = http; + action({ connectionId: 'destructured-alias' }); + `, + expected: 'action-catalog call aliases cannot be statically analyzed', + }, + { + description: 'action-catalog namespace member aliases', + code: ` + import * as http from '@datadog/action-catalog/http/http'; + const action = http.request; + action({ connectionId: 'namespace-member-alias' }); + `, + expected: 'action-catalog call aliases cannot be statically analyzed', + }, + { + description: 'computed namespace member calls', + code: ` + import * as http from '@datadog/action-catalog/http/http'; + http['request']({ connectionId: 'computed-member' }); + `, + expected: 'computed namespace member calls cannot be statically analyzed', + }, + ])( + 'Should fail closed for unsupported action-catalog call shapes: $description', + ({ code, expected }) => { + expect(() => extractConnectionIds(parseModule(code), filePath)).toThrow(expected); + }, + ); + + test.each([ + { + description: 'function parameters that shadow named imports', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + + export function run(request) { + request({ connectionId: 'shadowed-param' }); + } + `, + }, + { + description: 'function parameters that shadow namespace imports', + code: ` + import * as http from '@datadog/action-catalog/http/http'; + + export function run(http) { + http.request({ connectionId: 'shadowed-namespace-param' }); + } + `, + }, + { + description: 'local aliases of shadowed parameters', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + + export function run(request) { + const action = request; + action({ connectionId: 'shadowed-local-alias' }); + } + `, + }, + ])( + 'Should ignore action-catalog import names shadowed by local bindings: $description', + ({ code }) => { + expect(extractConnectionIds(parseModule(code), filePath)).toEqual([]); + }, + ); + + test.each([ + { + description: 'identifier', + expression: 'CONNECTION_ID', + expectedType: 'Identifier', + }, + { + description: 'template literal', + expression: '`conn-template`', + expectedType: 'TemplateLiteral', + }, + { + description: 'member expression', + expression: 'CONNECTIONS.HTTP', + expectedType: 'MemberExpression', + }, + { + description: 'call expression', + expression: 'getConnectionId()', + expectedType: 'CallExpression', + }, + { + description: 'binary expression', + expression: "'conn-' + suffix", + expectedType: 'BinaryExpression', + }, + ])( + 'Should fail closed for unsupported connectionId value expressions: $description', + ({ expression, expectedType }) => { + const ast = parseModule(` + import { request } from '@datadog/action-catalog/http/http'; + request({ connectionId: ${expression} }); + `); + + expect(() => extractConnectionIds(ast, filePath)).toThrow( + `expected an inline string literal, got ${expectedType}`, + ); + }, + ); +}); diff --git a/packages/plugins/apps/src/backend/extract-connection-ids.ts b/packages/plugins/apps/src/backend/extract-connection-ids.ts new file mode 100644 index 000000000..a43787052 --- /dev/null +++ b/packages/plugins/apps/src/backend/extract-connection-ids.ts @@ -0,0 +1,586 @@ +// 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 { + CallExpression, + Expression, + ImportDeclaration, + ImportSpecifier, + MemberExpression, + Node, + ObjectExpression, + Program, + Property, + Statement, + Super, + VariableDeclarator, +} from 'estree'; +import type { AstNode } from 'rollup'; + +const ACTION_CATALOG_PACKAGE = '@datadog/action-catalog'; + +interface ActionCatalogImports { + functions: Set; + namespaces: Set; + unsupportedAliases: Set; +} + +class ConnectionIdExtractionError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConnectionIdExtractionError'; + } +} + +/** + * Extracts inline action-catalog connection IDs from one backend module AST. + */ +export function extractConnectionIds(ast: AstNode, filePath: string): string[] { + // Rollup's this.parse(code) should return the module root for code like + // `import { request } from '@datadog/action-catalog/http/http';`. + if (!isProgramNode(ast)) { + throw new Error( + `Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`, + ); + } + + const actionImports = collectActionCatalogImports(ast); + const ids = new Set(); + + walkWithScope(ast, actionImports, (node, shadowedBindings) => { + // Only call sites such as `request({ connectionId: 'abc' })` can + // contain backend action connection IDs. + if (node.type !== 'CallExpression') { + return; + } + failIfUnsupportedActionCatalogCallee( + node.callee, + actionImports, + shadowedBindings, + filePath, + ); + if (!isActionCatalogCallee(node.callee, actionImports, shadowedBindings)) { + return; + } + + for (const id of extractIdsFromActionCatalogCall(node, filePath)) { + ids.add(id); + } + }); + + return [...ids].sort(); +} + +/** + * Narrows a Rollup AST node to the ESTree module root produced by this.parse(). + */ +function isProgramNode(node: AstNode): node is AstNode & Program { + return node.type === 'Program'; +} + +/** + * Reports whether a whole import declaration is type-only. + */ +function isTypeOnlyImport(node: ImportDeclaration): boolean { + return (node as ImportDeclaration & { importKind?: string }).importKind === 'type'; +} + +/** + * Reports whether a named import specifier is type-only. + */ +function isTypeOnlyImportSpecifier(node: ImportSpecifier): boolean { + return (node as ImportSpecifier & { importKind?: string }).importKind === 'type'; +} + +/** + * Reports whether an import source belongs to the action-catalog package. + */ +function isActionCatalogSource(source: string): boolean { + return source === ACTION_CATALOG_PACKAGE || source.startsWith(`${ACTION_CATALOG_PACKAGE}/`); +} + +/** + * Collects action-catalog function imports, namespace imports, and unsupported local aliases. + */ +function collectActionCatalogImports(ast: Program): ActionCatalogImports { + const functions = new Set(); + const namespaces = new Set(); + const unsupportedAliases = new Set(); + + for (const node of ast.body) { + // Keep only action-catalog imports like + // `import { request } from '@datadog/action-catalog/http/http';`. + if ( + node.type !== 'ImportDeclaration' || + isTypeOnlyImport(node) || + typeof node.source.value !== 'string' || + !isActionCatalogSource(node.source.value) + ) { + continue; + } + + for (const spec of node.specifiers) { + // `import { request as httpRequest } from '...'` + if (spec.type === 'ImportSpecifier') { + if (!isTypeOnlyImportSpecifier(spec)) { + functions.add(spec.local.name); + } + // `import request from '@datadog/action-catalog/http/http'` + } else if (spec.type === 'ImportDefaultSpecifier') { + functions.add(spec.local.name); + // `import * as http from '@datadog/action-catalog/http/http'` + } else if (spec.type === 'ImportNamespaceSpecifier') { + namespaces.add(spec.local.name); + } + } + } + + walkWithScope(ast, { functions, namespaces, unsupportedAliases }, (node, shadowedBindings) => { + // Aliases are introduced through declarations like `const action = request`. + if (node.type !== 'VariableDeclarator') { + return; + } + // `const action = request` aliases a named/default action import. + if ( + node.id.type === 'Identifier' && + node.init?.type === 'Identifier' && + functions.has(node.init.name) && + !shadowedBindings.has(node.init.name) + ) { + unsupportedAliases.add(node.id.name); + return; + } + // `const action = http.request` aliases a namespace action import. + if ( + node.id.type === 'Identifier' && + node.init?.type === 'MemberExpression' && + isNamespaceMember(node.init, namespaces, shadowedBindings) + ) { + unsupportedAliases.add(node.id.name); + return; + } + // `const { request: action } = http` aliases a namespace action import. + if ( + node.id.type !== 'ObjectPattern' || + node.init?.type !== 'Identifier' || + !namespaces.has(node.init.name) || + shadowedBindings.has(node.init.name) + ) { + return; + } + for (const prop of node.id.properties) { + // In `const { request: action } = http`, `action` is the local binding. + if (prop.type !== 'Property' || prop.computed) { + continue; + } + if (prop.value.type === 'Identifier') { + unsupportedAliases.add(prop.value.name); + } + } + }); + + return { functions, namespaces, unsupportedAliases }; +} + +/** + * Extracts connection IDs from a statically analyzable action-catalog call. + */ +function extractIdsFromActionCatalogCall(call: CallExpression, filePath: string): string[] { + failIfOptionalActionCatalogCall(call, filePath); + + const firstArg = call.arguments[0]; + // Support `request({ connectionId: 'abc' })`; reject `request(options)`. + if (!firstArg || firstArg.type !== 'ObjectExpression') { + fail( + `Unsupported action-catalog call in ${filePath}: the first argument must be an object literal so connectionId can be statically analyzed.`, + ); + } + + const connectionIdValue = findConnectionIdValue(firstArg, filePath); + if (!connectionIdValue) { + return []; + } + // In PR #339, only inline strings such as `{ connectionId: 'abc' }` + // are supported; later PRs widen this to const references. + if (connectionIdValue.type !== 'Literal' || typeof connectionIdValue.value !== 'string') { + fail( + `Unsupported connectionId expression in ${filePath}: expected an inline string literal, got ${connectionIdValue.type}.`, + ); + } + return [connectionIdValue.value]; +} + +/** + * Fails when an action-catalog call uses optional chaining that can hide the invoked callee. + */ +function failIfOptionalActionCatalogCall(call: CallExpression, filePath: string): void { + // `request?.({ connectionId: 'abc' })` and `http?.request(...)` can hide + // which callee is actually invoked. + if (isOptionalNode(call) || containsOptionalMember(call.callee)) { + fail( + `Unsupported action-catalog call in ${filePath}: optional chaining cannot be statically analyzed for connectionId.`, + ); + } +} + +/** + * Finds the visible connectionId property value in an object-literal call argument. + */ +function findConnectionIdValue(obj: ObjectExpression, filePath: string): Expression | undefined { + let connectionIdValue: Expression | undefined; + for (const prop of obj.properties) { + // `{ connectionId: 'visible', ...opts }` can be overwritten by `opts`. + if (prop.type === 'SpreadElement') { + fail( + `Unsupported action-catalog call in ${filePath}: object spreads can hide connectionId.`, + ); + } + // ObjectExpression also allows spread elements; only key/value + // properties like `{ connectionId: 'abc' }` are useful here. + if (prop.type !== 'Property') { + continue; + } + // `{ ['connectionId']: 'abc' }` is intentionally rejected so the key + // is visible without evaluating JavaScript. + if (prop.computed) { + fail( + `Unsupported action-catalog call in ${filePath}: computed object keys can hide connectionId.`, + ); + } + // Match both `{ connectionId: 'abc' }` and `{ 'connectionId': 'abc' }`. + if (isConnectionIdProperty(prop)) { + if (connectionIdValue) { + fail( + `Unsupported action-catalog call in ${filePath}: multiple connectionId properties cannot be statically analyzed.`, + ); + } + connectionIdValue = prop.value as Expression; + } + } + return connectionIdValue; +} + +/** + * Reports whether an object property key is the static connectionId key. + */ +function isConnectionIdProperty(prop: Property): boolean { + // Identifier key in `{ connectionId: 'abc' }`. + if (prop.key.type === 'Identifier') { + return prop.key.name === 'connectionId'; + } + // Literal key in `{ 'connectionId': 'abc' }`. + return prop.key.type === 'Literal' && prop.key.value === 'connectionId'; +} + +/** + * Reports whether a call expression callee resolves directly to an action-catalog import. + */ +function isActionCatalogCallee( + callee: Expression | Super, + imports: ActionCatalogImports, + shadowedBindings: Set, +): boolean { + // Named/default import call: `request({ connectionId: 'abc' })`. + if (callee.type === 'Identifier') { + return imports.functions.has(callee.name) && !shadowedBindings.has(callee.name); + } + // Namespace import call: `http.request({ connectionId: 'abc' })`. + if (callee.type !== 'MemberExpression') { + return false; + } + return isNamespaceMember(callee, imports.namespaces, shadowedBindings); +} + +/** + * Fails on action-catalog call shapes this PR intentionally cannot analyze. + */ +function failIfUnsupportedActionCatalogCallee( + callee: Expression | Super, + imports: ActionCatalogImports, + shadowedBindings: Set, + filePath: string, +): void { + // Unsupported alias call: `const action = request; action(...)`. + if ( + callee.type === 'Identifier' && + imports.unsupportedAliases.has(callee.name) && + !shadowedBindings.has(callee.name) + ) { + fail( + `Unsupported action-catalog call in ${filePath}: action-catalog call aliases cannot be statically analyzed for connectionId.`, + ); + } + // Unsupported computed namespace call: `http['request'](...)`. + if ( + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + imports.namespaces.has(callee.object.name) && + !shadowedBindings.has(callee.object.name) && + callee.computed + ) { + fail( + `Unsupported action-catalog call in ${filePath}: computed namespace member calls cannot be statically analyzed for connectionId.`, + ); + } +} + +/** + * Reports whether a member expression is a direct access on an action-catalog namespace import. + */ +function isNamespaceMember( + member: MemberExpression, + namespaces: Set, + shadowedBindings: Set, +): boolean { + return ( + // `http.request(...)` where `http` came from `import * as http`. + member.object.type === 'Identifier' && + namespaces.has(member.object.name) && + !shadowedBindings.has(member.object.name) && + // The member must be statically named, unlike `http[actionName](...)`. + member.property.type === 'Identifier' && + !member.computed + ); +} + +/** + * Recursively reports whether a callee member chain includes optional access. + */ +function containsOptionalMember(node: Node): boolean { + // Finds optional access in callees like `http?.request(...)`. + if (node.type === 'MemberExpression') { + return isOptionalNode(node) || containsOptionalMember(node.object); + } + return false; +} + +/** + * Reads the optional flag that ESTree parsers attach to optional call/member nodes. + */ +function isOptionalNode(node: Node): boolean { + return (node as Node & { optional?: boolean }).optional === true; +} + +/** + * Walks an ESTree subtree while tracking local bindings that shadow action-catalog imports. + * + * For example, given: + * + * ```ts + * import { request } from '@datadog/action-catalog/http/http'; + * request({ connectionId: 'real-action' }); + * export function run(request) { + * request({ connectionId: 'local-param' }); + * } + * ``` + * + * The visitor sees the top-level `request(...)` with an empty shadow set, so it can be treated as + * the imported action. Inside `run`, the function parameter shadows the import, so the visitor sees + * `shadowedBindings.has('request') === true` and ignores that local call. + */ +function walkWithScope( + node: Node, + imports: ActionCatalogImports, + visit: (node: Node, shadowedBindings: Set) => void, + shadowedBindings = new Set(), +): void { + visit(node, shadowedBindings); + + // Module body for source like `import ...; export function run() {}`. + if (node.type === 'Program') { + for (const statement of node.body) { + walkWithScope(statement, imports, visit, shadowedBindings); + } + return; + } + // Block scope for `{ const request = localClient; request(...) }`. + if (node.type === 'BlockStatement') { + const blockScope = new Set(shadowedBindings); + collectShadowingDeclarations(node.body, imports, blockScope); + for (const statement of node.body) { + walkWithScope(statement, imports, visit, blockScope); + } + return; + } + // Function parameters can shadow action imports: + // `function run(request) { request({ connectionId: 'local' }) }`. + if ( + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ) { + const functionScope = new Set(shadowedBindings); + for (const param of node.params) { + addShadowedPatternBindings(param, imports, functionScope); + } + walkWithScope(node.body, imports, visit, functionScope); + return; + } + // Catch parameters can shadow imports: + // `catch (request) { request({ connectionId: 'local' }) }`. + if (node.type === 'CatchClause') { + const catchScope = new Set(shadowedBindings); + if (node.param) { + addShadowedPatternBindings(node.param, imports, catchScope); + } + walkWithScope(node.body, imports, visit, catchScope); + return; + } + + for (const value of Object.values(node as unknown as Record)) { + if (Array.isArray(value)) { + for (const child of value) { + if (isNode(child)) { + walkWithScope(child, imports, visit, shadowedBindings); + } + } + } else if (isNode(value)) { + walkWithScope(value, imports, visit, shadowedBindings); + } + } +} + +/** + * Adds block-scoped declarations that shadow tracked action-catalog names. + */ +function collectShadowingDeclarations( + statements: Statement[], + imports: ActionCatalogImports, + shadowedBindings: Set, +): void { + for (const statement of statements) { + if (statement.type === 'VariableDeclaration') { + for (const declaration of statement.declarations) { + // Preserve action aliases like `const action = request` as + // unsupported aliases instead of treating them as local shadowing. + if (isActionCatalogAliasDeclaration(declaration, imports, shadowedBindings)) { + continue; + } + addShadowedPatternBindings(declaration.id, imports, shadowedBindings); + } + // `function request() {}` shadows an imported `request` inside the block. + } else if (statement.type === 'FunctionDeclaration' && statement.id) { + addShadowedBinding(statement.id.name, imports, shadowedBindings); + // `class http {}` shadows an imported namespace named `http`. + } else if (statement.type === 'ClassDeclaration' && statement.id) { + addShadowedBinding(statement.id.name, imports, shadowedBindings); + } + } +} + +/** + * Reports whether a variable declarator creates an unsupported alias of an action-catalog call. + */ +function isActionCatalogAliasDeclaration( + declaration: VariableDeclarator, + imports: ActionCatalogImports, + shadowedBindings: Set, +): boolean { + // `const action = request` + if ( + declaration.id.type === 'Identifier' && + declaration.init?.type === 'Identifier' && + imports.functions.has(declaration.init.name) && + !shadowedBindings.has(declaration.init.name) + ) { + return true; + } + // `const action = http.request` + if ( + declaration.id.type === 'Identifier' && + declaration.init?.type === 'MemberExpression' && + isNamespaceMember(declaration.init, imports.namespaces, shadowedBindings) + ) { + return true; + } + // `const { request: action } = http` + return ( + declaration.id.type === 'ObjectPattern' && + declaration.init?.type === 'Identifier' && + imports.namespaces.has(declaration.init.name) && + !shadowedBindings.has(declaration.init.name) + ); +} + +/** + * Adds every identifier introduced by a binding pattern to the current shadowing set. + */ +function addShadowedPatternBindings( + pattern: Node, + imports: ActionCatalogImports, + shadowedBindings: Set, +): void { + for (const name of getPatternBindingNames(pattern)) { + addShadowedBinding(name, imports, shadowedBindings); + } +} + +/** + * Adds a local binding name when it shadows an action-catalog import or alias. + */ +function addShadowedBinding( + name: string, + imports: ActionCatalogImports, + shadowedBindings: Set, +): void { + if ( + imports.functions.has(name) || + imports.namespaces.has(name) || + imports.unsupportedAliases.has(name) + ) { + shadowedBindings.add(name); + } +} + +/** + * Returns the identifier names declared by an ESTree binding pattern. + */ +function getPatternBindingNames(pattern: Node): string[] { + // `request` in `function run(request) {}` or `const request = client`. + if (pattern.type === 'Identifier') { + return [pattern.name]; + } + // `rest` in `const { ...rest } = value`. + if (pattern.type === 'RestElement') { + return getPatternBindingNames(pattern.argument); + } + // `request` in `function run(request = client) {}`. + if (pattern.type === 'AssignmentPattern') { + return getPatternBindingNames(pattern.left); + } + // `request` in `const [request] = clients`. + if (pattern.type === 'ArrayPattern') { + return pattern.elements.flatMap((element) => + element ? getPatternBindingNames(element) : [], + ); + } + // `request` in `const { client: request } = clients`. + if (pattern.type === 'ObjectPattern') { + return pattern.properties.flatMap((prop) => { + if (prop.type === 'RestElement') { + return getPatternBindingNames(prop.argument); + } + return getPatternBindingNames(prop.value as Node); + }); + } + return []; +} + +/** + * Reports whether an unknown value looks like an ESTree node. + */ +function isNode(value: unknown): value is Node { + return ( + value !== null && + typeof value === 'object' && + typeof (value as { type?: unknown }).type === 'string' + ); +} + +/** + * Throws a consistently prefixed extraction error. + */ +function fail(message: string): never { + throw new ConnectionIdExtractionError(`[connectionId manifest] ${message}`); +} diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 5daa06bdc..7b1a2c139 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -17,6 +17,8 @@ import { mockLogFn, } from '@dd/tests/_jest/helpers/mocks'; import { runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; +import { parse } from 'acorn'; +import type { Program } from 'estree'; import fsp from 'fs/promises'; import nock from 'nock'; import path from 'path'; @@ -30,6 +32,13 @@ function extractCloseBundle(plugins: PluginOptions[]) { return plugin.vite!.closeBundle as () => Promise; } +function parseModule(code: string) { + return parse(code, { + ecmaVersion: 'latest', + sourceType: 'module', + }) as unknown as Program; +} + describe('Apps Plugin - getPlugins', () => { const buildRoot = '/project'; const outDir = '/project/dist'; @@ -250,21 +259,9 @@ describe('Apps Plugin - getPlugins', () => { const transform = plugins[0].transform as { handler: (code: string, id: string) => unknown; }; - transform.handler.call( + await transform.handler.call( { - parse: () => ({ - type: 'Program', - body: [ - { - type: 'ExportNamedDeclaration', - declaration: { - type: 'FunctionDeclaration', - id: { type: 'Identifier', name: 'greet' }, - }, - specifiers: [], - }, - ], - }), + parse: parseModule, }, 'export function greet() {}', '/project/src/backend/greet.backend.js', @@ -293,6 +290,82 @@ describe('Apps Plugin - getPlugins', () => { ).toEqual([{ allowedConnectionIds: [] }]); }); + test('Should apply the same inline connection allowlist to every backend export', async () => { + jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({ + identifier: 'repo:app', + name: 'test-app', + }); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: '/project/dist/index.js', relativePath: 'dist/index.js' }, + ]); + jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined); + jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ + errors: [], + warnings: [], + }); + + let manifest: unknown; + jest.spyOn(archive, 'createArchive').mockImplementation(async (archiveAssets) => { + const manifestAsset = archiveAssets.find( + (asset) => asset.relativePath === 'manifest.json', + ); + expect(manifestAsset).toBeDefined(); + manifest = JSON.parse(await fsp.readFile(manifestAsset!.absolutePath, 'utf8')); + return { + archivePath: '/tmp/dd-apps-allowlist/datadog-apps-assets.zip', + assets: archiveAssets, + size: 30, + }; + }); + + const viteBuild = jest.fn().mockResolvedValue({ + output: [ + { + type: 'chunk', + isEntry: true, + name: expect.any(String), + fileName: 'unused.backend.js', + }, + ], + }); + const args = getArgs(); + args.bundler = { build: viteBuild }; + const plugins = getPlugins(args); + const transform = plugins[0].transform as { + handler: (code: string, id: string) => unknown; + }; + const code = ` + import { request } from '@datadog/action-catalog/http/http'; + import runSlack from '@datadog/action-catalog/slack'; + + export function alpha() { + return request({ connectionId: 'conn-b', inputs: {} }); + } + + export function beta() { + return runSlack({ connectionId: 'conn-a', inputs: {} }); + } + `; + await transform.handler.call( + { + parse: parseModule, + }, + code, + '/project/src/backend/multi.backend.js', + ); + + await extractCloseBundle(plugins)(); + + const functions = Object.values( + (manifest as { backend: { functions: Record } }).backend.functions, + ); + expect(functions).toHaveLength(2); + expect(functions).toEqual([ + { allowedConnectionIds: ['conn-a', 'conn-b'] }, + { allowedConnectionIds: ['conn-a', 'conn-b'] }, + ]); + }); + test('Should surface upload errors', async () => { jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({ identifier: 'repo:app', diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 9b54e625c..12d5ddc9c 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -16,6 +16,7 @@ import { collectAssets } from './assets'; import type { BackendFunction } from './backend/discovery'; import { extractExportedFunctions } from './backend/discovery'; import { encodeQueryName } from './backend/encodeQueryName'; +import { extractConnectionIds } from './backend/extract-connection-ids'; import { generateProxyModule } from './backend/proxy-codegen'; import { BACKEND_FILE_RE, CONFIG_KEY, PLUGIN_NAME } from './constants'; import { resolveIdentifier } from './identifier'; @@ -34,6 +35,7 @@ function buildProxyModule( exportNames: string[], id: string, buildRoot: string, + allowedConnectionIds: string[], ): { functions: BackendFunction[]; proxyCode: string } { const relativePath = path.relative(buildRoot, id); const refPath = relativePath.replace(BACKEND_FILE_RE, ''); @@ -46,7 +48,7 @@ function buildProxyModule( relativePath: refPath, name: exportName, absolutePath: id, - allowedConnectionIds: [], + allowedConnectionIds: [...allowedConnectionIds], }; functions.push(func); proxyExports.push({ exportName, queryName: encodeQueryName(func) }); @@ -275,7 +277,8 @@ Either: // them as backend functions, and replace the module with a // frontend proxy that calls executeBackendFunction at runtime. handler(code, id) { - const exportNames = extractExportedFunctions(this.parse(code), id); + const ast = this.parse(code); + const exportNames = extractExportedFunctions(ast, id); if (exportNames.length === 0) { log.warn( `Backend file ${id} has no exported functions. ` + @@ -291,6 +294,7 @@ Either: exportNames, id, context.buildRoot, + extractConnectionIds(ast, id), ); setBackendFunctions(id, functions); log.debug(`Generated proxy for ${id} with ${functions.length} export(s)`);