diff --git a/.changeset/secure-sentry-source-maps.md b/.changeset/secure-sentry-source-maps.md new file mode 100644 index 00000000000..e3c79b7578c --- /dev/null +++ b/.changeset/secure-sentry-source-maps.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Remove uploaded Sentry source maps from production build artifacts and fail builds when their upload fails. diff --git a/packages/core/package.json b/packages/core/package.json index b7bbb63137e..94c6e9d36d4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -410,6 +410,7 @@ "@rrweb/record": "2.1.0", "@sentry/browser": "10.60.0", "@sentry/node": "10.60.0", + "@sentry/vite-plugin": "5.4.0", "@shadcn/react": "^0.2.0", "@standard-schema/spec": "^1.1.0", "@tanstack/react-table": "^8.21.3", diff --git a/packages/core/src/client/analytics.spec.ts b/packages/core/src/client/analytics.spec.ts index b8631b018d6..344d0ede19f 100644 --- a/packages/core/src/client/analytics.spec.ts +++ b/packages/core/src/client/analytics.spec.ts @@ -912,6 +912,7 @@ describe("browser analytics pageviews", () => { expect.objectContaining({ dsn: "https://public@example/4511270423822336", environment: "beta", + release: "agent-native-client@development", }), ); expect(sentryMock.setTag).toHaveBeenCalledWith("runtime", "browser"); diff --git a/packages/core/src/client/analytics.ts b/packages/core/src/client/analytics.ts index a6403062a67..c063b9d789e 100644 --- a/packages/core/src/client/analytics.ts +++ b/packages/core/src/client/analytics.ts @@ -25,6 +25,7 @@ import { getOrCreateAnalyticsSessionId, } from "./analytics-session.js"; import { injectedAgentNativeConfig } from "./app-config.js"; +import { clientBuildId } from "./build-compatibility.js"; export { clearAnalyticsSessionId, setAnalyticsSessionId, @@ -1017,6 +1018,15 @@ function resolveClientDeploymentEnvironment(): string { ); } +/** + * Must match `resolveSentryClientRelease()` in `vite/sentry-source-maps.ts` + * exactly — that's the release name uploaded source maps are attached to, so + * a mismatch here means captured events never resolve against them. + */ +function resolveClientRelease(): string { + return `agent-native-client@${clientBuildId() || "development"}`; +} + function captureWithSentry( module: typeof Sentry, error: unknown, @@ -1060,6 +1070,7 @@ function ensureSentry(loadWithoutDsn = false): void { module.init({ dsn, environment: resolveClientDeploymentEnvironment(), + release: resolveClientRelease(), beforeSend(event) { if (isSyntheticBrowserTraffic()) return null; event.tags = { diff --git a/packages/core/src/vite/client.spec.ts b/packages/core/src/vite/client.spec.ts index fb46eaf0928..0302de1587c 100644 --- a/packages/core/src/vite/client.spec.ts +++ b/packages/core/src/vite/client.spec.ts @@ -1695,6 +1695,49 @@ describe("agentNative Vite plugin preset", () => { }); }); + it("leaves build.sourcemap off and adds no Sentry plugin without upload config", async () => { + const plugins = flatPlugins(agentNative()); + const configPlugin = plugins.find((p) => p?.name === "agent-native-config"); + + const config = (await configPlugin.config( + {}, + { command: "build", mode: "production" }, + )) as any; + + expect(config.build.sourcemap).toBe(false); + expect(plugins.map((p) => p?.name)).not.toContain("sentry-vite-plugin"); + }); + + it("emits hidden sourcemaps and adds the Sentry upload plugin when configured", async () => { + const previous = { + SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN, + SENTRY_ORG: process.env.SENTRY_ORG, + SENTRY_PROJECT: process.env.SENTRY_PROJECT, + }; + try { + process.env.SENTRY_AUTH_TOKEN = "test-token"; + process.env.SENTRY_ORG = "acme"; + process.env.SENTRY_PROJECT = "web"; + + const plugins = flatPlugins(agentNative()); + const configPlugin = plugins.find( + (p) => p?.name === "agent-native-config", + ); + const config = (await configPlugin.config( + {}, + { command: "build", mode: "production" }, + )) as any; + + expect(config.build.sourcemap).toBe("hidden"); + expect(plugins.map((p) => p?.name)).toContain("sentry-vite-plugin"); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + it("stops the dep optimizer from writing prebundle sourcemaps", async () => { const plugins = flatPlugins(agentNative()); const configPlugin = plugins.find((p) => p?.name === "agent-native-config"); diff --git a/packages/core/src/vite/client.ts b/packages/core/src/vite/client.ts index 8c49df85081..2c51c80b736 100644 --- a/packages/core/src/vite/client.ts +++ b/packages/core/src/vite/client.ts @@ -74,6 +74,10 @@ import { } from "./agent-native-config-loader.js"; import { agentsBundlePlugin } from "./agents-bundle-plugin.js"; import { resolveAgentNativePackageVersions } from "./package-versions.js"; +import { + createSentrySourceMapUploadPlugin, + isSentrySourceMapUploadEnabled, +} from "./sentry-source-maps.js"; const require = createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -3591,6 +3595,24 @@ function authClientAssetPlugin(): Plugin { }; } +// `.env`/`.env.production` values aren't in `process.env` unless the shell +// exported them — Vite loads them separately via `loadEnv`. Env-gated +// checks that only read `process.env` silently miss file-only config, so +// this is the one merge both the plugin list and the build config use. +function resolveAgentNativeRuntimeEnv( + cwd: string, + mode: string, +): Record { + const workspaceRoot = findWorkspaceRoot(cwd); + return { + ...(workspaceRoot && workspaceRoot !== cwd + ? loadEnv(mode, workspaceRoot, "") + : {}), + ...loadEnv(mode, cwd, ""), + ...process.env, + }; +} + function createAgentNativePlugins( options: ClientConfigOptions | AgentNativeVitePluginOptions, { @@ -3609,6 +3631,12 @@ function createAgentNativePlugins( const nitroPlugin = createNitroDevPlugin(options, appBasePath); const includeNitro = !isBuildCommand(command); const presetMarkerPlugin = nitroPresetMarkerPlugin(options); + // Vite's real `mode` isn't resolved yet at this eager, pre-config-hook + // point — same fallback createAgentNativeConfig uses as its own default. + const runtimeEnv = resolveAgentNativeRuntimeEnv( + process.cwd(), + process.env.NODE_ENV === "production" ? "production" : "development", + ); return [ presetMarkerPlugin, @@ -3645,6 +3673,8 @@ function createAgentNativePlugins( includeReactTransform ? createReactTransformPlugin() : null, createDesignSystemThemePlugin(options.designSystemTheme), createTailwindPlugin(options), + // No-ops unless a Sentry auth token/org/project is configured. + ...createSentrySourceMapUploadPlugin(runtimeEnv), ].filter(Boolean); } @@ -3728,13 +3758,7 @@ function createAgentNativeConfig( const workspaceRoot = findWorkspaceRoot(cwd); const envDir = workspaceRoot && workspaceRoot !== cwd ? workspaceRoot : cwd; - const runtimeEnv = { - ...(workspaceRoot && workspaceRoot !== cwd - ? loadEnv(mode, workspaceRoot, "") - : {}), - ...loadEnv(mode, cwd, ""), - ...process.env, - }; + const runtimeEnv = resolveAgentNativeRuntimeEnv(cwd, mode); const appConfig = resolveAgentNativeConfig( mergeAgentNativeConfigs( mergeAgentNativeConfigs( @@ -3982,6 +4006,11 @@ function createAgentNativeConfig( // the standard property survives the production pipeline. cssMinify: userConfig.build?.cssMinify ?? "esbuild", cssTarget: userConfig.build?.cssTarget ?? ["es2020", "safari18"], + // "hidden" writes .map files for upload without a public + // sourceMappingURL comment, so production never serves them directly. + sourcemap: + userConfig.build?.sourcemap ?? + (isSentrySourceMapUploadEnabled(runtimeEnv) ? "hidden" : false), }, // Bundle all non-Node.js deps into the production SSR server build. // Edge runtimes (CF Workers, Deno) don't have node_modules at runtime. diff --git a/packages/core/src/vite/sentry-source-maps.spec.ts b/packages/core/src/vite/sentry-source-maps.spec.ts new file mode 100644 index 00000000000..6cd9c4096af --- /dev/null +++ b/packages/core/src/vite/sentry-source-maps.spec.ts @@ -0,0 +1,203 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { build, type Plugin } from "vite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const sentryUpload = vi.hoisted(() => vi.fn<() => Promise>()); +const sentryVitePluginMock = vi.hoisted(() => + vi.fn((options: { errorHandler: (error: Error) => void }) => [ + { + name: "sentry-vite-plugin-mock", + enforce: "pre", + async writeBundle() { + try { + await sentryUpload(); + } catch (error) { + options.errorHandler(error as Error); + } + }, + }, + ]), +); + +const temporaryDirectories: string[] = []; + +function temporaryBuild(): { + entryPath: string; + mapPath: string; + publishDirectory: string; +} { + const directory = mkdtempSync( + path.join(tmpdir(), "agent-native-sourcemaps-"), + ); + const entryPath = path.join(directory, "entry.js"); + const publishDirectory = path.join(directory, "publish"); + temporaryDirectories.push(directory); + mkdirSync(publishDirectory); + writeFileSync(entryPath, "console.log('built');"); + return { + entryPath, + mapPath: path.join(publishDirectory, "client.js.map"), + publishDirectory, + }; +} + +async function runViteBuild( + entryPath: string, + publishDirectory: string, + plugins: Plugin[], +): Promise { + await build({ + configFile: false, + logLevel: "silent", + plugins, + build: { + emptyOutDir: true, + lib: { + entry: entryPath, + fileName: () => "client.js", + formats: ["es"], + }, + outDir: publishDirectory, + sourcemap: true, + }, + }); +} + +vi.mock("@sentry/vite-plugin", () => ({ + sentryVitePlugin: sentryVitePluginMock, +})); + +import { + createSentrySourceMapUploadPlugin, + resolveSentrySourceMapUploadConfig, +} from "./sentry-source-maps.js"; + +describe("vite/sentry-source-maps", () => { + beforeEach(() => { + sentryVitePluginMock.mockClear(); + sentryUpload.mockReset(); + sentryUpload.mockResolvedValue(); + }); + + afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { force: true, recursive: true }); + } + }); + + describe("resolveSentrySourceMapUploadConfig", () => { + it("returns null when no auth token is set", () => { + expect( + resolveSentrySourceMapUploadConfig({ + SENTRY_ORG: "acme", + SENTRY_PROJECT: "web", + }), + ).toBeNull(); + }); + + it("returns null when a token is set but org/project are missing", () => { + expect( + resolveSentrySourceMapUploadConfig({ SENTRY_AUTH_TOKEN: "tok" }), + ).toBeNull(); + }); + + it("does not accept the numeric SENTRY_PROJECT_ID as a project slug", () => { + expect( + resolveSentrySourceMapUploadConfig({ + SENTRY_AUTH_TOKEN: "tok", + SENTRY_ORG: "acme", + SENTRY_PROJECT_ID: "4511270423822336", + }), + ).toBeNull(); + }); + + it("resolves a full config, falling back to SENTRY_ORG_SLUG", () => { + const config = resolveSentrySourceMapUploadConfig({ + SENTRY_AUTH_TOKEN: "tok", + SENTRY_ORG_SLUG: "acme", + SENTRY_PROJECT: "web", + AGENT_NATIVE_BUILD_ID: "deploy-42", + }); + expect(config).toEqual({ + authToken: "tok", + org: "acme", + project: "web", + url: undefined, + release: "agent-native-client@deploy-42", + }); + }); + }); + + describe("createSentrySourceMapUploadPlugin", () => { + it("returns an empty array and never calls sentryVitePlugin when disabled", () => { + expect(createSentrySourceMapUploadPlugin({})).toEqual([]); + expect(sentryVitePluginMock).not.toHaveBeenCalled(); + }); + + it("calls sentryVitePlugin with the resolved config when enabled", () => { + const plugins = createSentrySourceMapUploadPlugin({ + SENTRY_AUTH_TOKEN: "tok", + SENTRY_ORG: "acme", + SENTRY_PROJECT: "web", + AGENT_NATIVE_BUILD_ID: "deploy-42", + }); + expect(plugins).toHaveLength(2); + const callArgs = sentryVitePluginMock.mock.calls[0][0]; + expect(callArgs).toMatchObject({ + org: "acme", + project: "web", + authToken: "tok", + release: { name: "agent-native-client@deploy-42", inject: false }, + }); + expect(callArgs.sourcemaps).toBeUndefined(); + expect(plugins.at(-1)?.name).toBe( + "agent-native:delete-uploaded-sentry-source-maps", + ); + }); + + it("removes source maps from the publish artifact after upload succeeds", async () => { + const { entryPath, mapPath, publishDirectory } = temporaryBuild(); + sentryUpload.mockImplementation(async () => { + expect(existsSync(mapPath)).toBe(true); + }); + const plugins = createSentrySourceMapUploadPlugin({ + SENTRY_AUTH_TOKEN: "tok", + SENTRY_ORG: "acme", + SENTRY_PROJECT: "web", + }); + + await runViteBuild(entryPath, publishDirectory, plugins); + + expect(sentryUpload).toHaveBeenCalledOnce(); + expect(existsSync(mapPath)).toBe(false); + expect(existsSync(path.join(publishDirectory, "client.js"))).toBe(true); + }); + + it("keeps source maps and rejects when upload fails", async () => { + const { entryPath, mapPath, publishDirectory } = temporaryBuild(); + sentryUpload.mockImplementation(async () => { + expect(existsSync(mapPath)).toBe(true); + throw new Error("upload rejected"); + }); + const plugins = createSentrySourceMapUploadPlugin({ + SENTRY_AUTH_TOKEN: "tok", + SENTRY_ORG: "acme", + SENTRY_PROJECT: "web", + }); + + await expect( + runViteBuild(entryPath, publishDirectory, plugins), + ).rejects.toThrow("upload rejected"); + expect(existsSync(mapPath)).toBe(true); + }); + }); +}); diff --git a/packages/core/src/vite/sentry-source-maps.ts b/packages/core/src/vite/sentry-source-maps.ts new file mode 100644 index 00000000000..959547a1c0d --- /dev/null +++ b/packages/core/src/vite/sentry-source-maps.ts @@ -0,0 +1,130 @@ +/** + * The release name here MUST match what `client/analytics.ts` sends as + * `event.release` at runtime, or uploaded source maps never resolve against + * captured events. Both derive it from the same `resolveAgentNativeBuildId()` + * identifier so they can't drift apart independently. + */ +import { readdir, rm } from "node:fs/promises"; +import path from "node:path"; + +import { sentryVitePlugin } from "@sentry/vite-plugin"; +import type { Plugin } from "vite"; + +import { resolveAgentNativeBuildId } from "../shared/build-id.js"; + +function firstNonEmpty( + ...values: Array +): string | undefined { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) return trimmed; + } + return undefined; +} + +export function resolveSentryClientRelease( + env: Record, +): string { + return `agent-native-client@${resolveAgentNativeBuildId(env, "development")}`; +} + +export interface SentrySourceMapUploadConfig { + authToken: string; + org: string; + project: string; + url?: string; + release: string; +} + +// A token alone can't safely guess org/project, and a half-configured plugin +// would fail every build rather than cleanly no-op. +export function resolveSentrySourceMapUploadConfig( + env: Record = process.env, +): SentrySourceMapUploadConfig | null { + const authToken = firstNonEmpty(env.SENTRY_AUTH_TOKEN); + if (!authToken) return null; + const org = firstNonEmpty(env.SENTRY_ORG, env.SENTRY_ORG_SLUG); + // SENTRY_PROJECT_ID is the numeric DSN project id used elsewhere in this + // repo (sentry-config.ts) — not a valid value for the plugin's `project` + // option, which wants the project slug. Passing the numeric id would + // silently target the wrong project instead of cleanly no-oping. + const project = firstNonEmpty(env.SENTRY_PROJECT, env.SENTRY_CLIENT_PROJECT); + if (!org || !project) return null; + return { + authToken, + org, + project, + url: firstNonEmpty(env.SENTRY_URL), + release: resolveSentryClientRelease(env), + }; +} + +export function isSentrySourceMapUploadEnabled( + env: Record = process.env, +): boolean { + return resolveSentrySourceMapUploadConfig(env) !== null; +} + +async function removeSourceMaps(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + await Promise.all( + entries.map(async (entry) => { + const filePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await removeSourceMaps(filePath); + } else if (entry.name.endsWith(".map")) { + await rm(filePath, { force: true }); + } + }), + ); +} + +function createUploadedSourceMapCleanupPlugin(): Plugin { + return { + name: "agent-native:delete-uploaded-sentry-source-maps", + enforce: "post", + writeBundle: { + order: "post", + sequential: true, + async handler(outputOptions) { + if (outputOptions.dir) { + await removeSourceMaps(outputOptions.dir); + } else if (outputOptions.file) { + await rm(`${outputOptions.file}.map`, { force: true }); + } + }, + }, + }; +} + +// Safe to always include in the plugins array regardless of `vite build` vs +// `vite dev` — `@sentry/vite-plugin`'s hooks only act during a real Rollup +// build. +export function createSentrySourceMapUploadPlugin( + env: Record = process.env, +): Plugin[] { + const config = resolveSentrySourceMapUploadConfig(env); + if (!config) return []; + const uploadPlugins = sentryVitePlugin({ + org: config.org, + project: config.project, + authToken: config.authToken, + url: config.url, + telemetry: false, + release: { + // inject: false — client/analytics.ts already sets `release` itself; + // letting the plugin also inject its own git-SHA-based release would + // create a second, divergent source of truth for the same field. + name: config.release, + inject: false, + }, + // Sentry's built-in filesToDeleteAfterUpload runs in a finally block, even + // after a failed upload. Throw here so the build cannot publish an artifact + // whose maps were neither uploaded nor intentionally retained. + errorHandler: (error) => { + throw error; + }, + }) as Plugin[]; + + return [...uploadPlugins, createUploadedSourceMapCleanupPlugin()]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 094b85a2503..55cd035b975 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -459,6 +459,9 @@ importers: '@sentry/node': specifier: 10.60.0 version: 10.60.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)) + '@sentry/vite-plugin': + specifier: 5.4.0 + version: 5.4.0(rollup@4.62.2) '@shadcn/react': specifier: ^0.2.0 version: 0.2.0(@types/react@19.2.17)(react@19.2.7) @@ -12505,10 +12508,78 @@ packages: resolution: {integrity: sha512-uJi0yPssB3Nt/cZ8/S8opW42gaM59/6IyNtPFYD7C0ciudi/nIo5QMVpCYBBI3jnKFOIQLlsMT4pDlOLuxxNuQ==} engines: {node: '>=18'} + '@sentry/bundler-plugins@10.73.0': + resolution: {integrity: sha512-4X5m2hoqgKO6SxHR7MF9QnvxPNk0hwTJFixDJ/pzQGVM+C34j/rSabouBqd0M+ZdxFeAt/9kA5X0TyeceNo9YQ==} + engines: {node: '>= 18'} + peerDependencies: + rollup: '>=4.59.0' + webpack: '>=5.0.0' + peerDependenciesMeta: + rollup: + optional: true + webpack: + optional: true + + '@sentry/cli-darwin@2.58.6': + resolution: {integrity: sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==} + engines: {node: '>=10'} + os: [darwin] + + '@sentry/cli-linux-arm64@2.58.6': + resolution: {integrity: sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux, freebsd, android] + + '@sentry/cli-linux-arm@2.58.6': + resolution: {integrity: sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux, freebsd, android] + + '@sentry/cli-linux-i686@2.58.6': + resolution: {integrity: sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [linux, freebsd, android] + + '@sentry/cli-linux-x64@2.58.6': + resolution: {integrity: sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux, freebsd, android] + + '@sentry/cli-win32-arm64@2.58.6': + resolution: {integrity: sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@sentry/cli-win32-i686@2.58.6': + resolution: {integrity: sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [win32] + + '@sentry/cli-win32-x64@2.58.6': + resolution: {integrity: sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@sentry/cli@2.58.6': + resolution: {integrity: sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==} + engines: {node: '>= 10'} + hasBin: true + '@sentry/conventions@0.12.0': resolution: {integrity: sha512-z1JQrl/1SLY+8wpzvork6vl+fpsg/oCCxM7HWWhUnI/R+OGNyoIzieQuggX3uUMY7NBtp8UWCQx6FeFazzOF9g==} engines: {node: '>=14'} + '@sentry/conventions@0.16.0': + resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==} + engines: {node: '>=14'} + '@sentry/core@10.60.0': resolution: {integrity: sha512-szN7ccOJAEaLb1BBQzCQhABGMTJmKNUk0G2sc7rWhajeXoZoMKIbNkI9RvJrFuV69cbad/d/BKGBjbpJhySAzw==} engines: {node: '>=18'} @@ -12517,6 +12588,10 @@ packages: resolution: {integrity: sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==} engines: {node: '>=18'} + '@sentry/core@10.73.0': + resolution: {integrity: sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==} + engines: {node: '>=18'} + '@sentry/electron@7.14.0': resolution: {integrity: sha512-H2Ommi2B/I2QwUT2WJW1uqBiuzDXhuv4pw8hkVm63qBnwGZkSH54YdDOAzkXF9bmw3SHaIAjZBFzsUrvFAzgyw==} peerDependencies: @@ -12586,6 +12661,10 @@ packages: resolution: {integrity: sha512-SX+MzWM3nz5ttKT48rlfktm0ERyIpDLma+b6pYeWgW2oFHKcpIu0g0qMGJrZs4lKM3MlgV7IqLa4texMqTp9kQ==} engines: {node: '>=18'} + '@sentry/vite-plugin@5.4.0': + resolution: {integrity: sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA==} + engines: {node: '>= 18'} + '@shadcn/react@0.2.0': resolution: {integrity: sha512-g/LMtyL0eiHCHkOCelhYf32RC5nTMdtUNag1ZQRhSDCW+jnHxDY1Dajk7P6zJosOg8z2N4wzfKOY5sh54QTDYA==} peerDependencies: @@ -16406,6 +16485,10 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -17479,6 +17562,10 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} @@ -18378,6 +18465,10 @@ packages: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-map@2.1.0: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} @@ -18814,6 +18905,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -28315,12 +28409,77 @@ snapshots: '@sentry/replay': 10.62.0 '@sentry/replay-canvas': 10.62.0 + '@sentry/bundler-plugins@10.73.0(rollup@4.62.2)': + dependencies: + '@babel/core': 7.29.7 + '@sentry/cli': 2.58.6 + '@sentry/core': 10.73.0 + dotenv: 17.4.2 + find-up: 5.0.0 + glob: 13.0.6 + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.62.2 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/cli-darwin@2.58.6': + optional: true + + '@sentry/cli-linux-arm64@2.58.6': + optional: true + + '@sentry/cli-linux-arm@2.58.6': + optional: true + + '@sentry/cli-linux-i686@2.58.6': + optional: true + + '@sentry/cli-linux-x64@2.58.6': + optional: true + + '@sentry/cli-win32-arm64@2.58.6': + optional: true + + '@sentry/cli-win32-i686@2.58.6': + optional: true + + '@sentry/cli-win32-x64@2.58.6': + optional: true + + '@sentry/cli@2.58.6': + dependencies: + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + which: 2.0.2 + optionalDependencies: + '@sentry/cli-darwin': 2.58.6 + '@sentry/cli-linux-arm': 2.58.6 + '@sentry/cli-linux-arm64': 2.58.6 + '@sentry/cli-linux-i686': 2.58.6 + '@sentry/cli-linux-x64': 2.58.6 + '@sentry/cli-win32-arm64': 2.58.6 + '@sentry/cli-win32-i686': 2.58.6 + '@sentry/cli-win32-x64': 2.58.6 + transitivePeerDependencies: + - encoding + - supports-color + '@sentry/conventions@0.12.0': {} + '@sentry/conventions@0.16.0': {} + '@sentry/core@10.60.0': {} '@sentry/core@10.62.0': {} + '@sentry/core@10.73.0': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/electron@7.14.0': dependencies: '@sentry/browser': 10.60.0 @@ -28406,6 +28565,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@sentry/vite-plugin@5.4.0(rollup@4.62.2)': + dependencies: + '@sentry/bundler-plugins': 10.73.0(rollup@4.62.2) + transitivePeerDependencies: + - encoding + - rollup + - supports-color + - webpack + '@shadcn/react@0.2.0(@types/react@19.2.17)(react@19.2.7)': optionalDependencies: '@types/react': 19.2.17 @@ -32765,6 +32933,11 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -33895,6 +34068,10 @@ snapshots: dependencies: p-locate: 4.1.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash-es@4.17.21: {} lodash-es@4.18.1: {} @@ -35173,6 +35350,10 @@ snapshots: dependencies: p-limit: 2.3.0 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-map@2.1.0: {} p-map@7.0.4: {} @@ -35656,6 +35837,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} pump@3.0.4: diff --git a/scripts/sync-template-netlify-env.spec.ts b/scripts/sync-template-netlify-env.spec.ts index 19afcc14912..bd8beee93cc 100644 --- a/scripts/sync-template-netlify-env.spec.ts +++ b/scripts/sync-template-netlify-env.spec.ts @@ -5,6 +5,7 @@ import { isForbiddenHostedTemplateEnvKey, normalizeProductionUrlEntry, resolveNetlifyApiContext, + resolveNetlifyEnvScopes, resolveNetlifyTemplateName, } from "./sync-template-netlify-env"; @@ -131,6 +132,24 @@ describe("resolveNetlifyApiContext", () => { }); }); +describe("resolveNetlifyEnvScopes", () => { + it("limits the fleet-wide Sentry upload token to builds", () => { + expect( + resolveNetlifyEnvScopes("SENTRY_AUTH_TOKEN", [ + "builds", + "functions", + "runtime", + ]), + ).toEqual(["builds"]); + }); + + it("preserves configured scopes for other keys", () => { + expect( + resolveNetlifyEnvScopes("SENTRY_DSN", ["functions", "runtime"]), + ).toEqual(["functions", "runtime"]); + }); +}); + describe("resolveNetlifyTemplateName", () => { it("maps the legacy chat template name to the current starter site", () => { expect(resolveNetlifyTemplateName("chat")).toBe("starter"); diff --git a/scripts/sync-template-netlify-env.ts b/scripts/sync-template-netlify-env.ts index 93ef72bfe05..5a59f905cca 100644 --- a/scripts/sync-template-netlify-env.ts +++ b/scripts/sync-template-netlify-env.ts @@ -80,6 +80,7 @@ const TEMPLATE_SITES: TemplateSite[] = Object.entries(NETLIFY_SITES) const SITE_BY_NAME = new Map(TEMPLATE_SITES.map((site) => [site.name, site])); const DEFAULT_SOURCES = [".env", ".env.local"]; const DEFAULT_SCOPES = ["builds", "functions", "runtime"]; +const ENV_SCOPES_BY_KEY = new Map([["SENTRY_AUTH_TOKEN", ["builds"]]]); const DEFAULT_CONTEXT = "production"; const DEFAULT_HOSTED_TEMPLATE_ENV = new Map([ ["GA_MEASUREMENT_ID", "G-ESF7FYXGN9"], @@ -124,7 +125,10 @@ const HOSTED_TEMPLATE_ENV_ALLOWLIST_EXACT = new Set([ "NETLIFY_DATABASE_URL_UNPOOLED", "NITRO_PRESET", "SENDGRID_API_KEY", + "SENTRY_AUTH_TOKEN", "SENTRY_DSN", + "SENTRY_ORG", + "SENTRY_PROJECT", "SENTRY_SERVER_DSN", "SUPABASE_URL", "SUPABASE_ANON_KEY", @@ -137,9 +141,19 @@ const HOSTED_TEMPLATE_ALLOWED_SECRET_EXACT = new Set([ "NETLIFY_DATABASE_URL", "NETLIFY_DATABASE_URL_UNPOOLED", "SENDGRID_API_KEY", + "SENTRY_AUTH_TOKEN", "SENTRY_DSN", "SENTRY_SERVER_DSN", ]); +// Sentry build-time upload credentials are one org/project shared by every +// hosted site, unlike SENTRY_DSN which can vary per site. Pulling them from +// the invoking shell (rather than each template's committed .env) means the +// token is never written to disk in this repo. +const FLEET_WIDE_ENV_KEYS = [ + "SENTRY_AUTH_TOKEN", + "SENTRY_ORG", + "SENTRY_PROJECT", +]; const FORBIDDEN_HOSTED_TEMPLATE_ENV_EXACT = new Set([ "ANTHROPIC_API_KEY", "AMPLITUDE_API_KEY", @@ -164,6 +178,10 @@ const PUBLIC_KEY_EXACT = new Set([ "GOOGLE_PICKER_APP_ID", "NEON_AUTH_BASE_URL", "NITRO_PRESET", + // The org/project slugs identify a Sentry project, not a credential - + // SENTRY_AUTH_TOKEN is the actual secret and stays out of this set. + "SENTRY_ORG", + "SENTRY_PROJECT", "SUPABASE_URL", "SUPABASE_ANON_KEY", "ZOOM_CLIENT_ID", @@ -209,6 +227,10 @@ Options: GA_MEASUREMENT_ID and GTM_CONTAINER_ID default to the hosted Agent-Native analytics configuration unless an env source overrides them. + SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT are + read from this shell's environment (not any template + .env) since they're the same for every hosted site. + SENTRY_AUTH_TOKEN is always scoped to builds only. --help Show this help. Known templates: @@ -369,6 +391,11 @@ function loadTemplateEnv(template: string, sources: string[]) { const foundSources: string[] = []; const sourcesByKey = new Map(); + for (const key of FLEET_WIDE_ENV_KEYS) { + const value = process.env[key]; + if (value) values.set(key, value); + } + for (const source of sources) { const filePath = path.join(REPO_ROOT, "templates", template, source); if (!existsSync(filePath)) continue; @@ -501,6 +528,13 @@ export function resolveNetlifyApiContext(context: string): string { return isBetaContext(context) ? "production" : context; } +export function resolveNetlifyEnvScopes( + key: string, + scopes: string[], +): string[] { + return ENV_SCOPES_BY_KEY.get(key) ?? scopes; +} + function siteIdForContext(site: TemplateSite, context: string): string { if (!isBetaContext(context)) return site.siteId; if (!site.betaSiteId) { @@ -745,7 +779,7 @@ async function main() { accountId: options.accountId!, context: options.context, key, - scopes: options.scopes, + scopes: resolveNetlifyEnvScopes(key, options.scopes), siteId: targetSiteId, token: token!, value,