From 7e9d9bb516481095eea34cd330bda0b5a084d194 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 30 Jul 2026 16:07:28 -0700 Subject: [PATCH 1/4] feat(apps): reject Node built-in imports in backend files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend functions run in a restricted environment (isomorphic/fetch-based APIs only), so direct static imports of Node built-in modules (fs, child_process, net, etc.) in .backend.ts files are now rejected at build time in the Vite transform hook, right after AST parsing. This is a best-effort, defense-in-depth check on static import specifiers only — it does not catch require() or dynamic import() of a computed specifier. --- .../reject-node-builtin-imports.test.ts | 108 ++++++++++++++++++ .../reject-node-builtin-imports.ts | 40 +++++++ packages/plugins/apps/src/vite/index.test.ts | 25 ++++ packages/plugins/apps/src/vite/index.ts | 2 + 4 files changed, 175 insertions(+) create mode 100644 packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts new file mode 100644 index 000000000..bdbae89c5 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts @@ -0,0 +1,108 @@ +// 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 { rejectNodeBuiltinImports } from '@dd/apps-plugin/backend/ast-parsing/reject-node-builtin-imports'; +import type { ImportDeclaration, Program } from 'estree'; + +/** + * Helper to build a minimal ESTree Program for testing. + */ +function program(body: Program['body']): Program { + return { type: 'Program', sourceType: 'module', body }; +} + +/** + * Helper to build a minimal ImportDeclaration node for a given source. + */ +function importDecl(source: string, overrides: Partial = {}): ImportDeclaration { + return { + type: 'ImportDeclaration', + specifiers: [ + { + type: 'ImportDefaultSpecifier', + local: { type: 'Identifier', name: 'x' }, + }, + ], + source: { type: 'Literal', value: source }, + attributes: [], + ...overrides, + }; +} + +describe('Backend Functions - rejectNodeBuiltinImports', () => { + const filePath = '/project/src/math.backend.ts'; + + const allowedCases = [ + { + description: 'allow importing a relative module', + source: './helpers', + }, + { + description: 'allow importing a scoped npm package', + source: '@datadog/action-catalog', + }, + { + description: 'allow importing an ordinary npm package', + source: 'lodash', + }, + ]; + + test.each(allowedCases)('Should $description', ({ source }) => { + const ast = program([importDecl(source)]); + expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); + }); + + const rejectedCases = [ + { + description: 'reject importing "node:fs" via the node: prefix', + source: 'node:fs', + }, + { + description: 'reject importing the bare built-in "fs"', + source: 'fs', + }, + { + description: 'reject importing "child_process"', + source: 'child_process', + }, + { + description: 'reject importing "node:child_process"', + source: 'node:child_process', + }, + { + description: 'reject importing "net"', + source: 'net', + }, + { + description: 'reject importing a built-in subpath "fs/promises"', + source: 'fs/promises', + }, + ]; + + test.each(rejectedCases)('Should $description', ({ source }) => { + const ast = program([importDecl(source)]); + expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow( + `Importing Node built-in module "${source}" is not supported in .backend.ts files`, + ); + expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow(filePath); + }); + + test('Should allow a type-only import of a Node built-in', () => { + // import type { Stats } from 'fs'; + const ast = program([ + importDecl('fs', { importKind: 'type' } as Partial), + ]); + expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); + }); + + test('Should ignore non-import statements', () => { + const ast = program([ + { + type: 'ExpressionStatement', + expression: { type: 'Literal', value: 1 }, + }, + ]); + expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); + }); +}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts new file mode 100644 index 000000000..d11e56163 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts @@ -0,0 +1,40 @@ +// 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 { BaseNode } from 'estree'; +import { builtinModules } from 'node:module'; + +import { ensureProgram, isTypeOnly } from './type-guards'; + +const RESTRICTED_MODULES = new Set(builtinModules); + +function isRestrictedSource(source: string): boolean { + return source.startsWith('node:') || RESTRICTED_MODULES.has(source); +} + +/** + * Reject static imports of Node built-in modules in `.backend.ts` files. + * Backend functions run in a restricted environment (isomorphic/fetch-based + * APIs only) so direct Node built-in usage isn't supported. + * + * This is a best-effort, defense-in-depth check on static `import` specifiers + * only — it doesn't catch `require()` or dynamic `import()` of a computed + * specifier. + */ +export function rejectNodeBuiltinImports(ast: BaseNode, filePath: string): void { + const program = ensureProgram(ast, filePath); + for (const node of program.body) { + if (node.type !== 'ImportDeclaration' || isTypeOnly(node)) { + continue; + } + + const source = node.source.value; + if (typeof source === 'string' && isRestrictedSource(source)) { + throw new Error( + `Importing Node built-in module "${source}" is not supported in .backend.ts files. ` + + `Backend functions run in a restricted environment and must use fetch-based/isomorphic APIs instead: ${filePath}`, + ); + } + } +} diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 3a798312f..debb6b72e 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -160,6 +160,31 @@ describe('Backend Functions - getVitePlugin', () => { expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], '/build'); }); + test('Should reject a backend file importing a Node built-in module', () => { + const plugin = getVitePlugin(defaultOptions); + const transform = plugin!.transform as { + handler: (code: string, id: string) => unknown; + }; + + expect(() => + transform.handler.call( + { + parse: parseAst, + resolve: jest.fn(async () => null), + load: jest.fn(async () => null), + addWatchFile: jest.fn(), + }, + ` + import fs from 'node:fs'; + export function myHandler() { + return fs.readFileSync('/etc/passwd', 'utf8'); + } + `, + '/build/src/backend/myHandler.backend.ts', + ), + ).toThrow('Importing Node built-in module "node:fs" is not supported in .backend.ts files'); + }); + test('Should inject the apps runtime', () => { getVitePlugin(defaultOptions); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 831ce75c2..f23481cb2 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -14,6 +14,7 @@ import { type DoAuthenticatedRequest, } from '../auth'; import { extractExportedFunctions } from '../backend/ast-parsing/extract-backend-functions'; +import { rejectNodeBuiltinImports } from '../backend/ast-parsing/reject-node-builtin-imports'; import { encodeQueryName } from '../backend/encodeQueryName'; import { generateProxyModule } from '../backend/proxy-codegen'; import type { BackendFunction } from '../backend/types'; @@ -130,6 +131,7 @@ export const getVitePlugin = ({ // frontend proxy that calls executeBackendFunction at runtime. handler(code, id) { const ast = this.parse(code); + rejectNodeBuiltinImports(ast, id); const exportNames = extractExportedFunctions(ast, id); if (exportNames.length === 0) { log.warn( From e17c9c87543dc21c511358dce0b87e8ce7928456 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 7 Aug 2026 13:27:30 -0700 Subject: [PATCH 2/4] feat(apps): reject network globals in backend files Backend functions have no raw network access in every real production runtime -- Deno's --allow-net is off today, and the planned Terrapin-based v2 sandbox restricts it the same way -- so any outbound call must go through an Action Platform action ($.Actions or an @datadog/action-catalog typed wrapper), never a direct HTTP client. rejectNodeBuiltinImports only catches import specifiers; fetch and friends need no import at all, so this adds a separate, eslint-scope-based check for unshadowed references to fetch, XMLHttpRequest, WebSocket, and EventSource. Also corrects rejectNodeBuiltinImports' doc comment and error message, which previously pointed to fetch-based/isomorphic APIs as the allowed escape hatch -- no longer accurate now that fetch itself is blocked too. --- .../reject-node-builtin-imports.ts | 12 ++-- .../reject-restricted-globals.test.ts | 65 +++++++++++++++++++ .../ast-parsing/reject-restricted-globals.ts | 53 +++++++++++++++ packages/plugins/apps/src/vite/index.ts | 2 + 4 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts index d11e56163..e39e5eab3 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts @@ -15,12 +15,15 @@ function isRestrictedSource(source: string): boolean { /** * Reject static imports of Node built-in modules in `.backend.ts` files. - * Backend functions run in a restricted environment (isomorphic/fetch-based - * APIs only) so direct Node built-in usage isn't supported. + * Backend functions run in a restricted environment with no direct Node + * built-in or network access — everything, including raw HTTP requests, + * must go through an Action Platform action ($.Actions or an + * @datadog/action-catalog typed wrapper). * * This is a best-effort, defense-in-depth check on static `import` specifiers * only — it doesn't catch `require()` or dynamic `import()` of a computed - * specifier. + * specifier. See also `rejectRestrictedGlobals`, which covers bare network + * globals like `fetch` that need no import at all. */ export function rejectNodeBuiltinImports(ast: BaseNode, filePath: string): void { const program = ensureProgram(ast, filePath); @@ -33,7 +36,8 @@ export function rejectNodeBuiltinImports(ast: BaseNode, filePath: string): void if (typeof source === 'string' && isRestrictedSource(source)) { throw new Error( `Importing Node built-in module "${source}" is not supported in .backend.ts files. ` + - `Backend functions run in a restricted environment and must use fetch-based/isomorphic APIs instead: ${filePath}`, + `Backend functions run in a restricted environment and must use an Action ` + + `Platform action ($.Actions or an @datadog/action-catalog typed wrapper) instead: ${filePath}`, ); } } diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts new file mode 100644 index 000000000..d43ec70b7 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts @@ -0,0 +1,65 @@ +// 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 { rejectRestrictedGlobals } from '@dd/apps-plugin/backend/ast-parsing/reject-restricted-globals'; +import { parseAst } from 'rollup/parseAst'; + +describe('Backend Functions - rejectRestrictedGlobals', () => { + const filePath = '/project/src/math.backend.ts'; + + const rejectedCases = [ + { + description: 'reject a bare fetch() call', + code: 'export async function run() { return fetch("https://example.com"); }', + }, + { + description: 'reject fetch referenced without calling it', + code: 'export function run() { const f = fetch; return f; }', + }, + { + description: 'reject new XMLHttpRequest()', + code: 'export function run() { return new XMLHttpRequest(); }', + }, + { + description: 'reject new WebSocket(...)', + code: 'export function run() { return new WebSocket("wss://example.com"); }', + }, + { + description: 'reject new EventSource(...)', + code: 'export function run() { return new EventSource("/events"); }', + }, + ]; + + test.each(rejectedCases)('Should $description', ({ code }) => { + const ast = parseAst(code); + expect(() => rejectRestrictedGlobals(ast, filePath)).toThrow( + 'is not supported in .backend.ts files', + ); + expect(() => rejectRestrictedGlobals(ast, filePath)).toThrow(filePath); + }); + + const allowedCases = [ + { + description: 'allow calling an imported action-catalog function', + code: "import { request } from '@datadog/action-catalog/http/http';\nexport async function run() { return request({ inputs: {} }); }", + }, + { + description: 'allow a locally-declared function that happens to be named fetch', + code: 'function fetch() { return "local"; }\nexport function run() { return fetch(); }', + }, + { + description: 'allow a parameter named fetch shadowing the global', + code: 'export function run(fetch) { return fetch(); }', + }, + { + description: 'allow unrelated code with no restricted-global references', + code: 'export function run(a, b) { return a + b; }', + }, + ]; + + test.each(allowedCases)('Should $description', ({ code }) => { + const ast = parseAst(code); + expect(() => rejectRestrictedGlobals(ast, filePath)).not.toThrow(); + }); +}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts new file mode 100644 index 000000000..f7a3b8bd6 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts @@ -0,0 +1,53 @@ +// 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 { BaseNode } from 'estree'; + +import { analyzeModuleScope } from './module-scope'; +import { ensureProgram } from './type-guards'; + +const RESTRICTED_GLOBALS = new Set(['fetch', 'XMLHttpRequest', 'WebSocket', 'EventSource']); + +/** + * Reject references to network-capable globals in `.backend.ts` files. + * Backend functions have no raw network access under today's v1 runtime — + * Deno's `--allow-net` is off — so any outbound call must go through an + * Action Platform action (`$.Actions` or an `@datadog/action-catalog` typed + * wrapper), never a direct HTTP client. Import-specifier restriction alone + * can't catch this: these are bare globals, not imports. + * + * This is the enforcement layer for a trap that's easy to fall into + * otherwise: `fetch` works fine during local dev (nothing stopped it before + * this check existed) but fails once the app is actually published, since + * production's sandbox blocks it. A separate, complementary effort adds + * AI-authoring guidance steering generated code away from `fetch` in the + * first place — that reduces how often this gets written at all, but only + * this build-time check actually guarantees it never ships, regardless of + * whether the code came from an AI, a human, or a copy-pasted snippet. + * + * Backend functions' planned v2 (Terrapin-based) sandbox will lift this + * restriction — legacy (pre-v2) apps are the ones that need it. + * + * This is a best-effort, defense-in-depth check: it flags any reference to one + * of these names that eslint-scope can't resolve to a declaration in this + * module (i.e. it falls through to the ambient global instead of a local + * variable or import that happens to share the name). + */ +export function rejectRestrictedGlobals(ast: BaseNode, filePath: string): void { + const program = ensureProgram(ast, filePath); + const scopeAnalysis = analyzeModuleScope(program); + + for (const [identifier, reference] of scopeAnalysis.referencesByIdentifier) { + if (!RESTRICTED_GLOBALS.has(identifier.name) || reference.resolved) { + continue; + } + + throw new Error( + `Using "${identifier.name}" is not supported in .backend.ts files. ` + + `Backend functions cannot make raw network requests in production — ` + + `use an Action Platform action ($.Actions or an @datadog/action-catalog ` + + `typed wrapper) instead: ${filePath}`, + ); + } +} diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index f23481cb2..9e4cf3a96 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -15,6 +15,7 @@ import { } from '../auth'; import { extractExportedFunctions } from '../backend/ast-parsing/extract-backend-functions'; import { rejectNodeBuiltinImports } from '../backend/ast-parsing/reject-node-builtin-imports'; +import { rejectRestrictedGlobals } from '../backend/ast-parsing/reject-restricted-globals'; import { encodeQueryName } from '../backend/encodeQueryName'; import { generateProxyModule } from '../backend/proxy-codegen'; import type { BackendFunction } from '../backend/types'; @@ -132,6 +133,7 @@ export const getVitePlugin = ({ handler(code, id) { const ast = this.parse(code); rejectNodeBuiltinImports(ast, id); + rejectRestrictedGlobals(ast, id); const exportNames = extractExportedFunctions(ast, id); if (exportNames.length === 0) { log.warn( From cadf4c0dd27ccf70424ce004e00bba5f011cfa21 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 21 Aug 2026 14:18:13 -0400 Subject: [PATCH 3/4] fix(apps): close named re-export, globalThis-qualified, and destructured global bypasses reject-node-builtin-imports only walked ImportDeclarations, so a named re-export like `export { readFile as handler } from 'node:fs'` loaded the same builtin without ever binding a local identifier, sailing through undetected. Now also walks ExportNamedDeclaration nodes with a source, reusing the same restricted-source check. reject-restricted-globals only caught bare identifier references resolved via eslint-scope, missing globalThis.fetch(...)/globalThis['fetch'] (ESTree represents the property as a member access, not a reference eslint-scope tracks) and const { fetch } = globalThis (binds a local that then shadows the scope-based check entirely). Added a dedicated AST walk for both forms. Also replaced an `as` cast in a test with the codebase's existing intersection-type pattern for ESTree's incomplete parser-metadata types, and named two inlined function-call results before passing them to another call, per this repo's no-inlined-call-arguments convention. --- .../reject-node-builtin-imports.test.ts | 87 ++++++++++++++-- .../reject-node-builtin-imports.ts | 36 ++++--- .../reject-restricted-globals.test.ts | 28 ++++++ .../ast-parsing/reject-restricted-globals.ts | 98 +++++++++++++++++-- .../src/backend/ast-parsing/type-guards.ts | 2 +- 5 files changed, 222 insertions(+), 29 deletions(-) diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts index bdbae89c5..5ab0f9112 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts @@ -3,7 +3,9 @@ // Copyright 2019-Present Datadog, Inc. import { rejectNodeBuiltinImports } from '@dd/apps-plugin/backend/ast-parsing/reject-node-builtin-imports'; -import type { ImportDeclaration, Program } from 'estree'; +import type { TypeScriptImportExportMetadata } from '@dd/apps-plugin/backend/ast-parsing/type-guards'; +import type { ExportNamedDeclaration, ImportDeclaration, Program } from 'estree'; +import { parseAst } from 'rollup/parseAst'; /** * Helper to build a minimal ESTree Program for testing. @@ -14,8 +16,14 @@ function program(body: Program['body']): Program { /** * Helper to build a minimal ImportDeclaration node for a given source. + * `overrides` also accepts TypeScript's `importKind`/`exportKind`, which the + * `estree` types don't declare, so type-only imports can be built without a + * cast. */ -function importDecl(source: string, overrides: Partial = {}): ImportDeclaration { +function importDecl( + source: string, + overrides: Partial & TypeScriptImportExportMetadata = {}, +): ImportDeclaration { return { type: 'ImportDeclaration', specifiers: [ @@ -30,6 +38,32 @@ function importDecl(source: string, overrides: Partial = {}): }; } +/** + * Helper to build a minimal `export { readFile } from source` re-export node. + * `rollup/parseAst` can't parse TypeScript's `export type { ... }` syntax, + * so the type-only re-export test below builds this node by hand instead of + * parsing source text. + */ +function exportNamedDecl( + source: string, + overrides: Partial & TypeScriptImportExportMetadata = {}, +): ExportNamedDeclaration { + return { + type: 'ExportNamedDeclaration', + declaration: null, + specifiers: [ + { + type: 'ExportSpecifier', + local: { type: 'Identifier', name: 'readFile' }, + exported: { type: 'Identifier', name: 'readFile' }, + }, + ], + source: { type: 'Literal', value: source }, + attributes: [], + ...overrides, + }; +} + describe('Backend Functions - rejectNodeBuiltinImports', () => { const filePath = '/project/src/math.backend.ts'; @@ -49,7 +83,8 @@ describe('Backend Functions - rejectNodeBuiltinImports', () => { ]; test.each(allowedCases)('Should $description', ({ source }) => { - const ast = program([importDecl(source)]); + const importDeclaration = importDecl(source); + const ast = program([importDeclaration]); expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); }); @@ -81,7 +116,8 @@ describe('Backend Functions - rejectNodeBuiltinImports', () => { ]; test.each(rejectedCases)('Should $description', ({ source }) => { - const ast = program([importDecl(source)]); + const importDeclaration = importDecl(source); + const ast = program([importDeclaration]); expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow( `Importing Node built-in module "${source}" is not supported in .backend.ts files`, ); @@ -90,9 +126,8 @@ describe('Backend Functions - rejectNodeBuiltinImports', () => { test('Should allow a type-only import of a Node built-in', () => { // import type { Stats } from 'fs'; - const ast = program([ - importDecl('fs', { importKind: 'type' } as Partial), - ]); + const importDeclaration = importDecl('fs', { importKind: 'type' }); + const ast = program([importDeclaration]); expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); }); @@ -105,4 +140,42 @@ describe('Backend Functions - rejectNodeBuiltinImports', () => { ]); expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); }); + + const rejectedReExportCases = [ + { + description: 'reject re-exporting "readFile" from "node:fs" under a new name', + code: "export { readFile as handler } from 'node:fs';", + source: 'node:fs', + }, + { + description: 'reject re-exporting the bare built-in "fs" without renaming', + code: "export { readFile } from 'fs';", + source: 'fs', + }, + ]; + + test.each(rejectedReExportCases)('Should $description', ({ code, source }) => { + const ast = parseAst(code); + expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow( + `Importing Node built-in module "${source}" is not supported in .backend.ts files`, + ); + expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow(filePath); + }); + + test('Should allow re-exporting from a relative module', () => { + const ast = parseAst("export { helper } from './helpers';"); + expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); + }); + + test('Should allow a type-only re-export of a Node built-in', () => { + // export type { Stats } from 'fs'; + const exportDeclaration = exportNamedDecl('fs', { exportKind: 'type' }); + const ast = program([exportDeclaration]); + expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); + }); + + test('Should allow a named export with no source', () => { + const ast = parseAst('const value = 1;\nexport { value };'); + expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); + }); }); diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts index e39e5eab3..547a364b3 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts @@ -2,7 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { BaseNode } from 'estree'; +import type { BaseNode, Literal } from 'estree'; import { builtinModules } from 'node:module'; import { ensureProgram, isTypeOnly } from './type-guards'; @@ -21,24 +21,36 @@ function isRestrictedSource(source: string): boolean { * @datadog/action-catalog typed wrapper). * * This is a best-effort, defense-in-depth check on static `import` specifiers - * only — it doesn't catch `require()` or dynamic `import()` of a computed - * specifier. See also `rejectRestrictedGlobals`, which covers bare network - * globals like `fetch` that need no import at all. + * and named re-export sources only — it doesn't catch `require()` or dynamic + * `import()` of a computed specifier. See also `rejectRestrictedGlobals`, + * which covers bare network globals like `fetch` that need no import at all. */ export function rejectNodeBuiltinImports(ast: BaseNode, filePath: string): void { const program = ensureProgram(ast, filePath); for (const node of program.body) { - if (node.type !== 'ImportDeclaration' || isTypeOnly(node)) { + if (node.type === 'ImportDeclaration' && !isTypeOnly(node)) { + rejectIfRestrictedSource(node.source, filePath); continue; } - const source = node.source.value; - if (typeof source === 'string' && isRestrictedSource(source)) { - throw new Error( - `Importing Node built-in module "${source}" is not supported in .backend.ts files. ` + - `Backend functions run in a restricted environment and must use an Action ` + - `Platform action ($.Actions or an @datadog/action-catalog typed wrapper) instead: ${filePath}`, - ); + // `export { readFile as handler } from 'node:fs'` loads the built-in + // module just like a regular import does — it only skips binding it + // to a local identifier, re-exposing it under a new name instead. + if (node.type === 'ExportNamedDeclaration' && node.source && !isTypeOnly(node)) { + rejectIfRestrictedSource(node.source, filePath); } } } + +function rejectIfRestrictedSource(source: Literal, filePath: string): void { + const value = source.value; + if (typeof value !== 'string' || !isRestrictedSource(value)) { + return; + } + + throw new Error( + `Importing Node built-in module "${value}" is not supported in .backend.ts files. ` + + `Backend functions run in a restricted environment and must use an Action ` + + `Platform action ($.Actions or an @datadog/action-catalog typed wrapper) instead: ${filePath}`, + ); +} diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts index d43ec70b7..e000f6964 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts @@ -29,6 +29,26 @@ describe('Backend Functions - rejectRestrictedGlobals', () => { description: 'reject new EventSource(...)', code: 'export function run() { return new EventSource("/events"); }', }, + { + description: 'reject globalThis.fetch(...)', + code: 'export async function run() { return globalThis.fetch("https://example.com"); }', + }, + { + description: 'reject globalThis.fetch referenced without calling it', + code: 'export function run() { const f = globalThis.fetch; return f; }', + }, + { + description: 'reject globalThis["fetch"](...)', + code: 'export async function run() { return globalThis["fetch"]("https://example.com"); }', + }, + { + description: 'reject destructuring fetch off of globalThis', + code: 'export function run() { const { fetch } = globalThis; return fetch("https://example.com"); }', + }, + { + description: 'reject destructuring fetch off of globalThis under a local alias', + code: 'export function run() { const { fetch: doFetch } = globalThis; return doFetch("https://example.com"); }', + }, ]; test.each(rejectedCases)('Should $description', ({ code }) => { @@ -56,6 +76,14 @@ describe('Backend Functions - rejectRestrictedGlobals', () => { description: 'allow unrelated code with no restricted-global references', code: 'export function run(a, b) { return a + b; }', }, + { + description: 'allow accessing an unrestricted globalThis property', + code: 'export function run() { return globalThis.console; }', + }, + { + description: 'allow destructuring an unrestricted property off of globalThis', + code: 'export function run() { const { console } = globalThis; return console; }', + }, ]; test.each(allowedCases)('Should $description', ({ code }) => { diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts index f7a3b8bd6..c4d68f320 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts @@ -2,12 +2,14 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { BaseNode } from 'estree'; +import type { BaseNode, MemberExpression, VariableDeclarator } from 'estree'; import { analyzeModuleScope } from './module-scope'; -import { ensureProgram } from './type-guards'; +import { ensureProgram, isStringLiteral } from './type-guards'; +import { walkAst } from './walk-ast'; const RESTRICTED_GLOBALS = new Set(['fetch', 'XMLHttpRequest', 'WebSocket', 'EventSource']); +const GLOBAL_THIS_NAME = 'globalThis'; /** * Reject references to network-capable globals in `.backend.ts` files. @@ -32,7 +34,9 @@ const RESTRICTED_GLOBALS = new Set(['fetch', 'XMLHttpRequest', 'WebSocket', 'Eve * This is a best-effort, defense-in-depth check: it flags any reference to one * of these names that eslint-scope can't resolve to a declaration in this * module (i.e. it falls through to the ambient global instead of a local - * variable or import that happens to share the name). + * variable or import that happens to share the name), plus the two + * `globalThis`-qualified forms below that never produce an eslint-scope + * reference for the restricted name at all. */ export function rejectRestrictedGlobals(ast: BaseNode, filePath: string): void { const program = ensureProgram(ast, filePath); @@ -43,11 +47,87 @@ export function rejectRestrictedGlobals(ast: BaseNode, filePath: string): void { continue; } - throw new Error( - `Using "${identifier.name}" is not supported in .backend.ts files. ` + - `Backend functions cannot make raw network requests in production — ` + - `use an Action Platform action ($.Actions or an @datadog/action-catalog ` + - `typed wrapper) instead: ${filePath}`, - ); + throwRestrictedGlobalError(identifier.name, filePath); } + + walkAst(program, null, { + MemberExpression(node) { + const name = restrictedGlobalThisMemberName(node); + if (name) { + throwRestrictedGlobalError(name, filePath); + } + }, + VariableDeclarator(node) { + const name = restrictedGlobalThisDestructuredName(node); + if (name) { + throwRestrictedGlobalError(name, filePath); + } + }, + }); +} + +/** + * `globalThis.fetch(...)` and `globalThis['fetch']` reach the same restricted + * global as a bare `fetch` reference, but ESTree represents `fetch` here as a + * member property rather than an `Identifier` reference — eslint-scope never + * creates a reference for it, so the scope-based check above can't see this + * portable form on its own. + */ +function restrictedGlobalThisMemberName(node: MemberExpression): string | undefined { + if (node.object.type !== 'Identifier' || node.object.name !== GLOBAL_THIS_NAME) { + return undefined; + } + + if (!node.computed && node.property.type === 'Identifier') { + return RESTRICTED_GLOBALS.has(node.property.name) ? node.property.name : undefined; + } + + if (node.computed && isStringLiteral(node.property)) { + return RESTRICTED_GLOBALS.has(node.property.value) ? node.property.value : undefined; + } + + return undefined; +} + +/** + * `const { fetch } = globalThis` binds a local variable straight from the + * restricted global's value. A later bare `fetch()` call then resolves to + * that local declaration instead of falling through to the ambient global, + * so the scope-based check above treats it as an ordinary local and lets it + * pass. Reject the destructure itself rather than trying to trace every + * later use of the bound name. + */ +function restrictedGlobalThisDestructuredName(node: VariableDeclarator): string | undefined { + if ( + node.id.type !== 'ObjectPattern' || + node.init?.type !== 'Identifier' || + node.init.name !== GLOBAL_THIS_NAME + ) { + return undefined; + } + + for (const property of node.id.properties) { + if ( + property.type === 'RestElement' || + property.computed || + property.key.type !== 'Identifier' + ) { + continue; + } + + if (RESTRICTED_GLOBALS.has(property.key.name)) { + return property.key.name; + } + } + + return undefined; +} + +function throwRestrictedGlobalError(name: string, filePath: string): never { + throw new Error( + `Using "${name}" is not supported in .backend.ts files. ` + + `Backend functions cannot make raw network requests in production — ` + + `use an Action Platform action ($.Actions or an @datadog/action-catalog ` + + `typed wrapper) instead: ${filePath}`, + ); } diff --git a/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts b/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts index 369a9aca1..51b99cf55 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts @@ -6,7 +6,7 @@ import type { BaseNode, Program, SimpleLiteral } from 'estree'; export type StringLiteral = SimpleLiteral & { value: string }; -interface TypeScriptImportExportMetadata { +export interface TypeScriptImportExportMetadata { importKind?: 'type' | 'value'; exportKind?: 'type' | 'value'; } From b32490a571605a3538c14920247ddd370bf2c31a Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 21 Aug 2026 14:18:33 -0400 Subject: [PATCH 4/4] fix(apps): run static import/global checks against nested backend-module imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reject-node-builtin-imports and reject-restricted-globals only ran against the entry .backend.ts file matched by the outer Vite transform filter. A backend function importing a local app helper module is a supported flow, but the helper's own source was never scanned by either check — only the nested backend build (which bundles a single function's own module graph) actually walks into it, so a helper doing `import fs from 'fs'` or calling a bare `fetch()` shipped undetected even though the entry file itself was clean. Adds a Vite plugin that re-runs both checks against every app-local module the nested backend build resolves, via the same moduleParsed hook and module-id normalization/exclusion the connection-ID collector already uses for the same module set. Wired into both the production build-backend-functions path and the dev bundleBackendFunction path, matching how the connection-ID collector itself is wired into both. --- packages/plugins/apps/src/index.test.ts | 129 ++++++++++++++++++ .../vite/backend-static-checks-plugin.test.ts | 100 ++++++++++++++ .../src/vite/backend-static-checks-plugin.ts | 62 +++++++++ .../apps/src/vite/build-backend-functions.ts | 2 + packages/plugins/apps/src/vite/dev-server.ts | 2 + 5 files changed, 295 insertions(+) create mode 100644 packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts create mode 100644 packages/plugins/apps/src/vite/backend-static-checks-plugin.ts diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index e515f20f6..867d2af5f 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -742,6 +742,135 @@ describe('Apps Plugin - getPlugins', () => { ).toEqual([{ allowedConnectionIds: ['conn-helper'] }]); }); + test('Should reject a Node builtin import inside a helper module reachable from a backend function', 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); + + // The entry file alone is clean: the outer transform's static checks + // pass, and only importing a local helper is visible from here. + const entryCode = ` + import { readSecret } from './helpers/fs-helper.js'; + + export function greet() { + return readSecret(); + } + `; + // The helper never reaches the outer transform's filter, so this + // Node builtin import is only caught by the nested backend build's + // own module graph walk. + const helperCode = ` + import fs from 'fs'; + + export function readSecret() { + return fs.readFileSync('/etc/passwd', 'utf8'); + } + `; + const helperId = '/project/src/backend/helpers/fs-helper.js'; + const viteBuild = jest.fn().mockImplementation(async (config) => { + emitModuleParsed(config, '/project/src/backend/greet.backend.js', entryCode, [ + helperId, + ]); + emitModuleParsed(config, helperId, helperCode); + return { + output: [ + { + type: 'chunk', + isEntry: true, + name: expect.any(String), + fileName: 'unused.greet.js', + }, + ], + }; + }); + const args = getArgs(); + args.bundler = { build: viteBuild }; + const plugins = getPlugins(args); + const transform = extractViteTransform(plugins); + await transform.call( + { + parse: parseAst, + resolve: jest.fn(async (specifier: string) => + specifier === './helpers/fs-helper.js' ? { id: helperId } : null, + ), + load: jest.fn(async () => null), + addWatchFile: jest.fn(), + }, + entryCode, + '/project/src/backend/greet.backend.js', + ); + + await expect(extractCloseBundle(plugins)()).rejects.toThrow( + 'Importing Node built-in module "fs" is not supported in .backend.ts files', + ); + }); + + test('Should reject a bare fetch() call inside a helper module reachable from a backend function', 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); + + const entryCode = ` + import { getEcho } from './helpers/http-helper.js'; + + export function greet() { + return getEcho(); + } + `; + const helperCode = ` + export function getEcho() { + return fetch('https://example.com'); + } + `; + const helperId = '/project/src/backend/helpers/http-helper.js'; + const viteBuild = jest.fn().mockImplementation(async (config) => { + emitModuleParsed(config, '/project/src/backend/greet.backend.js', entryCode, [ + helperId, + ]); + emitModuleParsed(config, helperId, helperCode); + return { + output: [ + { + type: 'chunk', + isEntry: true, + name: expect.any(String), + fileName: 'unused.greet.js', + }, + ], + }; + }); + const args = getArgs(); + args.bundler = { build: viteBuild }; + const plugins = getPlugins(args); + const transform = extractViteTransform(plugins); + await transform.call( + { + parse: parseAst, + resolve: jest.fn(async (specifier: string) => + specifier === './helpers/http-helper.js' ? { id: helperId } : null, + ), + load: jest.fn(async () => null), + addWatchFile: jest.fn(), + }, + entryCode, + '/project/src/backend/greet.backend.js', + ); + + await expect(extractCloseBundle(plugins)()).rejects.toThrow( + 'Using "fetch" is not supported in .backend.ts files', + ); + }); + test('Should surface upload errors', async () => { jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({ identifier: 'repo:app', diff --git a/packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts b/packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts new file mode 100644 index 000000000..ba2fb81ce --- /dev/null +++ b/packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts @@ -0,0 +1,100 @@ +// 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 { parseAst } from 'rollup/parseAst'; + +import { createBackendStaticChecksPlugin } from './backend-static-checks-plugin'; + +type FakeModuleInfo = { id: string; code: string | null }; + +/** + * Calls the `moduleParsed` hook with a plugin context exposing `parse`, which + * is where it takes its parser from. `rollup/parseAst` is what Rollup's real + * context supplies. + */ +function callModuleParsed( + plugin: ReturnType, + moduleInfo: FakeModuleInfo, +): void { + const hook = plugin.moduleParsed; + if (typeof hook !== 'function') { + throw new Error('Expected "moduleParsed" to be a function hook.'); + } + + Reflect.apply(hook, { parse: parseAst }, [moduleInfo]); +} + +describe('Backend Functions - backend static checks plugin', () => { + test('Should reject a module importing a Node built-in', () => { + const plugin = createBackendStaticChecksPlugin('/project'); + + expect(() => + callModuleParsed(plugin, { + id: '/project/src/backend/helpers/http.js', + code: "import fs from 'fs';\nexport function readIt() { return fs.readFileSync('/etc/passwd'); }", + }), + ).toThrow('Importing Node built-in module "fs" is not supported in .backend.ts files'); + }); + + test('Should reject a module calling a restricted global', () => { + const plugin = createBackendStaticChecksPlugin('/project'); + + expect(() => + callModuleParsed(plugin, { + id: '/project/src/backend/helpers/http.js', + code: 'export function callIt() { return fetch("https://example.com"); }', + }), + ).toThrow('Using "fetch" is not supported in .backend.ts files'); + }); + + test('Should allow a module with no restricted imports or globals', () => { + const plugin = createBackendStaticChecksPlugin('/project'); + + expect(() => + callModuleParsed(plugin, { + id: '/project/src/backend/helpers/http.js', + code: 'export function add(a, b) { return a + b; }', + }), + ).not.toThrow(); + }); + + test('Should skip modules under node_modules', () => { + const plugin = createBackendStaticChecksPlugin('/project'); + + expect(() => + callModuleParsed(plugin, { + id: '/project/node_modules/some-package/index.js', + code: "import fs from 'fs';\nexport const value = fs;", + }), + ).not.toThrow(); + }); + + test('Should skip virtual modules', () => { + const plugin = createBackendStaticChecksPlugin('/project'); + + expect(() => + callModuleParsed(plugin, { + id: '\0dd-backend:hash.greet', + code: "import fs from 'fs';\nexport const value = fs;", + }), + ).not.toThrow(); + expect(() => + callModuleParsed(plugin, { + id: 'virtual:dd-backend-dev:greet.js', + code: "import fs from 'fs';\nexport const value = fs;", + }), + ).not.toThrow(); + }); + + test('Should skip modules with no source (external/synthetic modules)', () => { + const plugin = createBackendStaticChecksPlugin('/project'); + + expect(() => + callModuleParsed(plugin, { + id: '/project/src/backend/external.js', + code: null, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/plugins/apps/src/vite/backend-static-checks-plugin.ts b/packages/plugins/apps/src/vite/backend-static-checks-plugin.ts new file mode 100644 index 000000000..52086e31b --- /dev/null +++ b/packages/plugins/apps/src/vite/backend-static-checks-plugin.ts @@ -0,0 +1,62 @@ +// 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 { ModuleInfo } from 'rollup'; +import type { Plugin } from 'vite'; + +import { shouldTraverseCollectedModule } from '../backend/ast-parsing/module-graph'; +import { rejectNodeBuiltinImports } from '../backend/ast-parsing/reject-node-builtin-imports'; +import { rejectRestrictedGlobals } from '../backend/ast-parsing/reject-restricted-globals'; + +const VIRTUAL_MODULE_ID_RE = /^(?:\0|virtual:)/; + +/** + * Re-runs the Node-builtin-import and restricted-globals static checks + * against every app-local module the nested backend build resolves, not just + * the `.backend.ts` entry the outer Vite `transform` hook already checked. + * + * A `.backend.ts` file is free to import a local helper module, and that + * helper's own source is never seen by the outer transform — only the + * nested backend build (this one) actually walks into it. Without this + * plugin, a helper could `import fs from 'fs'` or call a bare `fetch()` and + * ship undetected, even though the entry file itself is clean. + * + * Mirrors `createBackendModuleGraphCollector`'s `moduleParsed` hook: reuse + * its module-id normalization and its `shouldTraverseCollectedModule` + * exclusion (skips `node_modules`/package-manager dirs and anything outside + * `buildRoot`) so this checks exactly the same app-local module set that + * feeds connection-ID collection, and re-parses rather than reading + * `moduleInfo.ast` for the same Rolldown-compatibility reason documented + * there. + */ +export function createBackendStaticChecksPlugin(buildRoot: string): Plugin { + return { + name: 'dd-backend-static-checks', + moduleParsed(moduleInfo: ModuleInfo) { + const moduleId = normalizeViteModuleId(moduleInfo.id); + if ( + isViteVirtualModuleId(moduleId) || + !shouldTraverseCollectedModule(moduleId, buildRoot) + ) { + return; + } + + if (typeof moduleInfo.code !== 'string') { + return; + } + + const ast = this.parse(moduleInfo.code); + rejectNodeBuiltinImports(ast, moduleId); + rejectRestrictedGlobals(ast, moduleId); + }, + }; +} + +function normalizeViteModuleId(id: string): string { + return id.split('?')[0]; +} + +function isViteVirtualModuleId(id: string): boolean { + return VIRTUAL_MODULE_ID_RE.test(id); +} diff --git a/packages/plugins/apps/src/vite/build-backend-functions.ts b/packages/plugins/apps/src/vite/build-backend-functions.ts index c11056f2a..f005a2f05 100644 --- a/packages/plugins/apps/src/vite/build-backend-functions.ts +++ b/packages/plugins/apps/src/vite/build-backend-functions.ts @@ -13,6 +13,7 @@ import type { BackendFunction } from '../backend/types'; import { generateVirtualEntryContent } from '../backend/virtual-entry'; import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; +import { createBackendStaticChecksPlugin } from './backend-static-checks-plugin'; import { getBaseBackendBuildConfig } from './build-config'; const VIRTUAL_PREFIX = '\0dd-backend:'; @@ -49,6 +50,7 @@ export async function buildBackendFunctions( const baseConfig = getBaseBackendBuildConfig(buildRoot, { [virtualId]: virtualContent }, [ connectionIdCollector.plugin, + createBackendStaticChecksPlugin(buildRoot), ]); // eslint-disable-next-line no-await-in-loop diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 3d0c78d58..2a37aba2d 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -17,6 +17,7 @@ import type { BackendFunction } from '../backend/types'; import { generateDevVirtualEntryContent } from '../backend/virtual-entry'; import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; +import { createBackendStaticChecksPlugin } from './backend-static-checks-plugin'; import { getBaseBackendBuildConfig } from './build-config'; interface BundleResult { @@ -88,6 +89,7 @@ async function bundleBackendFunction( const baseConfig = getBaseBackendBuildConfig(projectRoot, { [virtualId]: virtualContent }, [ connectionIdCollector.plugin, + createBackendStaticChecksPlugin(projectRoot), ]); // Dev: build a single function in-memory per request so we can send the