Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@
"typescript": "^5.0.0",
"uuid": "^11.0.0",
"yaml": "^2.8.0",
"yargs": "^17.6.0"
"yargs": "^17.6.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@netlify/nock-udp": "^5.0.1",
Expand Down
8 changes: 4 additions & 4 deletions packages/build/src/plugins_core/edge_functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Metric } from '../../core/report_metrics.js'
import { log, reduceLogLines } from '../../log/logger.js'
import { logFunctionsToBundle } from '../../log/messages/core_steps.js'
import {
FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT,
FRAMEWORKS_API_EDGE_FUNCTIONS_PATH,
FRAMEWORKS_API_EDGE_FUNCTIONS_IMPORT_MAP,
} from '../../utils/frameworks_api.js'

Expand Down Expand Up @@ -52,7 +52,7 @@ const coreStep = async function ({
const internalSrcPath = resolve(buildDir, internalSrcDirectory)
const distImportMapPath = join(dirname(internalSrcPath), IMPORT_MAP_FILENAME)
const srcPath = srcDirectory ? resolve(buildDir, srcDirectory) : undefined
const frameworksAPISrcPath = resolve(buildDir, packagePath || '', FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT)
const frameworksAPISrcPath = resolve(buildDir, packagePath || '', FRAMEWORKS_API_EDGE_FUNCTIONS_PATH)
const generatedFunctionPaths = [internalSrcPath]

if (await pathExists(frameworksAPISrcPath)) {
Expand All @@ -62,7 +62,7 @@ const coreStep = async function ({
const frameworkImportMap = resolve(
buildDir,
packagePath || '',
FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT,
FRAMEWORKS_API_EDGE_FUNCTIONS_PATH,
FRAMEWORKS_API_EDGE_FUNCTIONS_IMPORT_MAP,
)

Expand Down Expand Up @@ -170,7 +170,7 @@ const hasEdgeFunctionsDirectories = async function ({
return true
}

const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT)
const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_EDGE_FUNCTIONS_PATH)

return await pathExists(frameworkFunctionsSrc)
}
Expand Down
30 changes: 30 additions & 0 deletions packages/build/src/plugins_core/frameworks_api/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { promises as fs } from 'node:fs'
import { dirname, resolve } from 'node:path'

import { mergeConfigs } from '@netlify/config'

import type { NetlifyConfig } from '../../index.js'
import { getConfigMutations } from '../../plugins/child/diff.js'
import { DEPLOY_CONFIG_DIST_PATH, FRAMEWORKS_API_SKEW_PROTECTION_PATH } from '../../utils/frameworks_api.js'
import { CoreStep, CoreStepFunction } from '../types.js'

import { loadSkewProtectionConfig } from './skew_protection.js'
import { filterConfig, loadConfigFile } from './util.js'

// The properties that can be set using this API. Each element represents a
Expand All @@ -26,6 +31,30 @@ const ALLOWED_PROPERTIES = [
// a special notation where `redirects!` represents "forced redirects", etc.
const OVERRIDE_PROPERTIES = new Set(['redirects!'])

// Looks for a skew protection configuration file. If found, the file is loaded
// and validated against the schema, throwing a build error if validation
// fails. If valid, the contents are written to the deploy config file.
const handleSkewProtection = async (buildDir: string, packagePath?: string) => {
const inputPath = resolve(buildDir, packagePath ?? '', FRAMEWORKS_API_SKEW_PROTECTION_PATH)
const outputPath = resolve(buildDir, packagePath ?? '', DEPLOY_CONFIG_DIST_PATH)

const skewProtectionConfig = await loadSkewProtectionConfig(inputPath)
if (!skewProtectionConfig) {
return
}

const deployConfig = {
skew_protection: skewProtectionConfig,
}

try {
await fs.mkdir(dirname(outputPath), { recursive: true })
await fs.writeFile(outputPath, JSON.stringify(deployConfig))
} catch (error) {
throw new Error('Failed to process skew protection configuration', { cause: error })
}
}

const coreStep: CoreStepFunction = async function ({
buildDir,
netlifyConfig,
Expand All @@ -34,6 +63,7 @@ const coreStep: CoreStepFunction = async function ({
// no-op
},
}) {
await handleSkewProtection(buildDir, packagePath)
let config: Partial<NetlifyConfig> | undefined

try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { promises as fs } from 'node:fs'

import { z } from 'zod'

const deployIDSourceTypeSchema = z.enum(['cookie', 'header', 'query'])

const deployIDSourceSchema = z.object({
type: deployIDSourceTypeSchema,
name: z.string(),
})

const skewProtectionConfigSchema = z.object({
patterns: z.array(z.string()),
sources: z.array(deployIDSourceSchema),
})

export type SkewProtectionConfig = z.infer<typeof skewProtectionConfigSchema>
export type DeployIDSource = z.infer<typeof deployIDSourceSchema>
export type DeployIDSourceType = z.infer<typeof deployIDSourceTypeSchema>

const validateSkewProtectionConfig = (input: unknown): SkewProtectionConfig => {
const { data, error, success } = skewProtectionConfigSchema.safeParse(input)

if (success) {
return data
}

throw new Error(`Invalid skew protection configuration:\n\n${formatSchemaError(error)}`)
}

export const loadSkewProtectionConfig = async (configPath: string) => {
let parsedData: unknown

try {
const data = await fs.readFile(configPath, 'utf8')

parsedData = JSON.parse(data)
} catch (error) {
// If the file doesn't exist, this is a non-error.
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return
}

throw new Error('Invalid skew protection configuration', { cause: error })
}

return validateSkewProtectionConfig(parsedData)
}

const formatSchemaError = (error: z.ZodError) => {
const lines = error.issues.map((issue) => `- ${issue.path.join('.')}: ${issue.message}`)

return lines.join('\n')
}
4 changes: 2 additions & 2 deletions packages/build/src/plugins_core/frameworks_api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { resolve } from 'path'
import isPlainObject from 'is-plain-obj'

import type { NetlifyConfig } from '../../index.js'
import { FRAMEWORKS_API_CONFIG_ENDPOINT } from '../../utils/frameworks_api.js'
import { FRAMEWORKS_API_CONFIG_PATH } from '../../utils/frameworks_api.js'
import { SystemLogger } from '../types.js'

export const loadConfigFile = async (buildDir: string, packagePath?: string) => {
const configPath = resolve(buildDir, packagePath ?? '', FRAMEWORKS_API_CONFIG_ENDPOINT)
const configPath = resolve(buildDir, packagePath ?? '', FRAMEWORKS_API_CONFIG_PATH)

try {
const data = await fs.readFile(configPath, 'utf8')
Expand Down
6 changes: 3 additions & 3 deletions packages/build/src/plugins_core/functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { addErrorInfo } from '../../error/info.js'
import { log } from '../../log/logger.js'
import { type GeneratedFunction, getGeneratedFunctions } from '../../steps/return_values.js'
import { logBundleResults, logFunctionsNonExistingDir, logFunctionsToBundle } from '../../log/messages/core_steps.js'
import { FRAMEWORKS_API_FUNCTIONS_ENDPOINT } from '../../utils/frameworks_api.js'
import { FRAMEWORKS_API_FUNCTIONS_PATH } from '../../utils/frameworks_api.js'

import { getZipError } from './error.js'
import { getUserAndInternalFunctions, validateFunctionsSrc } from './utils.js'
Expand Down Expand Up @@ -147,7 +147,7 @@ const coreStep = async function ({
const functionsDist = resolve(buildDir, relativeFunctionsDist)
const internalFunctionsSrc = resolve(buildDir, relativeInternalFunctionsSrc)
const internalFunctionsSrcExists = await pathExists(internalFunctionsSrc)
const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_FUNCTIONS_ENDPOINT)
const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_FUNCTIONS_PATH)
const frameworkFunctionsSrcExists = await pathExists(frameworkFunctionsSrc)
const functionsSrcExists = await validateFunctionsSrc({ functionsSrc, relativeFunctionsSrc })
const [userFunctions = [], internalFunctions = [], frameworkFunctions = []] = await getUserAndInternalFunctions({
Expand Down Expand Up @@ -240,7 +240,7 @@ const hasFunctionsDirectories = async function ({
return true
}

const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_FUNCTIONS_ENDPOINT)
const frameworkFunctionsSrc = resolve(buildDir, packagePath || '', FRAMEWORKS_API_FUNCTIONS_PATH)

if (await pathExists(frameworkFunctionsSrc)) {
return true
Expand Down
4 changes: 2 additions & 2 deletions packages/build/src/plugins_core/pre_cleanup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { rm } from 'node:fs/promises'
import { resolve } from 'node:path'

import { getBlobsDirs } from '../../utils/blobs.js'
import { FRAMEWORKS_API_ENDPOINT } from '../../utils/frameworks_api.js'
import { FRAMEWORKS_API_PATH } from '../../utils/frameworks_api.js'
import { CoreStep, CoreStepFunction } from '../types.js'

const coreStep: CoreStepFunction = async ({ buildDir, packagePath }) => {
const dirs = [...getBlobsDirs(buildDir, packagePath), resolve(buildDir, packagePath || '', FRAMEWORKS_API_ENDPOINT)]
const dirs = [...getBlobsDirs(buildDir, packagePath), resolve(buildDir, packagePath || '', FRAMEWORKS_API_PATH)]

try {
await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true })))
Expand Down
4 changes: 2 additions & 2 deletions packages/build/src/utils/blobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { fdir } from 'fdir'

import { DEFAULT_API_HOST } from '../core/normalize_flags.js'

import { FRAMEWORKS_API_BLOBS_ENDPOINT } from './frameworks_api.js'
import { FRAMEWORKS_API_BLOBS_PATH } from './frameworks_api.js'

const LEGACY_BLOBS_PATH = '.netlify/blobs/deploy'
const DEPLOY_CONFIG_BLOBS_PATH = '.netlify/deploy/v1/blobs/deploy'
Expand Down Expand Up @@ -60,7 +60,7 @@ export const getBlobsEnvironmentContext = ({
*/
export const scanForBlobs = async function (buildDir: string, packagePath?: string) {
// We start by looking for files using the Frameworks API.
const frameworkBlobsDir = path.resolve(buildDir, packagePath || '', FRAMEWORKS_API_BLOBS_ENDPOINT, 'deploy')
const frameworkBlobsDir = path.resolve(buildDir, packagePath || '', FRAMEWORKS_API_BLOBS_PATH, 'deploy')
const frameworkBlobsDirScan = await new fdir().onlyCounts().crawl(frameworkBlobsDir).withPromise()

if (frameworkBlobsDirScan.files > 0) {
Expand Down
13 changes: 8 additions & 5 deletions packages/build/src/utils/frameworks_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { basename, dirname, resolve, sep } from 'node:path'

import { fdir } from 'fdir'

export const FRAMEWORKS_API_ENDPOINT = '.netlify/v1'
export const FRAMEWORKS_API_BLOBS_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/blobs`
export const FRAMEWORKS_API_CONFIG_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/config.json`
export const FRAMEWORKS_API_EDGE_FUNCTIONS_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/edge-functions`
export const FRAMEWORKS_API_PATH = '.netlify/v1'
export const FRAMEWORKS_API_BLOBS_PATH = `${FRAMEWORKS_API_PATH}/blobs`
export const FRAMEWORKS_API_CONFIG_PATH = `${FRAMEWORKS_API_PATH}/config.json`
export const FRAMEWORKS_API_EDGE_FUNCTIONS_PATH = `${FRAMEWORKS_API_PATH}/edge-functions`
export const FRAMEWORKS_API_EDGE_FUNCTIONS_IMPORT_MAP = 'import_map.json'
export const FRAMEWORKS_API_FUNCTIONS_ENDPOINT = `${FRAMEWORKS_API_ENDPOINT}/functions`
export const FRAMEWORKS_API_FUNCTIONS_PATH = `${FRAMEWORKS_API_PATH}/functions`
export const FRAMEWORKS_API_SKEW_PROTECTION_PATH = `${FRAMEWORKS_API_PATH}/skew-protection.json`

export const DEPLOY_CONFIG_DIST_PATH = '.netlify/deploy-config/deploy-config.json'

type DirectoryTreeFiles = Map<string, string[]>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { mkdir, writeFile } from 'node:fs/promises'

await mkdir('.netlify/v1', { recursive: true })

await writeFile('.netlify/v1/skew-protection.json', JSON.stringify({
patterns: ["api"],
sources: [{ type: "invalid_type", name: "test" }]
}))
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
command = "node build.mjs"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { mkdir, writeFile } from 'node:fs/promises'

await mkdir('.netlify/v1', { recursive: true })

await writeFile('.netlify/v1/skew-protection.json', `{"patterns":`)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
command = "node build.mjs"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// No skew protection file created
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
command = "node build.mjs"
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { mkdir, writeFile } from 'node:fs/promises'

await mkdir('.netlify/v1', { recursive: true })

await writeFile('.netlify/v1/skew-protection.json', JSON.stringify({
patterns: ["/api/*", "/dashboard/*"],
sources: [
{ type: "cookie", name: "nf_deploy_id" },
{ type: "header", name: "x-nf-deploy-id" },
{ type: "query", name: "deploy_id" }
]
}))
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
command = "node build.mjs"
53 changes: 53 additions & 0 deletions packages/build/tests/frameworks_api/tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,56 @@ test('Removes any leftover files from a previous build', async (t) => {
remote_images: ['domain1.from-toml.netlify', 'domain2.from-toml.netlify'],
})
})

test('Throws an error if the skew protection configuration file is invalid', async (t) => {
const { output, success } = await new Fixture('./fixtures/skew_protection_invalid').runWithBuildAndIntrospect()
t.false(success)
t.true(output.includes('Invalid skew protection configuration'))
t.true(
output.includes(
`sources.0.type: Invalid enum value. Expected 'cookie' | 'header' | 'query', received 'invalid_type'`,
),
)
})

test('Throws an error if the skew protection configuration file is malformed', async (t) => {
const { output, success } = await new Fixture('./fixtures/skew_protection_malformed').runWithBuildAndIntrospect()
t.false(success)
t.true(output.includes('Invalid skew protection configuration'))
})

test('Does not create dist file when skew protection file is missing', async (t) => {
const fixture = new Fixture('./fixtures/skew_protection_missing')
const { success } = await fixture.runWithBuildAndIntrospect()
const distPath = resolve(fixture.repositoryRoot, '.netlify/deploy-config/deploy-config.json')
t.true(success)

try {
await fs.access(distPath)
t.fail('Dist file should not exist when skew protection file is missing')
} catch (error) {
t.is(error.code, 'ENOENT')
}
})

test('Creates dist file when valid skew protection configuration is provided', async (t) => {
const fixture = new Fixture('./fixtures/skew_protection_valid')
const { success } = await fixture.runWithBuildAndIntrospect()
const distPath = resolve(fixture.repositoryRoot, '.netlify/deploy-config/deploy-config.json')

t.true(success)

const distContent = await fs.readFile(distPath, 'utf8')
const config = JSON.parse(distContent)

t.deepEqual(config, {
skew_protection: {
patterns: ['/api/*', '/dashboard/*'],
sources: [
{ type: 'cookie', name: 'nf_deploy_id' },
{ type: 'header', name: 'x-nf-deploy-id' },
{ type: 'query', name: 'deploy_id' },
],
},
})
})
Loading